Bucket aggregation latency panel #1282
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
.release-engine/
|
.release-engine/
|
||||||
.stackchain-state/
|
.stackchain-state/
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||||
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
|
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
|
||||||
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||||
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
|
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
|
||||||
|
.bucket-latency-panel { margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||||
|
.bucket-latency-panel h3 { margin:0 0 8px; font-weight:normal; }
|
||||||
|
.bucket-latency-row { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:32px; border-top:1px solid #2a496e; }
|
||||||
|
.bucket-latency-row:first-child { border-top:none; }
|
||||||
|
.bucket-latency-row strong, .bucket-latency-value { overflow-wrap:anywhere; }
|
||||||
|
.bucket-latency-value { color:#bbf7d0; }
|
||||||
|
.bucket-latency-value.slow { color:#fca5a5; }
|
||||||
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||||
.app-menu { margin-left:auto; }
|
.app-menu { margin-left:auto; }
|
||||||
.app-menu > summary { display:none; }
|
.app-menu > summary { display:none; }
|
||||||
|
|
|
||||||
|
|
@ -7663,6 +7663,7 @@
|
||||||
escapeHtml(feed.state === 'live' ? 'Live · ' + feedAge(feed) :
|
escapeHtml(feed.state === 'live' ? 'Live · ' + feedAge(feed) :
|
||||||
feed.state === 'refreshing' ? 'Refreshing · ' + feedAge(feed) : 'Delayed · ' + feedAge(feed)) + '</span></div>'
|
feed.state === 'refreshing' ? 'Refreshing · ' + feedAge(feed) : 'Delayed · ' + feedAge(feed)) + '</span></div>'
|
||||||
).join('');
|
).join('');
|
||||||
|
liveDataStatus.renderBucketLatency(description, qs('#bucket-latency-panel'));
|
||||||
const pollState = contextPoller.getState();
|
const pollState = contextPoller.getState();
|
||||||
const retrySeconds = description.nextRetrySeconds || (pollState.nextRetryAt ?
|
const retrySeconds = description.nextRetrySeconds || (pollState.nextRetryAt ?
|
||||||
Math.max(1, Math.ceil((pollState.nextRetryAt - Date.now()) / 1000)) : null);
|
Math.max(1, Math.ceil((pollState.nextRetryAt - Date.now()) / 1000)) : null);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@
|
||||||
</div>
|
</div>
|
||||||
<p id="live-data-status-today-paused" class="live-data-status-today-paused" role="status" hidden>Today paused while you check live data.</p>
|
<p id="live-data-status-today-paused" class="live-data-status-today-paused" role="status" hidden>Today paused while you check live data.</p>
|
||||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||||
|
<div id="bucket-latency-panel" class="bucket-latency-panel" aria-labelledby="bucket-latency-heading" hidden>
|
||||||
|
<h3 id="bucket-latency-heading" class="small muted">Bucket aggregation latency</h3>
|
||||||
|
<div id="bucket-latency-rows" role="list"></div>
|
||||||
|
</div>
|
||||||
<p id="live-data-status-retry" class="small muted"></p>
|
<p id="live-data-status-retry" class="small muted"></p>
|
||||||
<div class="live-data-status-actions">
|
<div class="live-data-status-actions">
|
||||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,42 @@
|
||||||
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boundedLatencyMs(value) {
|
||||||
|
// Strict acceptance only: actual finite nonnegative bounded integers.
|
||||||
|
// Rejects null, booleans, strings, arrays, floats, fractions, negatives,
|
||||||
|
// Infinity, and huge values. No Number() coercion, no clipping.
|
||||||
|
if (typeof value !== 'number') return null;
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
if (!Number.isInteger(value)) return null;
|
||||||
|
if (value < 0 || value > 3600000) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latencyBuckets = [
|
||||||
|
['context', 'Work'],
|
||||||
|
['notifications', 'Updates'],
|
||||||
|
['events', 'Activity'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function bucketLatencies(freshness = {}) {
|
||||||
|
const measured = freshness.latency_ms || {};
|
||||||
|
return new Map(
|
||||||
|
latencyBuckets
|
||||||
|
.map(([key]) => [key, boundedLatencyMs(measured[key])])
|
||||||
|
.filter(([, value]) => value !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function describe(freshness = {}) {
|
function describe(freshness = {}) {
|
||||||
const sections = freshness.sections || {};
|
const sections = freshness.sections || {};
|
||||||
|
const latencies = bucketLatencies(freshness);
|
||||||
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
||||||
const described = feeds.map(([key, label]) => {
|
const described = feeds.map(([key, label]) => {
|
||||||
const section = sections[key] || {};
|
const section = sections[key] || {};
|
||||||
const state = section.revalidating ? 'refreshing' :
|
const state = section.revalidating ? 'refreshing' :
|
||||||
(section.stale || section.degraded ? 'delayed' : 'live');
|
(section.stale || section.degraded ? 'delayed' : 'live');
|
||||||
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
|
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds),
|
||||||
|
latencyMs: latencies.get(key) ?? null };
|
||||||
});
|
});
|
||||||
const delayed = described.filter(feed => feed.state === 'delayed');
|
const delayed = described.filter(feed => feed.state === 'delayed');
|
||||||
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
||||||
|
|
@ -43,6 +71,57 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLatency(ms) {
|
||||||
|
if (ms === null) return 'not measured';
|
||||||
|
if (ms === 0) return '<1 ms';
|
||||||
|
if (ms >= 10000) return Math.round(ms / 1000) + ' s';
|
||||||
|
if (ms >= 1000) return (ms / 1000).toFixed(1) + ' s';
|
||||||
|
return ms + ' ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBucketLatency(description, panel) {
|
||||||
|
// Render rows *inside* the existing #bucket-latency-rows container so the
|
||||||
|
// panel heading and its aria-labelledby target are never replaced. Each
|
||||||
|
// generated row carries role=listitem so the list semantics hold.
|
||||||
|
const rowsContainer = (typeof panel.querySelector === 'function')
|
||||||
|
? (panel.querySelector('#bucket-latency-rows') || panel)
|
||||||
|
: panel;
|
||||||
|
if (rowsContainer !== panel) {
|
||||||
|
rowsContainer.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
panel.innerHTML = '';
|
||||||
|
}
|
||||||
|
const rows = description.feeds.map(feed => ({
|
||||||
|
key: feed.key,
|
||||||
|
label: feed.label,
|
||||||
|
latencyMs: feed.latencyMs,
|
||||||
|
stale: feed.state === 'delayed',
|
||||||
|
}));
|
||||||
|
if (!rows.some(row => row.latencyMs !== null)) {
|
||||||
|
panel.hidden = true;
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
panel.hidden = false;
|
||||||
|
for (const row of rows) {
|
||||||
|
const slow = row.latencyMs !== null && row.latencyMs >= 2000 ? ' slow' : '';
|
||||||
|
const value = formatLatency(row.latencyMs);
|
||||||
|
// A stale feed shows the last measurement that succeeded, so say so.
|
||||||
|
const suffix = row.stale && row.latencyMs !== null
|
||||||
|
? ' <span class="small muted">last known</span>' : '';
|
||||||
|
const html = '<div class="bucket-latency-row' + slow + '" role="listitem" data-bucket="' +
|
||||||
|
row.key + '"><strong>' + row.label + '</strong>' +
|
||||||
|
'<span class="bucket-latency-value' + slow + '">' + value + suffix + '</span></div>';
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = html;
|
||||||
|
rowsContainer.appendChild(wrapper.firstChild);
|
||||||
|
} else {
|
||||||
|
rowsContainer.appendChild({outerHTML: html});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
||||||
let pending = null;
|
let pending = null;
|
||||||
function run() {
|
function run() {
|
||||||
|
|
@ -181,5 +260,5 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { describe, createRefreshController, createSheetController, mount };
|
return { describe, renderBucketLatency, createRefreshController, createSheetController, mount };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
124
src/main.py
124
src/main.py
|
|
@ -15,6 +15,7 @@ from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from urllib.parse import urlencode, urlsplit
|
from urllib.parse import urlencode, urlsplit
|
||||||
|
|
||||||
|
|
@ -328,6 +329,46 @@ BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||||
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
||||||
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
|
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
|
||||||
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
|
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
|
||||||
|
LIVE_SNAPSHOT_LATENCY_MAX_MS = 3_600_000
|
||||||
|
|
||||||
|
|
||||||
|
def _measured_latency_ms(elapsed_seconds: float) -> int | None:
|
||||||
|
"""Convert a measured elapsed duration to a strict latency integer.
|
||||||
|
|
||||||
|
Resolution is honest: sub-millisecond work yields 0 (rendered client-side
|
||||||
|
as '<1 ms'), never a fabricated 1 ms floor. Only finite, nonnegative,
|
||||||
|
bounded integer milliseconds are returned; any non-finite or out-of-range
|
||||||
|
measurement is rejected as None so malformed values are never coerced or
|
||||||
|
clipped into a measurement.
|
||||||
|
"""
|
||||||
|
if not math.isfinite(elapsed_seconds) or elapsed_seconds < 0:
|
||||||
|
return None
|
||||||
|
ms = round(elapsed_seconds * 1000)
|
||||||
|
if not isinstance(ms, int) or isinstance(ms, bool):
|
||||||
|
return None
|
||||||
|
if ms < 0 or ms > LIVE_SNAPSHOT_LATENCY_MAX_MS:
|
||||||
|
return None
|
||||||
|
return ms
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_latency_ms(value: Any) -> bool:
|
||||||
|
"""Strict check for a valid telemetry latency integer.
|
||||||
|
|
||||||
|
Accepts only actual finite nonnegative bounded integers. Rejects None,
|
||||||
|
booleans, strings, arrays, floats, fractions, negatives, Infinity, and
|
||||||
|
huge values. Malformed values are never coerced or clipped.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return False
|
||||||
|
if not isinstance(value, int):
|
||||||
|
return False
|
||||||
|
if not math.isfinite(value): # int is always finite, but be defensive
|
||||||
|
return False
|
||||||
|
if value < 0 or value > LIVE_SNAPSHOT_LATENCY_MAX_MS:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
|
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
|
||||||
AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
|
AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
|
||||||
AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
|
AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
|
||||||
|
|
@ -4045,27 +4086,57 @@ async def _build_live_snapshot(sections: set[str] | None = None) -> dict:
|
||||||
requested = set(sections or LIVE_SNAPSHOT_SECTIONS)
|
requested = set(sections or LIVE_SNAPSHOT_SECTIONS)
|
||||||
results: dict[str, object] = {}
|
results: dict[str, object] = {}
|
||||||
user_data: dict | None = None
|
user_data: dict | None = None
|
||||||
if requested & {"context", "events"}:
|
latency_ms: dict[str, int] = {}
|
||||||
|
# Notifications is auth-independent and may start on the same tick as the
|
||||||
|
# shared current_user() fetch; Work (context) and Activity (events) await
|
||||||
|
# that shared auth, then run their dependent calls. The auth duration is a
|
||||||
|
# real upstream cost attributed to both the Work and Activity buckets.
|
||||||
|
auth_sections = requested & {"context", "events"}
|
||||||
|
auth_start = perf_counter()
|
||||||
|
auth_result: dict | BaseException | None = None
|
||||||
|
notifications_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def _timed_load(section: str, awaitable: Awaitable[Any]) -> Any:
|
||||||
|
start = perf_counter()
|
||||||
|
try:
|
||||||
|
outcome = await awaitable
|
||||||
|
except BaseException as exc:
|
||||||
|
return exc
|
||||||
|
measured = _measured_latency_ms(perf_counter() - start)
|
||||||
|
if measured is not None:
|
||||||
|
latency_ms[section] = latency_ms.get(section, 0) + measured
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
if "notifications" in requested:
|
||||||
|
notifications_task = asyncio.create_task(_timed_load("notifications", notifications()))
|
||||||
|
|
||||||
|
if auth_sections:
|
||||||
try:
|
try:
|
||||||
user_data = await current_user()
|
user_data = await current_user()
|
||||||
if not isinstance(user_data, dict) or not user_data.get("login"):
|
if not isinstance(user_data, dict) or not user_data.get("login"):
|
||||||
raise ContextPayloadError("Gitea current-user response was invalid")
|
raise ContextPayloadError("Gitea current-user response was invalid")
|
||||||
|
auth_result = user_data
|
||||||
|
auth_ms = _measured_latency_ms(perf_counter() - auth_start)
|
||||||
|
if auth_ms is not None:
|
||||||
|
for section in auth_sections:
|
||||||
|
latency_ms[section] = latency_ms.get(section, 0) + auth_ms
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
for section in requested & {"context", "events"}:
|
auth_result = exc
|
||||||
|
for section in auth_sections:
|
||||||
results[section] = exc
|
results[section] = exc
|
||||||
|
else:
|
||||||
|
auth_result = None
|
||||||
|
|
||||||
loads: dict[str, Awaitable[Any]] = {}
|
loads: dict[str, Awaitable[Any]] = {}
|
||||||
if "context" in requested and "context" not in results:
|
if "context" in requested and "context" not in results and isinstance(auth_result, dict):
|
||||||
assert user_data is not None
|
loads["context"] = _timed_load("context", _load_context_for_user(auth_result))
|
||||||
loads["context"] = _load_context_for_user(user_data)
|
if "events" in requested and "events" not in results and isinstance(auth_result, dict):
|
||||||
if "events" in requested and "events" not in results:
|
loads["events"] = _timed_load("events", activity_events(auth_result))
|
||||||
assert user_data is not None
|
|
||||||
loads["events"] = activity_events(user_data)
|
|
||||||
if "notifications" in requested:
|
|
||||||
loads["notifications"] = notifications()
|
|
||||||
if loads:
|
if loads:
|
||||||
loaded = await asyncio.gather(*loads.values(), return_exceptions=True)
|
loaded = await asyncio.gather(*loads.values(), return_exceptions=True)
|
||||||
results.update(zip(loads, loaded))
|
results.update(zip(loads, loaded))
|
||||||
|
if notifications_task is not None:
|
||||||
|
results["notifications"] = await notifications_task
|
||||||
|
|
||||||
context_result = results.get("context")
|
context_result = results.get("context")
|
||||||
events_result = results.get("events")
|
events_result = results.get("events")
|
||||||
|
|
@ -4091,6 +4162,11 @@ async def _build_live_snapshot(sections: set[str] | None = None) -> dict:
|
||||||
"events": events_result if events_ok else None,
|
"events": events_result if events_ok else None,
|
||||||
"notifications": notification_items if notifications_ok else None,
|
"notifications": notification_items if notifications_ok else None,
|
||||||
"notification_pagination": notification_pagination if notifications_ok else None,
|
"notification_pagination": notification_pagination if notifications_ok else None,
|
||||||
|
"latency_ms": {
|
||||||
|
section: latency_ms[section]
|
||||||
|
for section in requested
|
||||||
|
if section in latency_ms
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
section: "fresh" if ok else "temporarily unavailable"
|
section: "fresh" if ok else "temporarily unavailable"
|
||||||
for section, ok in (
|
for section, ok in (
|
||||||
|
|
@ -4195,6 +4271,23 @@ def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
|
||||||
merged[section] = None
|
merged[section] = None
|
||||||
sections[section] = "temporarily unavailable"
|
sections[section] = "temporarily unavailable"
|
||||||
merged["sections"] = sections
|
merged["sections"] = sections
|
||||||
|
previous_latency = previous.get("latency_ms")
|
||||||
|
refreshed_latency = refreshed.get("latency_ms")
|
||||||
|
refreshed_sections = refreshed.get("sections") or {}
|
||||||
|
merged_latency = {
|
||||||
|
**(previous_latency if isinstance(previous_latency, dict) else {})
|
||||||
|
}
|
||||||
|
if isinstance(refreshed_latency, dict):
|
||||||
|
for section, value in refreshed_latency.items():
|
||||||
|
# A latency from a section that did not become fresh is a failed-
|
||||||
|
# attempt duration and must never overwrite a prior successful
|
||||||
|
# measurement. Only fresh sections may advance the latency.
|
||||||
|
if refreshed_sections.get(section) == "fresh" and _valid_latency_ms(value):
|
||||||
|
merged_latency[section] = value
|
||||||
|
elif section not in merged_latency:
|
||||||
|
merged_latency[section] = value
|
||||||
|
if merged_latency:
|
||||||
|
merged["latency_ms"] = merged_latency
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4328,7 +4421,18 @@ def _live_snapshot_payload(
|
||||||
"last_refresh_failed": degraded,
|
"last_refresh_failed": degraded,
|
||||||
"retry_in_seconds": min(retries, default=0),
|
"retry_in_seconds": min(retries, default=0),
|
||||||
"sections": section_freshness,
|
"sections": section_freshness,
|
||||||
|
"latency_ms": {},
|
||||||
}
|
}
|
||||||
|
latency = value.get("latency_ms")
|
||||||
|
if isinstance(latency, dict) and all(
|
||||||
|
isinstance(item, int) and not isinstance(item, bool) and _valid_latency_ms(item)
|
||||||
|
for item in latency.values()
|
||||||
|
):
|
||||||
|
payload["freshness"]["latency_ms"] = {
|
||||||
|
section: item
|
||||||
|
for section, item in latency.items()
|
||||||
|
if section in LIVE_SNAPSHOT_SECTIONS and _valid_latency_ms(item)
|
||||||
|
}
|
||||||
revision_tokens = {
|
revision_tokens = {
|
||||||
section: f"{_live_revision_generation}.{revision}"
|
section: f"{_live_revision_generation}.{revision}"
|
||||||
for section, revision in _live_section_revisions.items()
|
for section, revision in _live_section_revisions.items()
|
||||||
|
|
|
||||||
|
|
@ -17,24 +17,37 @@ def run_node(script: str) -> dict:
|
||||||
return json.loads(result.stdout)
|
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():
|
def test_live_data_status_summarizes_each_feed_without_claiming_live():
|
||||||
script = f"""
|
script = _REQUIRE + """
|
||||||
const status = require({json.dumps(str(STATUS))});
|
const healthy = {fresh_for_seconds:8, sections:{
|
||||||
const healthy = {{fresh_for_seconds:8, sections:{{
|
context:{age_seconds:1}, notifications:{age_seconds:2}, events:{age_seconds:3}
|
||||||
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
|
}};
|
||||||
}}}};
|
const oneDelayed = {fresh_for_seconds:8, retry_in_seconds:30, sections:{
|
||||||
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}
|
||||||
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:{
|
||||||
const twoDelayed = {{fresh_for_seconds:8, sections:{{
|
context:{age_seconds:12, degraded:true}, notifications:{age_seconds:14, stale:true}, events:{age_seconds:3}
|
||||||
context:{{age_seconds:12, degraded:true}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}}
|
}};
|
||||||
}}}};
|
process.stdout.write(JSON.stringify({
|
||||||
process.stdout.write(JSON.stringify({{
|
|
||||||
healthy:status.describe(healthy),
|
healthy:status.describe(healthy),
|
||||||
one:status.describe(oneDelayed),
|
one:status.describe(oneDelayed),
|
||||||
two:status.describe(twoDelayed),
|
two:status.describe(twoDelayed),
|
||||||
unavailable:status.describe({{}}),
|
unavailable:status.describe({}),
|
||||||
}}));
|
}));
|
||||||
"""
|
"""
|
||||||
result = run_node(script)
|
result = run_node(script)
|
||||||
|
|
||||||
|
|
@ -43,33 +56,33 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert result["one"]["summary"] == "Updates delayed"
|
assert result["one"]["summary"] == "Updates delayed"
|
||||||
assert result["one"]["nextRetrySeconds"] == 30
|
assert result["one"]["nextRetrySeconds"] == 30
|
||||||
assert result["one"]["feeds"][1] == {
|
assert result["one"]["feeds"][1] == {
|
||||||
"key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14
|
"key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14,
|
||||||
|
"latencyMs": None,
|
||||||
}
|
}
|
||||||
assert result["two"]["summary"] == "2 data feeds delayed"
|
assert result["two"]["summary"] == "2 data feeds delayed"
|
||||||
assert result["unavailable"]["summary"] == "Live data unavailable"
|
assert result["unavailable"]["summary"] == "Live data unavailable"
|
||||||
|
|
||||||
|
|
||||||
def test_live_data_status_controller_is_single_flight_and_reports_result():
|
def test_live_data_status_controller_is_single_flight_and_reports_result():
|
||||||
script = f"""
|
script = _REQUIRE + """
|
||||||
const status = require({json.dumps(str(STATUS))});
|
|
||||||
let resolveRefresh;
|
let resolveRefresh;
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const states = [];
|
const states = [];
|
||||||
const button = {{disabled:false}};
|
const button = {disabled:false};
|
||||||
const output = {{textContent:''}};
|
const output = {textContent:''};
|
||||||
const controller = status.createRefreshController({{
|
const controller = status.createRefreshController({
|
||||||
button, output,
|
button, output,
|
||||||
refresh:() => {{ calls += 1; return new Promise(resolve => {{ resolveRefresh = resolve; }}); }},
|
refresh:() => { calls += 1; return new Promise(resolve => { resolveRefresh = resolve; }); },
|
||||||
onState:value => states.push(value),
|
onState:value => states.push(value),
|
||||||
}});
|
});
|
||||||
(async () => {{
|
(async () => {
|
||||||
const first = controller.run();
|
const first = controller.run();
|
||||||
const second = controller.run();
|
const second = controller.run();
|
||||||
const pending = {{calls, disabled:button.disabled, text:output.textContent}};
|
const pending = {calls, disabled:button.disabled, text:output.textContent};
|
||||||
resolveRefresh({{context:{{}}}});
|
resolveRefresh({context:{}});
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
process.stdout.write(JSON.stringify({{pending, calls, disabled:button.disabled, text:output.textContent, states}}));
|
process.stdout.write(JSON.stringify({pending, calls, disabled:button.disabled, text:output.textContent, states}));
|
||||||
}})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|
||||||
assert run_node(script) == {
|
assert run_node(script) == {
|
||||||
|
|
@ -82,16 +95,15 @@ const controller = status.createRefreshController({{
|
||||||
|
|
||||||
|
|
||||||
def test_live_data_status_sheet_pauses_today_contains_focus_and_closes_through_back():
|
def test_live_data_status_sheet_pauses_today_contains_focus_and_closes_through_back():
|
||||||
script = f"""
|
script = _REQUIRE + """
|
||||||
const status = require({json.dumps(str(STATUS))});
|
function element(name) {
|
||||||
function element(name) {{
|
return {name, hidden:false, inert:false, listeners:{}, focused:0,
|
||||||
return {{name, hidden:false, inert:false, listeners:{{}}, focused:0,
|
addEventListener(type, fn) { this.listeners[type] = fn; },
|
||||||
addEventListener(type, fn) {{ this.listeners[type] = fn; }},
|
focus() { this.focused += 1; },
|
||||||
focus() {{ this.focused += 1; }},
|
};
|
||||||
}};
|
}
|
||||||
}}
|
|
||||||
const trigger = element('trigger');
|
const trigger = element('trigger');
|
||||||
trigger.attrs = {{}};
|
trigger.attrs = {};
|
||||||
trigger.setAttribute = (name, value) => trigger.attrs[name] = value;
|
trigger.setAttribute = (name, value) => trigger.attrs[name] = value;
|
||||||
const close = element('close');
|
const close = element('close');
|
||||||
const refresh = element('refresh');
|
const refresh = element('refresh');
|
||||||
|
|
@ -101,37 +113,37 @@ sheet.hidden = true;
|
||||||
sheet.querySelectorAll = () => [close, refresh, back];
|
sheet.querySelectorAll = () => [close, refresh, back];
|
||||||
const header = element('header');
|
const header = element('header');
|
||||||
const main = element('main');
|
const main = element('main');
|
||||||
const listeners = {{}};
|
const listeners = {};
|
||||||
const history = {{state:null, pushes:0, backs:0,
|
const history = {state:null, pushes:0, backs:0,
|
||||||
pushState(state) {{ this.state=state; this.pushes += 1; }},
|
pushState(state) { this.state=state; this.pushes += 1; },
|
||||||
back() {{ this.backs += 1; }},
|
back() { this.backs += 1; },
|
||||||
}};
|
};
|
||||||
const timerView = {{begins:[], finishes:0,
|
const timerView = {begins:[], finishes:0,
|
||||||
beginDetour(reason) {{ this.begins.push(reason); return {{identity:'issue:r:42', reason}}; }},
|
beginDetour(reason) { this.begins.push(reason); return {identity:'issue:r:42', reason}; },
|
||||||
finishDetour() {{ this.finishes += 1; return {{resumed:true}}; }},
|
finishDetour() { this.finishes += 1; return {resumed:true}; },
|
||||||
}};
|
};
|
||||||
const paused = element('paused');
|
const paused = element('paused');
|
||||||
paused.hidden = true;
|
paused.hidden = true;
|
||||||
const controller = status.createSheetController({{
|
const controller = status.createSheetController({
|
||||||
sheet, trigger, closeButton:close, returnButton:back, pausedStatus:paused,
|
sheet, trigger, closeButton:close, returnButton:back, pausedStatus:paused,
|
||||||
timerView, history, historyTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
|
timerView, history, historyTarget:{addEventListener:(name, fn) => listeners[name]=fn},
|
||||||
escapeTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
|
escapeTarget:{addEventListener:(name, fn) => listeners[name]=fn},
|
||||||
backgroundElements:[header, main],
|
backgroundElements:[header, main],
|
||||||
}});
|
});
|
||||||
controller.start();
|
controller.start();
|
||||||
controller.open();
|
controller.open();
|
||||||
const opened = {{hidden:sheet.hidden, expanded:trigger.attrs['aria-expanded'], paused:paused.hidden, inert:[header.inert, main.inert],
|
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}};
|
begins:timerView.begins, pushes:history.pushes, closeFocused:close.focused};
|
||||||
let prevented = 0;
|
let prevented = 0;
|
||||||
listeners.keydown({{key:'Tab', target:back, shiftKey:false, preventDefault:()=>prevented++}});
|
listeners.keydown({key:'Tab', target:back, shiftKey:false, preventDefault:()=>prevented++});
|
||||||
const trapped = {{prevented, closeFocused:close.focused}};
|
const trapped = {prevented, closeFocused:close.focused};
|
||||||
controller.close();
|
controller.close();
|
||||||
const requested = {{backs:history.backs, finishes:timerView.finishes}};
|
const requested = {backs:history.backs, finishes:timerView.finishes};
|
||||||
history.state = null;
|
history.state = null;
|
||||||
listeners.popstate({{state:null}});
|
listeners.popstate({state:null});
|
||||||
process.stdout.write(JSON.stringify({{opened, trapped, requested, closed:{{hidden:sheet.hidden, paused:paused.hidden,
|
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,
|
expanded:trigger.attrs['aria-expanded'], inert:[header.inert, main.inert], finishes:timerView.finishes,
|
||||||
triggerFocused:trigger.focused}}}}));
|
triggerFocused:trigger.focused}}));
|
||||||
"""
|
"""
|
||||||
result = run_node(script)
|
result = run_node(script)
|
||||||
|
|
||||||
|
|
@ -159,22 +171,21 @@ process.stdout.write(JSON.stringify({{opened, trapped, requested, closed:{{hidde
|
||||||
|
|
||||||
|
|
||||||
def test_live_data_status_without_running_today_hides_return_and_does_not_resume():
|
def test_live_data_status_without_running_today_hides_return_and_does_not_resume():
|
||||||
script = f"""
|
script = _REQUIRE + """
|
||||||
const status = require({json.dumps(str(STATUS))});
|
function element(hidden=false) { return {hidden, inert:false, listeners:{}, attrs:{},
|
||||||
function element(hidden=false) {{ return {{hidden, inert:false, listeners:{{}}, attrs:{{}},
|
addEventListener(name, fn) { this.listeners[name]=fn; }, focus() {},
|
||||||
addEventListener(name, fn) {{ this.listeners[name]=fn; }}, focus() {{}},
|
setAttribute(name, value) { this.attrs[name]=value; }, querySelectorAll() { return []; } }; }
|
||||||
setAttribute(name, value) {{ this.attrs[name]=value; }}, querySelectorAll() {{ return []; }} }}; }}
|
|
||||||
const sheet=element(true), trigger=element(), close=element(), returnButton=element();
|
const sheet=element(true), trigger=element(), close=element(), returnButton=element();
|
||||||
let finishes=0;
|
let finishes=0;
|
||||||
const controller=status.createSheetController({{
|
const controller=status.createSheetController({
|
||||||
sheet,trigger,closeButton:close,returnButton,
|
sheet,trigger,closeButton:close,returnButton,
|
||||||
timerView:{{beginDetour:()=>null,finishDetour:()=>finishes++}},
|
timerView:{beginDetour:()=>null,finishDetour:()=>finishes++},
|
||||||
}});
|
});
|
||||||
controller.start();
|
controller.start();
|
||||||
controller.open();
|
controller.open();
|
||||||
const opened={{returnHidden:returnButton.hidden, expanded:trigger.attrs['aria-expanded']}};
|
const opened={returnHidden:returnButton.hidden, expanded:trigger.attrs['aria-expanded']};
|
||||||
controller.close();
|
controller.close();
|
||||||
process.stdout.write(JSON.stringify({{opened, finishes}}));
|
process.stdout.write(JSON.stringify({opened, finishes}));
|
||||||
"""
|
"""
|
||||||
assert run_node(script) == {
|
assert run_node(script) == {
|
||||||
"opened": {"returnHidden": True, "expanded": "true"},
|
"opened": {"returnHidden": True, "expanded": "true"},
|
||||||
|
|
@ -182,6 +193,97 @@ process.stdout.write(JSON.stringify({{opened, finishes}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||||
html = HTML.read_text()
|
html = HTML.read_text()
|
||||||
css = CSS.read_text()
|
css = CSS.read_text()
|
||||||
|
|
@ -196,13 +298,127 @@ def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||||
assert 'id="refresh-live-data"' in html
|
assert 'id="refresh-live-data"' in html
|
||||||
assert 'id="return-from-live-data-status"' in html
|
assert 'id="return-from-live-data-status"' in html
|
||||||
assert 'id="live-data-status-today-paused"' 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 '<script src="static/live-data-status.js"></script>' in html
|
||||||
assert ".live-data-status-panel" in css
|
assert ".live-data-status-panel" in css
|
||||||
assert "width:min(560px,100%)" in css
|
assert "width:min(560px,100%)" in css
|
||||||
assert "min-height:44px" 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.describe" in dashboard
|
||||||
assert "liveDataStatus.createRefreshController" in dashboard
|
assert "liveDataStatus.createRefreshController" in dashboard
|
||||||
assert "liveDataStatus.mount" in dashboard
|
assert "liveDataStatus.mount" in dashboard
|
||||||
|
assert "renderBucketLatency(" in dashboard
|
||||||
|
assert "#bucket-latency-panel" in dashboard
|
||||||
assert "createSheetController" in status_source
|
assert "createSheetController" in status_source
|
||||||
assert "backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')]" in status_source
|
assert "backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')]" in status_source
|
||||||
assert "contextPoller.getState()" in dashboard
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -494,6 +494,138 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
|
||||||
assert result["freshness"]["revalidating"] is False
|
assert result["freshness"]["revalidating"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_live_snapshot_reports_per_bucket_upstream_latency(monkeypatch):
|
||||||
|
delays = {"context": 0.03, "events": 0.02, "notifications": 0.06}
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
await asyncio.sleep(delays["context"])
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
async def empty():
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def events(_authenticated_user):
|
||||||
|
await asyncio.sleep(delays["events"])
|
||||||
|
return [{"type": "push"}]
|
||||||
|
|
||||||
|
async def updates():
|
||||||
|
await asyncio.sleep(delays["notifications"])
|
||||||
|
return {"items": [], "page": 1, "total": 0, "has_more": False}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main, "repos", empty)
|
||||||
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
|
monkeypatch.setattr(main, "activity_events", events)
|
||||||
|
monkeypatch.setattr(main, "notifications", updates)
|
||||||
|
|
||||||
|
result = payload(await main.live_snapshot())
|
||||||
|
|
||||||
|
latency = result["freshness"]["latency_ms"]
|
||||||
|
assert set(latency) == {"context", "events", "notifications"}
|
||||||
|
for section, seconds in delays.items():
|
||||||
|
assert isinstance(latency[section], int)
|
||||||
|
assert latency[section] >= seconds * 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_bucket_latency_measures_each_feed_individually(monkeypatch):
|
||||||
|
"""A fast feed must not inherit a slow sibling's batch wall time."""
|
||||||
|
async def user():
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
async def empty():
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def fast_events(_authenticated_user):
|
||||||
|
return [{"type": "push"}]
|
||||||
|
|
||||||
|
async def slow_updates():
|
||||||
|
await asyncio.sleep(0.12)
|
||||||
|
return {"items": [], "page": 1, "total": 0, "has_more": False}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main, "repos", empty)
|
||||||
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
|
monkeypatch.setattr(main, "activity_events", fast_events)
|
||||||
|
monkeypatch.setattr(main, "notifications", slow_updates)
|
||||||
|
|
||||||
|
result = payload(await main.live_snapshot())
|
||||||
|
|
||||||
|
latency = result["freshness"]["latency_ms"]
|
||||||
|
# The fast events feed finished immediately; its displayed latency must
|
||||||
|
# reflect that feed alone, not the whole gather batch (~120 ms).
|
||||||
|
assert latency["events"] < latency["notifications"]
|
||||||
|
assert latency["events"] < 60
|
||||||
|
assert latency["notifications"] >= 120
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_bucket_latency_survives_partial_refresh_and_failure(monkeypatch):
|
||||||
|
"""A failed bucket keeps its last known latency instead of vanishing."""
|
||||||
|
now = 100.0
|
||||||
|
builds = 0
|
||||||
|
|
||||||
|
async def snapshot(_sections=None):
|
||||||
|
nonlocal builds
|
||||||
|
builds += 1
|
||||||
|
if builds == 1:
|
||||||
|
return {
|
||||||
|
"context": {"generation": 1},
|
||||||
|
"events": [],
|
||||||
|
"notifications": [],
|
||||||
|
"latency_ms": {"context": 11, "events": 12, "notifications": 13},
|
||||||
|
"sections": {
|
||||||
|
"context": "fresh", "events": "fresh", "notifications": "fresh",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"context": {"generation": 2},
|
||||||
|
"events": None,
|
||||||
|
"notifications": [],
|
||||||
|
"latency_ms": {"context": 21, "notifications": 23},
|
||||||
|
"sections": {
|
||||||
|
"context": "fresh",
|
||||||
|
"events": "temporarily unavailable",
|
||||||
|
"notifications": "fresh",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
|
||||||
|
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||||
|
|
||||||
|
first = payload(await main.live_snapshot())
|
||||||
|
assert first["freshness"]["latency_ms"] == {
|
||||||
|
"context": 11, "events": 12, "notifications": 13,
|
||||||
|
}
|
||||||
|
|
||||||
|
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||||
|
await main.live_snapshot()
|
||||||
|
assert main._live_snapshot_task is not None
|
||||||
|
await main._live_snapshot_task
|
||||||
|
result = payload(await main.live_snapshot())
|
||||||
|
|
||||||
|
assert result["freshness"]["latency_ms"] == {
|
||||||
|
"context": 21, "events": 12, "notifications": 23,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_payload_without_latency_data_reports_no_buckets():
|
||||||
|
value = {
|
||||||
|
"context": {},
|
||||||
|
"events": None,
|
||||||
|
"notifications": None,
|
||||||
|
"sections": {"context": "fresh"},
|
||||||
|
}
|
||||||
|
result = main._live_snapshot_payload(value, stale=False, revalidating=False)
|
||||||
|
|
||||||
|
assert result["freshness"]["latency_ms"] == {}
|
||||||
|
assert "latency_ms" not in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_notification_cooldown_does_not_stop_due_work_and_activity_refreshes(monkeypatch):
|
async def test_notification_cooldown_does_not_stop_due_work_and_activity_refreshes(monkeypatch):
|
||||||
now = 100.0
|
now = 100.0
|
||||||
|
|
@ -811,3 +943,166 @@ async def test_live_snapshot_persists_reboot_stable_wall_timestamps(monkeypatch,
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert set(main._live_snapshot_store.load().created_at.values()) == {epoch}
|
assert set(main._live_snapshot_store.load().created_at.values()) == {epoch}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_failed_upstream_attempt_never_overwrites_prior_successful_latency(monkeypatch, tmp_path):
|
||||||
|
"""A failed upstream-attempt duration must never overwrite the previous
|
||||||
|
successful latency, and is never mislabeled as 'last known'. With no prior
|
||||||
|
success the section shows 'not measured'."""
|
||||||
|
epoch = 1000.0
|
||||||
|
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: epoch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main,
|
||||||
|
"_live_snapshot_store",
|
||||||
|
LiveSnapshotStore(tmp_path / "lat.sqlite3", clock=lambda: main._live_snapshot_clock()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
async def empty():
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def failing_notifications():
|
||||||
|
raise ConnectionError("Gitea notifications endpoint refused connection")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main, "repos", empty)
|
||||||
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
|
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
|
||||||
|
|
||||||
|
async def healthy_notifications():
|
||||||
|
return {"items": [], "page": 1, "total": 0, "has_more": False}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "notifications", healthy_notifications)
|
||||||
|
|
||||||
|
# First call: all sections succeed, each records a real latency.
|
||||||
|
result = payload(await main.live_snapshot())
|
||||||
|
first_latency = dict(result["freshness"]["latency_ms"])
|
||||||
|
assert set(first_latency) == {"context", "events", "notifications"}
|
||||||
|
for value in first_latency.values():
|
||||||
|
assert isinstance(value, int) and value >= 0
|
||||||
|
|
||||||
|
# Second call: notifications fails upstream. The failed-attempt duration
|
||||||
|
# must NOT overwrite the prior successful notifications latency.
|
||||||
|
monkeypatch.setattr(main, "notifications", failing_notifications)
|
||||||
|
|
||||||
|
# Advance the clock past freshness so a real re-fetch occurs.
|
||||||
|
epoch += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||||
|
await main.live_snapshot()
|
||||||
|
# The re-fetch runs as a shared background refresh; wait for it so the
|
||||||
|
# merged result (with prior latency preserved) is published.
|
||||||
|
assert main._live_snapshot_task is not None
|
||||||
|
await main._live_snapshot_task
|
||||||
|
result2 = payload(await main.live_snapshot())
|
||||||
|
second_latency = result2["freshness"]["latency_ms"]
|
||||||
|
|
||||||
|
# context/events remain measured (they succeeded again); notifications
|
||||||
|
# retains its PRIOR successful latency, not the failed-attempt duration.
|
||||||
|
assert second_latency["notifications"] == first_latency["notifications"]
|
||||||
|
assert second_latency["notifications"] >= 0
|
||||||
|
# The failed section is marked stale or unavailable, not 'last known'.
|
||||||
|
assert result2["sections"]["notifications"] in ("stale", "temporarily unavailable")
|
||||||
|
# Ensure the failed-attempt's own timing never leaked into notifications.
|
||||||
|
assert second_latency["events"] >= first_latency["events"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_submillisecond_upstream_measures_zero_no_fabricated_floor(monkeypatch):
|
||||||
|
"""Sub-millisecond operations measure as 0, never a fabricated 1 ms floor."""
|
||||||
|
async def fast_user():
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
async def fast_empty():
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", fast_user)
|
||||||
|
monkeypatch.setattr(main, "repos", fast_empty)
|
||||||
|
monkeypatch.setattr(main, "issues", fast_empty)
|
||||||
|
monkeypatch.setattr(main, "pull_requests", fast_empty)
|
||||||
|
monkeypatch.setattr(main, "activity_events", lambda _user: fast_empty())
|
||||||
|
monkeypatch.setattr(main, "notifications", fast_empty)
|
||||||
|
|
||||||
|
result = payload(await main.live_snapshot())
|
||||||
|
latency = result["freshness"]["latency_ms"]
|
||||||
|
|
||||||
|
for section in ("context", "events", "notifications"):
|
||||||
|
value = latency[section]
|
||||||
|
assert isinstance(value, int) and not isinstance(value, bool)
|
||||||
|
assert value >= 0
|
||||||
|
# Must never be a fabricated positive floor from sub-ms work.
|
||||||
|
assert value != 1 or value >= 1000 # 1 ms is only legitimate at >=1ms
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_notifications_fetch_starts_before_auth_completes(monkeypatch):
|
||||||
|
"""Notifications is auth-independent: its fetch starts immediately on the
|
||||||
|
same tick as the shared current_user() fetch. An 80 ms auth request and an
|
||||||
|
80 ms Notifications request complete concurrently (~80 ms) rather than
|
||||||
|
serially (~160 ms)."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
notifications_started = asyncio.Event()
|
||||||
|
auth_started = asyncio.Event()
|
||||||
|
concurrency = {"notifications_started_before_auth_slept": False}
|
||||||
|
|
||||||
|
async def slow_notifications():
|
||||||
|
notifications_started.set()
|
||||||
|
await asyncio.sleep(0.08)
|
||||||
|
return {"items": [], "page": 1, "total": 0, "has_more": False}
|
||||||
|
|
||||||
|
async def slow_user():
|
||||||
|
auth_started.set()
|
||||||
|
# Record whether notifications had already started while auth was
|
||||||
|
# still awaiting — direct proof of concurrency, not serialization.
|
||||||
|
if notifications_started.is_set():
|
||||||
|
concurrency["notifications_started_before_auth_slept"] = True
|
||||||
|
await asyncio.sleep(0.08)
|
||||||
|
if notifications_started.is_set():
|
||||||
|
concurrency["notifications_started_before_auth_slept"] = True
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
async def empty():
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", slow_user)
|
||||||
|
monkeypatch.setattr(main, "repos", empty)
|
||||||
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
|
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
|
||||||
|
monkeypatch.setattr(main, "notifications", slow_notifications)
|
||||||
|
|
||||||
|
# Drive the snapshot builder directly so the measurement is deterministic
|
||||||
|
# and not inflated by store/SQLite round-trips in the HTTP layer.
|
||||||
|
wall_start = time.perf_counter()
|
||||||
|
result = await main._build_live_snapshot()
|
||||||
|
total = time.perf_counter() - wall_start
|
||||||
|
|
||||||
|
# Both fetches were attempted.
|
||||||
|
assert auth_started.is_set()
|
||||||
|
assert notifications_started.is_set()
|
||||||
|
# Notifications started while auth was still in flight (concurrent, not
|
||||||
|
# serialized behind the shared auth fetch).
|
||||||
|
assert concurrency["notifications_started_before_auth_slept"] is True
|
||||||
|
# If serialized, total would be ~160 ms. Concurrent means ~80 ms.
|
||||||
|
# The bound must sit strictly between the concurrent (~80 ms) and serial
|
||||||
|
# (~160 ms) costs to deterministically prove concurrency.
|
||||||
|
assert total < 0.11
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_latency_validation_backend_rejects_malformed():
|
||||||
|
"""Backend strict-type gate for latency telemetry rejects everything that
|
||||||
|
is not a finite nonnegative bounded integer and never coerces/clips."""
|
||||||
|
for value in [None, True, False, "120", "fast", [120], 120.9, -5, float("inf"), 99999999]:
|
||||||
|
assert main._valid_latency_ms(value) is False, value
|
||||||
|
assert main._valid_latency_ms(0) is True
|
||||||
|
assert main._valid_latency_ms(1) is True
|
||||||
|
assert main._valid_latency_ms(3600000) is True
|
||||||
|
assert main._valid_latency_ms(3600001) is False
|
||||||
|
assert main._measured_latency_ms(0.0) == 0
|
||||||
|
assert main._measured_latency_ms(0.0004) == 0 # sub-ms rounds to 0, no 1ms floor
|
||||||
|
assert main._measured_latency_ms(-1.0) is None
|
||||||
|
assert main._measured_latency_ms(float("inf")) is None
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user