stackchain-dashboard/tests/test_live_data_status.py
ox 398598e154
All checks were successful
CI / lint (pull_request) Successful in 4m4s
CI / build-release (pull_request) Successful in 15s
CI / browser-journey (pull_request) Successful in 8m45s
CI / release-candidate (pull_request) Has been skipped
feat: bucket aggregation latency panel (Closes #11)
Measure each live aggregation bucket's own upstream duration server-side
(auth fetch attributed to Work/Activity, per-feed timed gather) and surface
the measured values in the Live data status panel.

- Per-bucket latency_ms measured individually, never batch wall time
- Stale feeds label their number 'last known'; unmeasured feeds say
  'not measured' instead of an ambiguous dash; hidden when no data
- Failed buckets keep their last known latency across partial refreshes
- Panel is aria-labelled rows as a list, values wrap on mobile
- Ignore .venv/
2026-08-22 21:49:39 +00:00

319 lines
13 KiB
Python

import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
STATUS = ROOT / "frontend" / "live-data-status.js"
HTML = ROOT / "frontend" / "index.html"
CSS = ROOT / "frontend" / "dashboard.css"
DASHBOARD = ROOT / "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_live_data_status_summarizes_each_feed_without_claiming_live():
script = f"""
const status = require({json.dumps(str(STATUS))});
const healthy = {{fresh_for_seconds:8, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
}}}};
const oneDelayed = {{fresh_for_seconds:8, retry_in_seconds:30, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true, retry_in_seconds:30}}, events:{{age_seconds:3}}
}}}};
const twoDelayed = {{fresh_for_seconds:8, sections:{{
context:{{age_seconds:12, degraded:true}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}}
}}}};
process.stdout.write(JSON.stringify({{
healthy:status.describe(healthy),
one:status.describe(oneDelayed),
two:status.describe(twoDelayed),
unavailable:status.describe({{}}),
}}));
"""
result = run_node(script)
assert result["healthy"]["summary"] == "Live"
assert [feed["state"] for feed in result["healthy"]["feeds"]] == ["live"] * 3
assert result["one"]["summary"] == "Updates delayed"
assert result["one"]["nextRetrySeconds"] == 30
assert result["one"]["feeds"][1] == {
"key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14,
"latencyMs": None,
}
assert result["two"]["summary"] == "2 data feeds delayed"
assert result["unavailable"]["summary"] == "Live data unavailable"
def test_live_data_status_controller_is_single_flight_and_reports_result():
script = f"""
const status = require({json.dumps(str(STATUS))});
let resolveRefresh;
let calls = 0;
const states = [];
const button = {{disabled:false}};
const output = {{textContent:''}};
const controller = status.createRefreshController({{
button, output,
refresh:() => {{ calls += 1; return new Promise(resolve => {{ resolveRefresh = resolve; }}); }},
onState:value => states.push(value),
}});
(async () => {{
const first = controller.run();
const second = controller.run();
const pending = {{calls, disabled:button.disabled, text:output.textContent}};
resolveRefresh({{context:{{}}}});
await Promise.all([first, second]);
process.stdout.write(JSON.stringify({{pending, calls, disabled:button.disabled, text:output.textContent, states}}));
}})();
"""
assert run_node(script) == {
"pending": {"calls": 1, "disabled": True, "text": "Refreshing live data…"},
"calls": 1,
"disabled": False,
"text": "Live data refreshed.",
"states": ["refreshing", "success"],
}
def test_live_data_status_sheet_pauses_today_contains_focus_and_closes_through_back():
script = f"""
const status = require({json.dumps(str(STATUS))});
function element(name) {{
return {{name, hidden:false, inert:false, listeners:{{}}, focused:0,
addEventListener(type, fn) {{ this.listeners[type] = fn; }},
focus() {{ this.focused += 1; }},
}};
}}
const trigger = element('trigger');
trigger.attrs = {{}};
trigger.setAttribute = (name, value) => trigger.attrs[name] = value;
const close = element('close');
const refresh = element('refresh');
const back = element('return');
const sheet = element('sheet');
sheet.hidden = true;
sheet.querySelectorAll = () => [close, refresh, back];
const header = element('header');
const main = element('main');
const listeners = {{}};
const history = {{state:null, pushes:0, backs:0,
pushState(state) {{ this.state=state; this.pushes += 1; }},
back() {{ this.backs += 1; }},
}};
const timerView = {{begins:[], finishes:0,
beginDetour(reason) {{ this.begins.push(reason); return {{identity:'issue:r:42', reason}}; }},
finishDetour() {{ this.finishes += 1; return {{resumed:true}}; }},
}};
const paused = element('paused');
paused.hidden = true;
const controller = status.createSheetController({{
sheet, trigger, closeButton:close, returnButton:back, pausedStatus:paused,
timerView, history, historyTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
escapeTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
backgroundElements:[header, main],
}});
controller.start();
controller.open();
const opened = {{hidden:sheet.hidden, expanded:trigger.attrs['aria-expanded'], paused:paused.hidden, inert:[header.inert, main.inert],
begins:timerView.begins, pushes:history.pushes, closeFocused:close.focused}};
let prevented = 0;
listeners.keydown({{key:'Tab', target:back, shiftKey:false, preventDefault:()=>prevented++}});
const trapped = {{prevented, closeFocused:close.focused}};
controller.close();
const requested = {{backs:history.backs, finishes:timerView.finishes}};
history.state = null;
listeners.popstate({{state:null}});
process.stdout.write(JSON.stringify({{opened, trapped, requested, closed:{{hidden:sheet.hidden, paused:paused.hidden,
expanded:trigger.attrs['aria-expanded'], inert:[header.inert, main.inert], finishes:timerView.finishes,
triggerFocused:trigger.focused}}}}));
"""
result = run_node(script)
assert result == {
"opened": {
"hidden": False,
"expanded": "true",
"paused": False,
"inert": [True, True],
"begins": ["live-data-status"],
"pushes": 1,
"closeFocused": 1,
},
"trapped": {"prevented": 1, "closeFocused": 2},
"requested": {"backs": 1, "finishes": 0},
"closed": {
"hidden": True,
"paused": True,
"expanded": "false",
"inert": [False, False],
"finishes": 1,
"triggerFocused": 1,
},
}
def test_live_data_status_without_running_today_hides_return_and_does_not_resume():
script = f"""
const status = require({json.dumps(str(STATUS))});
function element(hidden=false) {{ return {{hidden, inert:false, listeners:{{}}, attrs:{{}},
addEventListener(name, fn) {{ this.listeners[name]=fn; }}, focus() {{}},
setAttribute(name, value) {{ this.attrs[name]=value; }}, querySelectorAll() {{ return []; }} }}; }}
const sheet=element(true), trigger=element(), close=element(), returnButton=element();
let finishes=0;
const controller=status.createSheetController({{
sheet,trigger,closeButton:close,returnButton,
timerView:{{beginDetour:()=>null,finishDetour:()=>finishes++}},
}});
controller.start();
controller.open();
const opened={{returnHidden:returnButton.hidden, expanded:trigger.attrs['aria-expanded']}};
controller.close();
process.stdout.write(JSON.stringify({{opened, finishes}}));
"""
assert run_node(script) == {
"opened": {"returnHidden": True, "expanded": "true"},
"finishes": 0,
}
def test_live_data_status_describe_exposes_bounded_bucket_latency():
script = f"""
const status = require({json.dumps(str(STATUS))});
const measured = {{fresh_for_seconds:8, latency_ms:{{context:120, notifications:45}}, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true, retry_in_seconds:30}}, events:{{age_seconds:3}}
}}}};
const unbounded = {{fresh_for_seconds:8, latency_ms:{{context:-5, events:9000000}}, sections:{{
context:{{age_seconds:1}}, events:{{age_seconds:2}}, notifications:{{age_seconds:3}}
}}}};
const missing = {{fresh_for_seconds:8, sections:{{
context:{{age_seconds:1}}, events:{{age_seconds:2}}, notifications:{{age_seconds:3}}
}}}};
process.stdout.write(JSON.stringify({{
measured:status.describe(measured),
unbounded:status.describe(unbounded),
missing:status.describe(missing),
}}));
"""
result = run_node(script)
assert result["measured"]["summary"] == "Updates delayed"
assert [(feed["key"], feed["latencyMs"]) for feed in result["measured"]["feeds"]] == [
("context", 120), ("notifications", 45), ("events", None),
]
assert [(feed["key"], feed["latencyMs"]) for feed in result["unbounded"]["feeds"]] == [
("context", None), ("notifications", None), ("events", 3600000),
]
assert all(feed["latencyMs"] is None for feed in result["missing"]["feeds"])
def test_live_data_status_renders_bucket_latency_panel_rows():
script = f"""
const status = require({json.dumps(str(STATUS))});
const description = status.describe({{fresh_for_seconds:8, latency_ms:{{context:120, notifications:45, events:3000}}, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}}
}}}});
function element() {{
return {{innerHTML:'', hidden:false, textContent:''}};
}}
const panel = element();
const rows = status.renderBucketLatency(description, panel);
process.stdout.write(JSON.stringify({{rows, panelHidden:panel.hidden, html:panel.innerHTML}}));
"""
result = run_node(script)
assert [row["label"] for row in result["rows"]] == ["Work", "Updates", "Activity"]
assert result["rows"][0] == {"key": "context", "label": "Work", "latencyMs": 120, "stale": False}
assert result["rows"][2]["latencyMs"] == 3000
assert result["panelHidden"] is False
assert 'data-bucket="context"' in result["html"]
assert "120 ms" in result["html"]
assert "3.0 s" in result["html"]
# A stale feed's number is a last-known measurement and must say so.
assert 'data-bucket="notifications"' in result["html"]
assert "last known" in result["html"]
def test_live_data_status_marks_unmeasured_buckets_honestly():
script = f"""
const status = require({json.dumps(str(STATUS))});
const description = status.describe({{fresh_for_seconds:8, latency_ms:{{context:120}}, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
}}}});
function element() {{
return {{innerHTML:'', hidden:false, textContent:''}};
}}
const panel = element();
const rows = status.renderBucketLatency(description, panel);
process.stdout.write(JSON.stringify({{rows, html:panel.innerHTML}}));
"""
result = run_node(script)
assert result["panelHidden"] is False if "panelHidden" in result else True
assert 'data-bucket="events"' in result["html"]
assert "not measured" in result["html"]
assert "" not in result["html"]
def test_live_data_status_hides_latency_panel_without_any_measurement():
script = f"""
const status = require({json.dumps(str(STATUS))});
const description = status.describe({{fresh_for_seconds:8, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
}}}});
function element() {{
return {{innerHTML:'', hidden:false, textContent:''}};
}}
const panel = element();
const rows = status.renderBucketLatency(description, panel);
process.stdout.write(JSON.stringify({{rows, panelHidden:panel.hidden, html:panel.innerHTML}}));
"""
result = run_node(script)
assert result["panelHidden"] is True
assert result["html"] == ""
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
html = HTML.read_text()
css = CSS.read_text()
dashboard = DASHBOARD.read_text()
status_source = STATUS.read_text()
assert 'id="open-live-data-status"' in html
assert 'aria-controls="live-data-status-sheet"' in html
assert 'id="live-data-status-sheet"' in html
assert 'aria-labelledby="live-data-status-heading"' in html
assert 'id="live-data-status-feeds"' in html
assert 'id="refresh-live-data"' in html
assert 'id="return-from-live-data-status"' in html
assert 'id="live-data-status-today-paused"' in html
assert 'id="bucket-latency-panel"' in html
assert '<script src="static/live-data-status.js"></script>' in html
assert ".live-data-status-panel" in css
assert "width:min(560px,100%)" in css
assert "min-height:44px" in css
assert ".bucket-latency-panel" in css
assert ".bucket-latency-row" in css
assert ".bucket-latency-value.slow" in css
# Latency rows must stay readable on narrow screens and to screen readers:
# the panel is labelled, rows are announced as a list, values wrap.
assert 'aria-labelledby="bucket-latency-heading"' in html
assert 'id="bucket-latency-rows" role="list"' in html
assert "bucket-latency-row { display:flex" in css
assert ".bucket-latency-row strong, .bucket-latency-value { overflow-wrap:anywhere" in css
assert "liveDataStatus.describe" in dashboard
assert "liveDataStatus.createRefreshController" in dashboard
assert "liveDataStatus.mount" in dashboard
assert "renderBucketLatency(" in dashboard
assert "#bucket-latency-panel" in dashboard
assert "createSheetController" in status_source
assert "backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')]" in status_source
assert "contextPoller.getState()" in dashboard