feat: paginate mobile Human Gate history (Closes #1444)
This commit is contained in:
parent
57c7373079
commit
0f1cac0e25
|
|
@ -1625,6 +1625,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
|
||||
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
|
||||
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
|
||||
.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
|
||||
.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-history-more{min-height:44px;width:100%;padding:max(10px,env(safe-area-inset-bottom)) 12px}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
|
||||
@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}}
|
||||
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}
|
||||
|
|
@ -16,6 +16,9 @@ function createHumanGates(options = {}) {
|
|||
let openFlight = null;
|
||||
let onChange = options.onChange;
|
||||
let historyItems = [];
|
||||
let historyNextCursor = null;
|
||||
let historyLoadedMore = false;
|
||||
let historyLoadError = '';
|
||||
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
|
|
@ -106,9 +109,15 @@ function createHumanGates(options = {}) {
|
|||
nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
function renderHistory(preserveDetail = false) {
|
||||
setView('history');
|
||||
setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.'));
|
||||
if (historyNextCursor) {
|
||||
setText(nodes.status, historyItems.length + ' Human Gate decisions loaded. Older decisions are available.');
|
||||
} else if (historyLoadedMore) {
|
||||
setText(nodes.status, 'All ' + historyItems.length + ' Human Gate decisions loaded.');
|
||||
} else {
|
||||
setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.'));
|
||||
}
|
||||
if (!historyItems.length) {
|
||||
setHtml(nodes.list, '<div class="human-gates-zero"><strong>No decision history</strong><span>Released and held candidates will appear here.</span></div>');
|
||||
setHtml(nodes.detail, '');
|
||||
|
|
@ -119,25 +128,57 @@ function createHumanGates(options = {}) {
|
|||
'<strong>' + escape(item.title) + '</strong><span class="human-gate-state human-gate-state-' + escape(item.state) + '">' +
|
||||
escape(item.state.charAt(0).toUpperCase() + item.state.slice(1)) + '</span>' +
|
||||
'<code>' + escape(item.candidate_hash) + '</code><time>' + escape(new Date(Number(item.updated_at) * 1000).toLocaleString()) + '</time></button>'
|
||||
).join(''));
|
||||
).join('') + (historyNextCursor ? '<button class="human-gate-history-more" type="button" data-human-gate-history-more>' +
|
||||
(historyLoadError ? 'Retry older decisions' : 'Load older decisions') + '</button>' : ''));
|
||||
Array.from(nodes.list?.querySelectorAll?.('[data-human-gate-history-id]') || []).forEach(card => {
|
||||
card.addEventListener('click', () => selectHistory(card.dataset.humanGateHistoryId).catch(error => {
|
||||
setText(nodes.status, error.message || 'Human Gate history is unavailable.');
|
||||
}));
|
||||
});
|
||||
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
|
||||
nodes.list?.querySelector?.('[data-human-gate-history-more]')?.addEventListener?.('click', () => {
|
||||
loadMoreHistory().catch(error => setText(nodes.status, error.message || 'Older Human Gate decisions are unavailable.'));
|
||||
});
|
||||
if (!preserveDetail) setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
|
||||
}
|
||||
|
||||
async function showHistory() {
|
||||
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
|
||||
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
||||
const result = validSnapshot(await fetchJson('api/v1/human-gates?state=all'));
|
||||
const result = validSnapshot(await fetchJson('api/v1/human-gates?state=history&limit=20'));
|
||||
if (!result) throw new Error('Human Gate history response is invalid.');
|
||||
historyItems = result.items.filter(item => item.state !== 'pending');
|
||||
historyItems = result.items;
|
||||
historyNextCursor = result.next_cursor || null;
|
||||
historyLoadedMore = false;
|
||||
historyLoadError = '';
|
||||
renderHistory();
|
||||
return JSON.parse(JSON.stringify(historyItems));
|
||||
}
|
||||
|
||||
async function loadMoreHistory() {
|
||||
if (!historyNextCursor) return JSON.parse(JSON.stringify(historyItems));
|
||||
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
|
||||
const cursor = historyNextCursor;
|
||||
let result;
|
||||
try {
|
||||
result = validSnapshot(await fetchJson(
|
||||
'api/v1/human-gates?state=history&limit=20&cursor=' + encodeURIComponent(cursor)
|
||||
));
|
||||
if (!result) throw new Error('Human Gate history response is invalid.');
|
||||
} catch (error) {
|
||||
historyLoadError = error?.message || 'Older Human Gate decisions are unavailable.';
|
||||
renderHistory(true);
|
||||
setText(nodes.status, historyLoadError + ' Loaded decisions are still available.');
|
||||
throw error;
|
||||
}
|
||||
const known = new Set(historyItems.map(item => item.id));
|
||||
historyItems.push(...result.items.filter(item => !known.has(item.id)));
|
||||
historyNextCursor = result.next_cursor || null;
|
||||
historyLoadedMore = true;
|
||||
historyLoadError = '';
|
||||
renderHistory(true);
|
||||
return JSON.parse(JSON.stringify(historyItems));
|
||||
}
|
||||
|
||||
async function selectHistory(gateId) {
|
||||
const summary = historyItems.find(item => item.id === gateId);
|
||||
if (!summary) throw new Error('Gate is not in the current history.');
|
||||
|
|
@ -383,7 +424,7 @@ function createHumanGates(options = {}) {
|
|||
}
|
||||
|
||||
return {
|
||||
load, open, reviewNext, select, showHistory, selectHistory, showPending,
|
||||
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
|
||||
decideAndNext, submitDecision, current, saveProgress,
|
||||
setOnChange(callback) { onChange = callback; },
|
||||
restoreCached: restore,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v148';
|
||||
const CACHE = 'stackchain-dashboard-shell-v149';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
|
@ -69,6 +70,9 @@ class HumanGateStore:
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS human_gates_queue
|
||||
ON human_gates(login, state, priority DESC, created_at, id);
|
||||
CREATE INDEX IF NOT EXISTS human_gates_decision_history
|
||||
ON human_gates(login, updated_at DESC, id DESC)
|
||||
WHERE state IN ('released','held','superseded');
|
||||
CREATE TABLE IF NOT EXISTS human_gate_intake_keys (
|
||||
login TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL, gate_id TEXT NOT NULL,
|
||||
|
|
@ -78,6 +82,8 @@ class HumanGateStore:
|
|||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, gate_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, at REAL NOT NULL, details_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS human_gate_history_gate_sequence
|
||||
ON human_gate_history(gate_id, sequence);
|
||||
CREATE TABLE IF NOT EXISTS human_gate_receipts (
|
||||
receipt_id TEXT PRIMARY KEY, login TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
||||
|
|
@ -262,13 +268,64 @@ class HumanGateStore:
|
|||
row = connection.execute("SELECT * FROM human_gates WHERE id=?", (gate_id,)).fetchone()
|
||||
return self._present(connection, row, history=True)
|
||||
|
||||
def list(self, login: str, *, state: str = "pending", limit: int = 100) -> dict:
|
||||
@staticmethod
|
||||
def _history_cursor(login: str, updated_at: float, gate_id: str) -> str:
|
||||
principal = hashlib.sha256(login.encode()).hexdigest()[:16]
|
||||
raw = _canonical([principal, updated_at, gate_id]).encode()
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
@staticmethod
|
||||
def _parse_history_cursor(login: str, cursor: str) -> tuple[float, str]:
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
|
||||
principal, updated_at, gate_id = json.loads(raw)
|
||||
expected = hashlib.sha256(login.encode()).hexdigest()[:16]
|
||||
if (
|
||||
principal != expected
|
||||
or not isinstance(updated_at, (int, float))
|
||||
or not isinstance(gate_id, str)
|
||||
or not gate_id
|
||||
):
|
||||
raise ValueError
|
||||
return float(updated_at), gate_id
|
||||
except (ValueError, TypeError, json.JSONDecodeError, UnicodeDecodeError) as error:
|
||||
raise GateValidationError("history cursor is invalid") from error
|
||||
|
||||
def list(
|
||||
self, login: str, *, state: str = "pending", limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict:
|
||||
login = self._login(login)
|
||||
if state not in _STATES and state != "all":
|
||||
if state not in _STATES and state not in {"all", "history"}:
|
||||
raise GateValidationError("state is invalid")
|
||||
limit = min(max(int(limit), 1), 100)
|
||||
with self._connect() as connection:
|
||||
pending_count = connection.execute("SELECT COUNT(*) FROM human_gates WHERE login=? AND state='pending'", (login,)).fetchone()[0]
|
||||
if state == "history":
|
||||
args: list[object] = [login]
|
||||
cursor_clause = ""
|
||||
if cursor:
|
||||
updated_at, gate_id = self._parse_history_cursor(login, cursor)
|
||||
cursor_clause = " AND (updated_at < ? OR (updated_at = ? AND id < ?))"
|
||||
args.extend([updated_at, updated_at, gate_id])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM human_gates WHERE login=? "
|
||||
"AND state IN ('released','held','superseded')" + cursor_clause +
|
||||
" ORDER BY updated_at DESC, id DESC LIMIT ?",
|
||||
(*args, limit + 1),
|
||||
).fetchall()
|
||||
visible = rows[:limit]
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
last = visible[-1]
|
||||
next_cursor = self._history_cursor(login, last["updated_at"], last["id"])
|
||||
return {
|
||||
"pending_count": pending_count,
|
||||
"items": [self._present(connection, row) for row in visible],
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
if cursor:
|
||||
raise GateValidationError("history cursor is invalid")
|
||||
where, args = ("login=?", [login]) if state == "all" else ("login=? AND state=?", [login, state])
|
||||
rows = connection.execute(
|
||||
f"SELECT * FROM human_gates WHERE {where} "
|
||||
|
|
|
|||
|
|
@ -1890,10 +1890,13 @@ async def list_human_gates(
|
|||
request: Request,
|
||||
state: str = Query(default="pending"),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
cursor: str | None = Query(default=None, min_length=1, max_length=512),
|
||||
):
|
||||
login = await _human_gate_login(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(_human_gate_store().list, login, state=state, limit=limit)
|
||||
result = await asyncio.to_thread(
|
||||
_human_gate_store().list, login, state=state, limit=limit, cursor=cursor
|
||||
)
|
||||
except (GateValidationError, sqlite3.Error) as error:
|
||||
raise _gate_error(error) from error
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
|
|
|||
|
|
@ -176,6 +176,11 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t
|
|||
**held, "id": "released", "title": "Released candidate", "candidate_hash": "aaa111",
|
||||
"state": "released", "updated_at": 150, "reason": "", "receipt_id": "receipt-1",
|
||||
}
|
||||
superseded = {
|
||||
**held, "id": "superseded", "title": "Superseded candidate",
|
||||
"candidate_hash": "old000", "state": "superseded", "updated_at": 100,
|
||||
"reason": "", "receipt_id": None,
|
||||
}
|
||||
try:
|
||||
with release_server(
|
||||
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
|
||||
|
|
@ -189,8 +194,10 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t
|
|||
|
||||
def gates_route(route):
|
||||
history_requests.append(route.request.url)
|
||||
if route.request.url.endswith("state=all"):
|
||||
payload = {"pending_count": 0, "items": [held, released]}
|
||||
if "state=history" in route.request.url and "cursor=older-page" in route.request.url:
|
||||
payload = {"pending_count": 0, "items": [superseded], "next_cursor": None}
|
||||
elif "state=history" in route.request.url:
|
||||
payload = {"pending_count": 0, "items": [held, released], "next_cursor": "older-page"}
|
||||
elif route.request.url.endswith("/held"):
|
||||
payload = held
|
||||
else:
|
||||
|
|
@ -216,19 +223,28 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t
|
|||
cached_before = page.evaluate("Object.keys(localStorage).sort()")
|
||||
|
||||
page.locator("#human-gates-history").click()
|
||||
expect(page.locator("#human-gates-status")).to_have_text("2 past Human Gate decisions.")
|
||||
expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available")
|
||||
expect(page.locator('[data-human-gate-history-id="held"]')).to_contain_text("Held")
|
||||
expect(page.locator('[data-human-gate-history-id="released"]')).to_contain_text("Released")
|
||||
page.locator('[data-human-gate-history-id="held"]').click()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Needs mobile evidence")
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
|
||||
expect(page.locator("#human-gate-detail [data-gate-decision]")).to_have_count(0)
|
||||
more = page.get_by_role("button", name="Load older decisions")
|
||||
more_bounds = more.bounding_box()
|
||||
assert more_bounds and more_bounds["height"] >= 44
|
||||
more.click()
|
||||
expect(page.locator('[data-human-gate-history-id="superseded"]')).to_contain_text("Superseded")
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
|
||||
expect(page.locator("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.")
|
||||
expect(page.locator("[data-human-gate-history-more]")).to_have_count(0)
|
||||
assert page.evaluate("Object.keys(localStorage).sort()") == cached_before
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
for selector in ("#human-gates-pending", "#human-gates-history"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert any(url.endswith("state=all") for url in history_requests)
|
||||
assert any("state=history&limit=20" in url for url in history_requests)
|
||||
assert any("cursor=older-page" in url for url in history_requests)
|
||||
assert not browser_errors
|
||||
browser.close()
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
|||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v148" in worker
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
|
|||
assert ".following-disposition-mode" in css
|
||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||
assert "stackchain-dashboard-shell-v148" in service_worker
|
||||
assert "stackchain-dashboard-shell-v149" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
|
|
|
|||
|
|
@ -101,6 +101,40 @@ async def test_decision_history_is_newest_first_receipt_linked_and_principal_bou
|
|||
gate_api.receipt("2:timmy", second_receipt["receipt_id"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decision_history_api_follows_an_opaque_continuation_cursor(gate_api):
|
||||
for index in range(3):
|
||||
candidate = {
|
||||
**CANDIDATE,
|
||||
"project": f"history/{index}",
|
||||
"candidate_hash": f"history-{index}",
|
||||
}
|
||||
gate = gate_api.intake("1:timmy", candidate, idempotency_key=f"api-history-{index}")
|
||||
gate_api.decide(
|
||||
"1:timmy", gate["id"], expected_revision=1, decision="release",
|
||||
reason="", override_reason="", checklist=CHECKLIST,
|
||||
idempotency_key=f"api-decision-{index}",
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.get("/api/v1/human-gates?state=history&limit=2")
|
||||
second = await client.get(
|
||||
"/api/v1/human-gates",
|
||||
params={"state": "history", "limit": 2, "cursor": first.json()["next_cursor"]},
|
||||
)
|
||||
malformed = await client.get(
|
||||
"/api/v1/human-gates?state=history&cursor=not-a-cursor"
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert len(first.json()["items"]) == 2
|
||||
assert second.status_code == 200
|
||||
assert len(second.json()["items"]) == 1
|
||||
assert second.json()["next_cursor"] is None
|
||||
assert malformed.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decision_requires_fresh_authorization_bound_to_the_exact_gate(monkeypatch, gate_api):
|
||||
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-authorized")
|
||||
|
|
|
|||
|
|
@ -64,6 +64,44 @@ def test_pending_queue_orders_highest_priority_then_oldest(tmp_path):
|
|||
assert [item["id"] for item in store.list("timmy")["items"]] == [oldest_high["id"], newest_high["id"], low["id"]]
|
||||
|
||||
|
||||
def test_decision_history_pages_are_bounded_stable_and_not_hidden_by_pending_work(tmp_path):
|
||||
tick = iter(range(1000)).__next__
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=tick)
|
||||
decided = []
|
||||
for index in range(3):
|
||||
gate = store.intake(
|
||||
"timmy",
|
||||
{**candidate(f"decided-{index}"), "project": f"decided/{index}"},
|
||||
idempotency_key=f"decided-{index}",
|
||||
)
|
||||
store.decide(
|
||||
"timmy", gate["id"], expected_revision=1, decision="release",
|
||||
reason="", override_reason="", checklist=checklist(),
|
||||
idempotency_key=f"decision-{index}",
|
||||
)
|
||||
decided.append(gate["id"])
|
||||
for index in range(105):
|
||||
store.intake(
|
||||
"timmy",
|
||||
{**candidate(f"pending-{index}"), "project": f"pending/{index}"},
|
||||
idempotency_key=f"pending-{index}",
|
||||
)
|
||||
|
||||
first = store.list("timmy", state="history", limit=2)
|
||||
second = store.list("timmy", state="history", limit=2, cursor=first["next_cursor"])
|
||||
|
||||
assert first["pending_count"] == 105
|
||||
assert [item["id"] for item in first["items"]] == list(reversed(decided[1:]))
|
||||
assert first["next_cursor"]
|
||||
assert [item["id"] for item in second["items"]] == [decided[0]]
|
||||
assert second["next_cursor"] is None
|
||||
assert store.list("timmy", state="history", limit=2, cursor=first["next_cursor"]) == second
|
||||
with pytest.raises(GateValidationError, match="cursor"):
|
||||
store.list("alex", state="history", cursor=first["next_cursor"])
|
||||
with pytest.raises(GateValidationError, match="cursor"):
|
||||
store.list("timmy", state="history", cursor="not-a-cursor")
|
||||
|
||||
|
||||
def test_decision_checks_revision_rules_and_returns_durable_idempotent_receipt(tmp_path):
|
||||
path = tmp_path / "gates.sqlite3"
|
||||
store = HumanGateStore(path, clock=iter([100, 101]).__next__)
|
||||
|
|
|
|||
|
|
@ -145,10 +145,9 @@ const nodes={
|
|||
};
|
||||
const pending={pending_count:1,items:[{id:'pending',title:'Waiting',candidate_hash:'aaa',state:'pending',revision:1,checks:[]}]};
|
||||
const history={pending_count:1,items:[
|
||||
{id:'pending',title:'Waiting',candidate_hash:'aaa',state:'pending',revision:1},
|
||||
{id:'held',title:'Held candidate',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2'},
|
||||
{id:'released',title:'Released candidate',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-1'},
|
||||
]};
|
||||
],next_cursor:null};
|
||||
const detail={id:'held',title:'Held candidate',project:'stackchain/dashboard',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2',history:[{action:'held',at:200}]};
|
||||
const receipt={receipt_id:'receipt-2',gate_id:'held',candidate_hash:'bbb',state:'held',decided_at:200,reason:'Needs mobile evidence',override_reason:'',checklist:{}};
|
||||
const gates=createHumanGates({
|
||||
|
|
@ -156,7 +155,7 @@ const gates=createHumanGates({
|
|||
getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''},
|
||||
fetchJson:async path=>{
|
||||
requests.push(path);
|
||||
if(path==='api/v1/human-gates?state=all') return history;
|
||||
if(path==='api/v1/human-gates?state=history&limit=20') return history;
|
||||
if(path==='api/v1/human-gates/held') return detail;
|
||||
if(path==='api/v1/human-gate-receipts/receipt-2') return receipt;
|
||||
return pending;
|
||||
|
|
@ -176,7 +175,7 @@ const gates=createHumanGates({
|
|||
assert output["writesAfterHistory"] == 1
|
||||
assert output["requests"] == [
|
||||
"api/v1/human-gates",
|
||||
"api/v1/human-gates?state=all",
|
||||
"api/v1/human-gates?state=history&limit=20",
|
||||
"api/v1/human-gates/held",
|
||||
"api/v1/human-gate-receipts/receipt-2",
|
||||
]
|
||||
|
|
@ -190,6 +189,91 @@ const gates=createHumanGates({
|
|||
assert output["status"] == "2 past Human Gate decisions."
|
||||
|
||||
|
||||
def test_mobile_history_loads_older_decisions_without_losing_the_open_receipt():
|
||||
output = run_node(r"""
|
||||
const requests=[];
|
||||
const nodes={
|
||||
count:{},list:{innerHTML:'',querySelectorAll:()=>[]},status:{textContent:''},panel:{},detail:{innerHTML:''},
|
||||
pendingTab:{setAttribute(){}},historyTab:{setAttribute(){}},
|
||||
};
|
||||
const first={pending_count:0,items:[
|
||||
{id:'new',title:'Newest decision',candidate_hash:'aaa',state:'released',updated_at:300,receipt_id:'receipt-new'},
|
||||
{id:'middle',title:'Middle decision',candidate_hash:'bbb',state:'held',updated_at:200,receipt_id:'receipt-middle'},
|
||||
],next_cursor:'page-two'};
|
||||
const second={pending_count:0,items:[
|
||||
{id:'old',title:'Oldest decision',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-old'},
|
||||
],next_cursor:null};
|
||||
const detail={id:'new',title:'Newest decision',project:'stackchain/dashboard',candidate_hash:'aaa',state:'released',receipt_id:'receipt-new'};
|
||||
const receipt={receipt_id:'receipt-new',gate_id:'new',candidate_hash:'aaa',state:'released',decided_at:300,checklist:{}};
|
||||
const gates=createHumanGates({
|
||||
storage:{getItem:()=>null,setItem(){throw new Error('history must not be cached')}},
|
||||
getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''},
|
||||
fetchJson:async path=>{
|
||||
requests.push(path);
|
||||
if(path==='api/v1/human-gates?state=history&limit=20') return first;
|
||||
if(path.includes('cursor=page-two')) return second;
|
||||
if(path==='api/v1/human-gates/new') return detail;
|
||||
if(path==='api/v1/human-gate-receipts/receipt-new') return receipt;
|
||||
throw new Error('unexpected '+path);
|
||||
},
|
||||
});
|
||||
(async()=>{
|
||||
await gates.showHistory();
|
||||
await gates.selectHistory('new');
|
||||
const selectedHtml=nodes.detail.innerHTML;
|
||||
await gates.loadMoreHistory();
|
||||
process.stdout.write(JSON.stringify({requests,listHtml:nodes.list.innerHTML,selectedHtml,detailHtml:nodes.detail.innerHTML,status:nodes.status.textContent}));
|
||||
})();
|
||||
""")
|
||||
assert output["requests"] == [
|
||||
"api/v1/human-gates?state=history&limit=20",
|
||||
"api/v1/human-gates/new",
|
||||
"api/v1/human-gate-receipts/receipt-new",
|
||||
"api/v1/human-gates?state=history&limit=20&cursor=page-two",
|
||||
]
|
||||
assert all(title in output["listHtml"] for title in ("Newest decision", "Middle decision", "Oldest decision"))
|
||||
assert "Load older decisions" not in output["listHtml"]
|
||||
assert output["detailHtml"] == output["selectedHtml"]
|
||||
assert output["status"] == "All 3 Human Gate decisions loaded."
|
||||
|
||||
|
||||
def test_failed_older_history_page_preserves_loaded_decisions_and_retries_in_place():
|
||||
output = run_node(r"""
|
||||
let attempts=0;
|
||||
const nodes={
|
||||
count:{},list:{innerHTML:'',querySelectorAll:()=>[],querySelector:()=>null},status:{textContent:''},panel:{},detail:{innerHTML:'receipt remains'},
|
||||
pendingTab:{setAttribute(){}},historyTab:{setAttribute(){}},
|
||||
};
|
||||
const gates=createHumanGates({
|
||||
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},
|
||||
fetchJson:async path=>{
|
||||
if(!path.includes('cursor=')) return {pending_count:0,items:[{id:'new',title:'Newest',candidate_hash:'aaa',state:'released',updated_at:300}],next_cursor:'older'};
|
||||
attempts += 1;
|
||||
if(attempts===1) throw new Error('History service timed out');
|
||||
return {pending_count:0,items:[{id:'old',title:'Oldest',candidate_hash:'bbb',state:'held',updated_at:100}],next_cursor:null};
|
||||
},
|
||||
});
|
||||
(async()=>{
|
||||
await gates.showHistory();
|
||||
nodes.detail.innerHTML='receipt remains';
|
||||
let message='';
|
||||
try{await gates.loadMoreHistory()}catch(error){message=error.message}
|
||||
const failed={message,html:nodes.list.innerHTML,status:nodes.status.textContent,detail:nodes.detail.innerHTML};
|
||||
await gates.loadMoreHistory();
|
||||
process.stdout.write(JSON.stringify({failed,attempts,html:nodes.list.innerHTML,status:nodes.status.textContent}));
|
||||
})();
|
||||
""")
|
||||
assert output["failed"]["message"] == "History service timed out"
|
||||
assert "Newest" in output["failed"]["html"]
|
||||
assert "Oldest" not in output["failed"]["html"]
|
||||
assert "Retry older decisions" in output["failed"]["html"]
|
||||
assert output["failed"]["status"] == "History service timed out Loaded decisions are still available."
|
||||
assert output["failed"]["detail"] == "receipt remains"
|
||||
assert output["attempts"] == 2
|
||||
assert "Oldest" in output["html"]
|
||||
assert output["status"] == "All 2 Human Gate decisions loaded."
|
||||
|
||||
|
||||
def test_queue_changes_publish_mobile_counts_and_authoritative_decision_completion():
|
||||
output = run_node(r"""
|
||||
const changes=[];
|
||||
|
|
@ -386,7 +470,7 @@ def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_
|
|||
def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache():
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in worker
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
|
||||
|
||||
def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason():
|
||||
|
|
@ -623,7 +707,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired():
|
|||
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
|
||||
assert "counts.gate = queueCounts.gate" in dashboard
|
||||
assert "gate:preparationItems.gate || []" in dashboard
|
||||
assert "stackchain-dashboard-shell-v148" in WORKER.read_text()
|
||||
assert "stackchain-dashboard-shell-v149" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell():
|
||||
|
|
@ -643,7 +727,8 @@ def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell(
|
|||
assert "historyTab:query('#human-gates-history')" in progressive
|
||||
assert ".human-gate-views button{min-height:44px" in css
|
||||
assert ".human-gate-history-card time" in css
|
||||
assert "stackchain-dashboard-shell-v148" in WORKER.read_text()
|
||||
assert ".human-gate-history-more{min-height:44px;width:100%" in css
|
||||
assert "stackchain-dashboard-shell-v149" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
|
||||
|
|
|
|||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v148" in worker
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
|||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v148" in worker
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
|||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v148" in worker
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
|
|
|||
|
|
@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
|||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in worker
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
|||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v148" in service_worker
|
||||
assert "stackchain-dashboard-shell-v149" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
|
|||
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/progressive-live-snapshot.js'" in source
|
||||
|
||||
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
|
@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
|||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
|
|
@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
|||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
|
|
@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -258,7 +258,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
|
@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -292,21 +292,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -1390,7 +1390,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
|||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
|||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v148';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v149';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v148" in source
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user