- Render bucket latency rows inside #bucket-latency-rows (never replace panel); preserve heading + aria-labelledby; role=list/listitem rows - Remove fabricated 1ms floor: sub-ms yields 0, UI shows '<1 ms' - Strict latency types: reject null/bool/str/float/negative/Infinity/huge (frontend + backend); no coercion or clipping - Failed upstream-attempt duration never overwrites prior successful latency; stale-but-measured feeds labeled 'last known' truthfully - Notifications fetch starts immediately (auth-independent), concurrent with shared current_user auth; deterministic concurrency proof - Repair tests: real DOM transitions (browser-verified), production failure shape, panel-hidden assertion, malformed-value matrix
425 lines
18 KiB
Python
425 lines
18 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)
|
|
|
|
|
|
# Shared JS for a mock panel that supports querySelector + appendChild,
|
|
# exercising the real runtime DOM transitions of renderBucketLatency.
|
|
_MAKE_PANEL = """
|
|
function makePanel() {
|
|
const heading = {localName:'h3', id:'bucket-latency-heading'};
|
|
const rows = {innerHTML:'', hidden:false, appendChild(child) { this.innerHTML += (child.outerHTML || String(child)); return child; }, querySelectorAll() { return []; }};
|
|
const panel = {hidden:false, querySelector(sel) { if (sel === '#bucket-latency-heading') return heading; if (sel === '#bucket-latency-rows') return rows; return null; }};
|
|
return {panel, rows, heading};
|
|
}
|
|
"""
|
|
|
|
_REQUIRE = "const status = require(" + json.dumps(str(STATUS)) + ");"
|
|
|
|
|
|
def test_live_data_status_summarizes_each_feed_without_claiming_live():
|
|
script = _REQUIRE + """
|
|
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 = _REQUIRE + """
|
|
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 = _REQUIRE + """
|
|
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 = _REQUIRE + """
|
|
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 = _REQUIRE + """
|
|
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),
|
|
]
|
|
# Strict telemetry: negatives and huge values are rejected, never clipped.
|
|
assert [(feed["key"], feed["latencyMs"]) for feed in result["unbounded"]["feeds"]] == [
|
|
("context", None), ("notifications", None), ("events", None),
|
|
]
|
|
assert all(feed["latencyMs"] is None for feed in result["missing"]["feeds"])
|
|
|
|
|
|
def test_live_data_status_renders_bucket_latency_panel_rows():
|
|
script = _REQUIRE + _MAKE_PANEL + """
|
|
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}
|
|
}});
|
|
const {panel, rows, heading} = makePanel();
|
|
const rowsReturned = status.renderBucketLatency(description, panel);
|
|
process.stdout.write(JSON.stringify({rows:rowsReturned, panelHidden:panel.hidden, html:rows.innerHTML, headingId:heading.id}));
|
|
"""
|
|
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 result["headingId"] == 'bucket-latency-heading'
|
|
assert 'data-bucket="context"' in result["html"]
|
|
assert 'role="listitem"' in result["html"]
|
|
assert result["html"].count('role="listitem"') == 3
|
|
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 = _REQUIRE + _MAKE_PANEL + """
|
|
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}
|
|
}});
|
|
const {panel, rows, heading} = makePanel();
|
|
const rowsReturned = status.renderBucketLatency(description, panel);
|
|
process.stdout.write(JSON.stringify({panelHidden:panel.hidden, html:rows.innerHTML}));
|
|
"""
|
|
result = run_node(script)
|
|
|
|
assert result["panelHidden"] is False
|
|
assert 'data-bucket="events"' in result["html"]
|
|
assert "not measured" in result["html"]
|
|
assert "—" not in result["html"]
|
|
assert 'role="listitem"' in result["html"]
|
|
|
|
|
|
def test_live_data_status_hides_latency_panel_without_any_measurement():
|
|
script = _REQUIRE + _MAKE_PANEL + """
|
|
const description = status.describe({fresh_for_seconds:8, sections:{
|
|
context:{age_seconds:1}, notifications:{age_seconds:2}, events:{age_seconds:3}
|
|
}});
|
|
const {panel, rows, heading} = makePanel();
|
|
const rowsReturned = status.renderBucketLatency(description, panel);
|
|
process.stdout.write(JSON.stringify({panelHidden:panel.hidden, html:rows.innerHTML, headingIntact:heading.id === 'bucket-latency-heading'}));
|
|
"""
|
|
result = run_node(script)
|
|
|
|
assert result["panelHidden"] is True
|
|
assert result["html"] == ""
|
|
# Hiding must never damage the preserved heading / aria-labelledby target.
|
|
assert result["headingIntact"] is True
|
|
|
|
|
|
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
|
|
# Rows must carry role=listitem so the list semantics hold at runtime.
|
|
assert 'role="listitem"' in status_source
|
|
assert "renderBucketLatency" in status_source
|
|
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
|
|
|
|
|
|
def test_live_data_status_latency_lifecycle_no_data_measured_stale():
|
|
"""Real DOM-style lifecycle across three states through the same panel:
|
|
no measurement -> hide (heading intact); measured -> rows with role=listitem;
|
|
stale-but-measured -> rows with 'last known'. The heading and aria-labelledby
|
|
target survive every transition and rows always use role=listitem."""
|
|
script = _REQUIRE + _MAKE_PANEL + """
|
|
const noData = status.describe({fresh_for_seconds:8, sections:{
|
|
context:{age_seconds:1}, notifications:{age_seconds:2}, events:{age_seconds:3}
|
|
}});
|
|
const measured = status.describe({fresh_for_seconds:8, latency_ms:{context:120, notifications:45, events:0}, sections:{
|
|
context:{age_seconds:1}, notifications:{age_seconds:14, stale:true}, events:{age_seconds:3}
|
|
}});
|
|
const p1 = makePanel(); const p2 = makePanel(); const p3 = makePanel();
|
|
status.renderBucketLatency(noData, p1.panel);
|
|
status.renderBucketLatency(measured, p2.panel);
|
|
// stale-but-measured: reuse the same measured description (notifications stale).
|
|
status.renderBucketLatency(measured, p3.panel);
|
|
process.stdout.write(JSON.stringify({
|
|
noData: {hidden:p1.panel.hidden, rowsHtml:p1.rows.innerHTML, headingId:p1.heading.id},
|
|
measured: {hidden:p2.panel.hidden, rowsHtml:p2.rows.innerHTML, listitemCount:(p2.rows.innerHTML.match(/role="listitem"/g)||[]).length},
|
|
staleMeasured: {hidden:p3.panel.hidden, hasLastKnown:p3.rows.innerHTML.includes('last known'), headingId:p3.heading.id},
|
|
}));
|
|
"""
|
|
result = run_node(script)
|
|
|
|
# No data: hidden, empty, heading preserved.
|
|
assert result["noData"]["hidden"] is True
|
|
assert result["noData"]["rowsHtml"] == ""
|
|
assert result["noData"]["headingId"] == "bucket-latency-heading"
|
|
# Measured: shown with 3 listitem rows, heading preserved.
|
|
assert result["measured"]["hidden"] is False
|
|
assert result["measured"]["listitemCount"] == 3
|
|
# Stale-but-measured: still shown with 'last known', heading preserved.
|
|
assert result["staleMeasured"]["hidden"] is False
|
|
assert result["staleMeasured"]["hasLastKnown"] is True
|
|
assert result["staleMeasured"]["headingId"] == "bucket-latency-heading"
|
|
|
|
|
|
def test_live_data_status_strict_latency_telemetry_rejects_malformed_values():
|
|
"""Client telemetry accepts only finite nonnegative bounded integers.
|
|
Rejects null, bool, strings, arrays, floats, negatives, Infinity, and
|
|
huge values without Number() coercion or clipping."""
|
|
script = _REQUIRE + """
|
|
function latencyOf(value) {
|
|
const desc = status.describe({fresh_for_seconds:8, latency_ms:{context:value}, sections:{context:{age_seconds:1}}});
|
|
return desc.feeds[0].latencyMs;
|
|
}
|
|
process.stdout.write(JSON.stringify({
|
|
null: latencyOf(null),
|
|
boolTrue: latencyOf(true),
|
|
boolFalse: latencyOf(false),
|
|
stringNum: latencyOf("120"),
|
|
stringWord: latencyOf("fast"),
|
|
array: latencyOf([120]),
|
|
float: latencyOf(120.9),
|
|
negative: latencyOf(-5),
|
|
infinity: latencyOf(Infinity),
|
|
huge: latencyOf(99999999),
|
|
zero: latencyOf(0),
|
|
valid: latencyOf(450),
|
|
}));
|
|
"""
|
|
result = run_node(script)
|
|
|
|
assert result == {
|
|
"null": None,
|
|
"boolTrue": None,
|
|
"boolFalse": None,
|
|
"stringNum": None,
|
|
"stringWord": None,
|
|
"array": None,
|
|
"float": None,
|
|
"negative": None,
|
|
"infinity": None,
|
|
"huge": None,
|
|
"zero": 0,
|
|
"valid": 450,
|
|
}
|
|
|
|
|
|
def test_live_data_status_renders_submillisecond_honestly():
|
|
"""Sub-millisecond latency (0 ms after rounding) renders as '<1 ms',
|
|
never a fabricated exact '1 ms' floor and never '0 ms'."""
|
|
script = _REQUIRE + _MAKE_PANEL + """
|
|
const description = status.describe({fresh_for_seconds:8, latency_ms:{context:0, notifications:0, events:0}, sections:{
|
|
context:{age_seconds:1}, notifications:{age_seconds:2}, events:{age_seconds:3}
|
|
}});
|
|
const {panel, rows} = makePanel();
|
|
status.renderBucketLatency(description, panel);
|
|
process.stdout.write(JSON.stringify({html:rows.innerHTML}));
|
|
"""
|
|
result = run_node(script)
|
|
|
|
assert "<1 ms" in result["html"]
|
|
assert "1 ms" not in result["html"].replace("<1 ms", "")
|
|
assert "0 ms" not in result["html"]
|
|
assert 'role="listitem"' in result["html"]
|