543 lines
17 KiB
Python
543 lines
17 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
|
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.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_adopts_progressive_snapshot_before_revision_conditional_refresh():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const requested = [];
|
|
const rendered = [];
|
|
const timers = [];
|
|
const seed = {{
|
|
context:{{user:{{login:'timmy'}}}}, events:[{{id:7}}], notifications:[],
|
|
revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2',notifications:'0011223344556677.3'}},
|
|
freshness:{{fresh_for_seconds:8,sections:{{
|
|
context:{{degraded:false,age_seconds:2}},events:{{degraded:false,age_seconds:5}},notifications:{{degraded:false,age_seconds:1}},
|
|
}}}},
|
|
}};
|
|
const poller = createContextPoller({{
|
|
fetchContext: revisions => {{
|
|
requested.push({{...revisions}});
|
|
return Promise.resolve({{revisions:{{...revisions}},freshness:seed.freshness}});
|
|
}},
|
|
onSnapshot: (snapshot, changed) => rendered.push({{login:snapshot.context.user.login,changed}}),
|
|
onError: error => {{ throw error; }},
|
|
setTimer: (callback, delay) => {{ const timer={{callback,delay}}; timers.push(timer); return timer; }},
|
|
clearTimer: () => {{}},
|
|
intervalMs:8000,
|
|
}});
|
|
(async()=>{{
|
|
const adopted=poller.adopt(seed);
|
|
const before={{adopted,requests:requested.length,rendered:[...rendered],delay:timers[0].delay}};
|
|
timers[0].callback();
|
|
await new Promise(resolve=>setImmediate(resolve));
|
|
process.stdout.write(JSON.stringify({{before,requested}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"before": {
|
|
"adopted": True,
|
|
"requests": 0,
|
|
"rendered": [{
|
|
"login": "timmy",
|
|
"changed": ["context", "events", "notifications"],
|
|
}],
|
|
"delay": 3000,
|
|
},
|
|
"requested": [{
|
|
"context": "0123456789abcdef.1",
|
|
"events": "fedcba9876543210.2",
|
|
"notifications": "0011223344556677.3",
|
|
}],
|
|
}
|
|
|
|
|
|
def test_context_poller_adopts_inflight_progressive_snapshot_without_fetching():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
let calls=0; const rendered=[]; const timers=[];
|
|
const snapshot={{context:{{user:{{login:'timmy'}}}},events:[],notifications:[]}};
|
|
const poller=createContextPoller({{
|
|
fetchContext:()=>{{calls+=1;return Promise.resolve(snapshot);}},
|
|
onSnapshot:value=>rendered.push(value.context.user.login),
|
|
onError:error=>{{throw error;}},
|
|
setTimer:(callback,delay)=>{{const timer={{callback,delay}};timers.push(timer);return timer;}},
|
|
clearTimer:()=>{{}}, setDeadlineTimer:()=>1, clearDeadlineTimer:()=>{{}}, intervalMs:8000,
|
|
}});
|
|
(async()=>{{
|
|
const adopted=await poller.adoptPending(Promise.resolve(snapshot));
|
|
process.stdout.write(JSON.stringify({{adopted,calls,rendered,delay:timers[0].delay}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"adopted": True,
|
|
"calls": 0,
|
|
"rendered": ["timmy"],
|
|
"delay": 8000,
|
|
}
|
|
|
|
|
|
def test_dashboard_adopts_progressive_snapshot_or_falls_back_to_immediate_load():
|
|
source = DASHBOARD.read_text()
|
|
|
|
assert "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source
|
|
assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source
|
|
assert "if (!adoptedProgressiveSnapshot) await load();" in source
|
|
|
|
|
|
def test_context_poller_uses_failed_section_retry_before_healthy_freshness_deadline():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const delays = [];
|
|
const poller = createContextPoller({{
|
|
fetchContext: () => Promise.resolve({{
|
|
context: null, events: [], notifications: [],
|
|
freshness: {{
|
|
fresh_for_seconds: 8,
|
|
retry_in_seconds: 5,
|
|
sections: {{
|
|
context: {{ degraded: true, retry_in_seconds: 5, age_seconds: 12 }},
|
|
events: {{ degraded: false, age_seconds: 0 }},
|
|
notifications: {{ degraded: false, age_seconds: 0 }},
|
|
}},
|
|
}},
|
|
}}),
|
|
onSnapshot: () => {{}},
|
|
onError: error => {{ throw error; }},
|
|
setTimer: (_callback, delay) => {{ delays.push(delay); return delays.length; }},
|
|
clearTimer: () => {{}},
|
|
setDeadlineTimer: () => 1,
|
|
clearDeadlineTimer: () => {{}},
|
|
intervalMs: 8000,
|
|
}});
|
|
(async () => {{
|
|
await poller.refresh();
|
|
process.stdout.write(JSON.stringify({{delays}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {"delays": [5000]}
|
|
|
|
|
|
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const delays = [];
|
|
const poller = createContextPoller({{
|
|
fetchContext: () => Promise.resolve({{
|
|
context: {{}}, events: [], notifications: [],
|
|
freshness: {{
|
|
fresh_for_seconds: 8,
|
|
retry_in_seconds: 60,
|
|
sections: {{
|
|
context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
|
events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
|
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
|
}},
|
|
}},
|
|
}}),
|
|
onSnapshot: () => {{}},
|
|
onError: error => {{ throw error; }},
|
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
|
clearTimer: () => {{}},
|
|
intervalMs: 8000,
|
|
}});
|
|
(async () => {{
|
|
await poller.start();
|
|
process.stdout.write(JSON.stringify({{ delays }}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {"delays": [60000]}
|
|
|
|
|
|
def test_context_poller_uses_earliest_healthy_section_freshness_deadline():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const delays = [];
|
|
const poller = createContextPoller({{
|
|
fetchContext: () => Promise.resolve({{
|
|
context: {{}}, events: [], notifications: [],
|
|
freshness: {{
|
|
fresh_for_seconds: 8,
|
|
retry_in_seconds: 60,
|
|
sections: {{
|
|
context: {{ degraded: false, age_seconds: 2 }},
|
|
events: {{ degraded: false, age_seconds: 5 }},
|
|
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
|
}},
|
|
}},
|
|
}}),
|
|
onSnapshot: () => {{}},
|
|
onError: error => {{ throw error; }},
|
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
|
clearTimer: () => {{}},
|
|
intervalMs: 8000,
|
|
}});
|
|
(async () => {{
|
|
await poller.start();
|
|
process.stdout.write(JSON.stringify({{ delays }}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {"delays": [3000]}
|
|
|
|
|
|
def test_context_poller_does_not_spin_while_stale_sections_are_revalidating():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const delays = [];
|
|
const poller = createContextPoller({{
|
|
fetchContext: () => Promise.resolve({{
|
|
context: {{}}, events: [], notifications: [],
|
|
freshness: {{
|
|
fresh_for_seconds: 8,
|
|
sections: {{
|
|
context: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
|
events: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
|
notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
|
}},
|
|
}},
|
|
}}),
|
|
onSnapshot: () => {{}},
|
|
onError: error => {{ throw error; }},
|
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
|
clearTimer: () => {{}},
|
|
intervalMs: 8000,
|
|
}});
|
|
(async () => {{
|
|
await poller.start();
|
|
process.stdout.write(JSON.stringify({{ delays }}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {"delays": [8000]}
|
|
|
|
|
|
def test_context_poller_honors_http_retry_after_delay():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const delays = [];
|
|
const errors = [];
|
|
const unavailable = new Error('HTTP 503');
|
|
unavailable.retryAfterMs = 45000;
|
|
const poller = createContextPoller({{
|
|
fetchContext: () => Promise.reject(unavailable),
|
|
onSnapshot: () => {{}},
|
|
onError: error => errors.push(error.message),
|
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
|
clearTimer: () => {{}},
|
|
intervalMs: 8000,
|
|
}});
|
|
(async () => {{
|
|
await poller.start();
|
|
process.stdout.write(JSON.stringify({{ delays, errors }}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]}
|
|
|
|
|
|
def test_retry_after_parser_accepts_bounded_delta_seconds():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
process.stdout.write(JSON.stringify({{
|
|
valid: createContextPoller.retryAfterMs('45'),
|
|
zero: createContextPoller.retryAfterMs('0'),
|
|
invalid: createContextPoller.retryAfterMs('later'),
|
|
excessive: createContextPoller.retryAfterMs('999999'),
|
|
}}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"valid": 45000,
|
|
"zero": None,
|
|
"invalid": None,
|
|
"excessive": 300000,
|
|
}
|
|
|
|
|
|
def test_context_poller_backs_off_transport_failures_and_resets_after_success():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const delays = [];
|
|
let calls = 0;
|
|
const poller = createContextPoller({{
|
|
fetchContext: () => {{
|
|
calls += 1;
|
|
if (calls < 6) return Promise.reject(new TypeError('network unavailable'));
|
|
return Promise.resolve({{ context: {{}}, events: [], notifications: [] }});
|
|
}},
|
|
onSnapshot: () => {{}},
|
|
onError: () => {{}},
|
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
|
clearTimer: () => {{}},
|
|
intervalMs: 8000,
|
|
maxBackoffMs: 60000,
|
|
}});
|
|
(async () => {{
|
|
await poller.refresh();
|
|
await poller.refresh();
|
|
await poller.refresh();
|
|
await poller.refresh();
|
|
await poller.refresh();
|
|
await poller.refresh();
|
|
process.stdout.write(JSON.stringify({{ delays }}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"delays": [8000, 16000, 32000, 60000, 60000, 8000]
|
|
}
|
|
|
|
|
|
def test_live_fetch_passes_retry_after_to_the_poller_error():
|
|
source = DASHBOARD.read_text()
|
|
|
|
assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source
|
|
assert "error.retryAfterMs" in source
|
|
|
|
|
|
def test_live_revision_query_preserves_bounded_opaque_tokens():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const query = createContextPoller.buildRevisionQuery({{
|
|
context: '0123456789abcdef.12',
|
|
events: 'fedcba9876543210.3',
|
|
notifications: 'not-a-token',
|
|
extra: '0123456789abcdef.1',
|
|
}});
|
|
process.stdout.write(JSON.stringify({{ query }}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"query": "context_revision=0123456789abcdef.12&events_revision=fedcba9876543210.3"
|
|
}
|
|
|
|
|
|
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": [],
|
|
}
|