From 51bd9d41f24d0b31dd50c8abb3a0c41bb0a1fd1e Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 21:58:28 +0000 Subject: [PATCH] feat: sync offline issue captures (#232) --- frontend/create-issue-sheet.js | 2 +- frontend/drafts.js | 25 ++++++ frontend/index.html | 108 +++++++++++++++++++----- frontend/issue-outbox.js | 123 +++++++++++++++++++++++++++ frontend/service-worker.js | 3 +- tests/test_drafts.py | 19 +++++ tests/test_issue_outbox.py | 150 +++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 8 files changed, 407 insertions(+), 24 deletions(-) create mode 100644 frontend/issue-outbox.js create mode 100644 tests/test_issue_outbox.py diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 7fa1bf1..e1f55bd 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -166,7 +166,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp } return { - saveDraft, loadDraft, loadLabels, loadMilestones, submit, + saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, }; } diff --git a/frontend/drafts.js b/frontend/drafts.js index 444817d..2d974b1 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -98,6 +98,26 @@ function createDraftInbox({ storage, now = () => Date.now() }) { return null; } + function parseOutbox(raw) { + try { + const record = JSON.parse(raw); + if (record?.version !== 1 || !Array.isArray(record.items)) return []; + return record.items.filter(item => + item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string' + ).map(item => ({ + id: 'stackchain.issue-outbox.v1:' + item.id, + outbox_id: item.id, + kind: 'issue-outbox', + status: item.status === 'attention' ? 'attention' : 'queued', + label: item.status === 'attention' ? 'Needs attention' : 'Queued issue', + repository: item.repository, + title: textPreview(item.title) || 'Untitled queued issue', + preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')), + updated_at: Number(item.queuedAt || 0), + })); + } catch (_error) { return []; } + } + function list() { const index = readIndex(); const seen = new Set(); @@ -107,6 +127,11 @@ function createDraftInbox({ storage, now = () => Date.now() }) { let raw; try { raw = storage.getItem(key); } catch (_error) { return; } + if (key === 'stackchain.issue-outbox.v1') { + parseOutbox(raw).forEach(item => drafts.push(item)); + seen.add(key); + return; + } const parsed = parse(key, raw); if (!parsed) return; seen.add(key); diff --git a/frontend/index.html b/frontend/index.html index e37b613..404a1e6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -75,7 +75,7 @@ textarea { resize: vertical; min-height: 120px; } .my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; } .draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; } .draft-preview { color:var(--muted); overflow-wrap:anywhere; } -.draft-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } .draft-actions button { min-height:44px; width:100%; } .my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); } .my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; } @@ -718,6 +718,7 @@ textarea { resize: vertical; min-height: 120px; } + @@ -798,11 +799,16 @@ textarea { resize: vertical; min-height: 120px; } let bulkConfirmationPending = false; let bulkMarkPending = false; let reviewHandoffPending = false; + let editingOutboxId = null; async function fetchReviewJson(url, options) { const response = await fetch(url, options); const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload.error || 'Review request failed.'); + if (!response.ok) { + const error = new Error(payload.error || payload.detail || 'Review request failed.'); + error.status = response.status; + throw error; + } return payload; } @@ -823,6 +829,7 @@ textarea { resize: vertical; min-height: 120px; } loadMilestones: item => issueController.loadMilestones(item), }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson }); const shareParams = new URLSearchParams(location.search); const sharedLaunch = { title: shareParams.get('title') || '', @@ -1243,15 +1250,22 @@ textarea { resize: vertical; min-height: 120px; } function renderDrafts() { const list = qs('#my-work-list'); - list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => - '
' + - '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + - '' + escapeHtml(item.title) + '' + - '' + escapeHtml(item.preview || 'Unfinished draft') + '' + - 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' + - '
' + - '
' - ).join('') : '
No unfinished drafts.
'; + list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { + const outboxActions = item.kind === 'issue-outbox' ? + '' + + '' + + '' : + '' + + ''; + const state = item.kind === 'issue-outbox' ? + '' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '' : ''; + return '
' + + '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + + '' + escapeHtml(item.title) + '' + + '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state + + 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' + + '
' + outboxActions + '
'; + }).join('') : '
No unfinished drafts.
'; list.querySelectorAll('.draft-resume').forEach(button => { button.addEventListener('click', () => { const item = lastDrafts[Number(button.dataset.draftIndex)]; @@ -1260,10 +1274,31 @@ textarea { resize: vertical; min-height: 120px; } else if (item.route) workRoute.open(item.route); }); }); + list.querySelectorAll('.draft-edit').forEach(button => { + button.addEventListener('click', () => { + const item = lastDrafts[Number(button.dataset.draftIndex)]; + const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id); + if (!queued) return; + editingOutboxId = queued.id; + issueCapture.saveDraft(queued); + openCreateIssueSheet(); + qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.'; + }); + }); + list.querySelectorAll('.draft-send').forEach(button => { + button.addEventListener('click', async () => { + const item = lastDrafts[Number(button.dataset.draftIndex)]; + if (!item?.outbox_id) return; + button.disabled = true; + applyOutboxResult(await issueOutbox.retry(item.outbox_id)); + }); + }); list.querySelectorAll('.draft-discard').forEach(button => { button.addEventListener('click', () => { if (!window.confirm('Discard this unfinished draft?')) return; - draftInbox.discard(button.dataset.draftId); + const item = lastDrafts[Number(button.dataset.draftIndex)]; + if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id); + else if (item) draftInbox.discard(item.id); lastDrafts = draftInbox.list(); const count = qs('[data-work-count="draft"]'); if (count) count.textContent = lastDrafts.length; @@ -1909,6 +1944,32 @@ textarea { resize: vertical; min-height: 120px; } qs('#new-issue').focus(); } + function applyOutboxResult(result, openCreated = false) { + (result.confirmed || []).forEach(confirmed => { + if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); + }); + if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot); + refreshMyWorkView(); + if (result.confirmed?.length) { + const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', '); + qs('#my-work-action-status').textContent = keys + ' created and assigned to you.'; + const confirmed = result.confirmed[result.confirmed.length - 1]; + const created = lastMyWork.find(item => + item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number + ); + if (openCreated && created) openRoutedWork(created, qs('#new-issue')); + } else if ((result.remaining || []).some(item => item.status === 'attention')) { + qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.'; + } else { + qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.'; + } + } + + async function flushIssueOutbox() { + if (!navigator.onLine || !issueOutbox.list().length) return; + applyOutboxResult(await issueOutbox.flush()); + } + function inlineAnchor(target) { return { path: target.dataset.reviewFilename, @@ -2491,7 +2552,10 @@ textarea { resize: vertical; min-height: 120px; } qs('#create-issue-title').focus(); }); qs('#cancel-new-issue').addEventListener('click', () => { - saveIssueCaptureDraft(); + if (editingOutboxId) { + issueCapture.clearDraft(); + editingOutboxId = null; + } else saveIssueCaptureDraft(); closeCreateIssueSheet(); }); ['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector => @@ -2521,18 +2585,16 @@ textarea { resize: vertical; min-height: 120px; } } const button = qs('#submit-new-issue'); button.disabled = true; - qs('#create-issue-status').textContent = 'Creating issue…'; + qs('#create-issue-status').textContent = navigator.onLine ? 'Sending issue…' : 'Saving issue to outbox…'; try { - const confirmed = await issueCapture.submit(captureDraft); - lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); - lastMyWork = buildMyWork(lastContextSnapshot); - const created = lastMyWork.find(item => - item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number - ); + const queued = editingOutboxId ? issueOutbox.update(editingOutboxId, captureDraft) : + issueOutbox.enqueue(captureDraft); + editingOutboxId = null; + issueCapture.clearDraft(); closeCreateIssueSheet(); refreshMyWorkView(); - qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.'; - openRoutedWork(created, qs('#new-issue')); + qs('#my-work-action-status').textContent = 'Queued for sync.'; + if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true); } catch (error) { qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.'; button.disabled = false; @@ -3089,6 +3151,7 @@ textarea { resize: vertical; min-height: 120px; } if (!navigator.onLine) showOfflineStatus(); window.addEventListener('offline', showOfflineStatus); window.addEventListener('online', reconnectLiveData); + window.addEventListener('online', flushIssueOutbox); qs('#refresh').addEventListener('click', load); qs('#start-work-session').addEventListener('click', () => { @@ -3173,6 +3236,7 @@ textarea { resize: vertical; min-height: 120px; } ); } contextPoller.start(); + flushIssueOutbox(); document.addEventListener('visibilitychange', () => { contextPoller.setVisible(!document.hidden); }); diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js new file mode 100644 index 0000000..871c8c4 --- /dev/null +++ b/frontend/issue-outbox.js @@ -0,0 +1,123 @@ +function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) { + const storageKey = 'stackchain.issue-outbox.v1'; + const operationId = createOperationId || (() => + globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) + ); + const pending = new Map(); + + function read() { + try { + const record = JSON.parse(storage?.getItem(storageKey) || 'null'); + if (record?.version !== 1 || !Array.isArray(record.items)) return []; + return record.items.filter(item => item && typeof item === 'object'); + } catch (_error) { return []; } + } + + function write(items) { + storage?.setItem(storageKey, JSON.stringify({ version: 1, items })); + } + + function enqueue(draft) { + const items = read(); + if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.'); + const item = { + id: String(operationId()).slice(0, 128), + operationId: '', + repository: String(draft?.repository || ''), + title: String(draft?.title || ''), + body: String(draft?.body || ''), + labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [], + status: 'queued', + queuedAt: Number(now()), + }; + item.operationId = item.id; + if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) { + item.milestoneId = Number(draft.milestoneId); + } + if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate); + items.push(item); + write(items); + return item; + } + + function update(id, draft) { + let updated = null; + write(read().map(item => { + if (item.id !== id) return item; + updated = { + ...item, + repository: String(draft?.repository || ''), title: String(draft?.title || ''), + body: String(draft?.body || ''), + labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [], + status: 'queued', + }; + delete updated.error; + return updated; + })); + return updated; + } + + function discard(id) { + const items = read(); + if (!items.some(item => item.id === id)) return false; + write(items.filter(item => item.id !== id)); + return true; + } + + async function sendItem(item) { + if (pending.has(item.id)) return pending.get(item.id); + const repository = item.repository.split('/').map(encodeURIComponent).join('/'); + const request = (async () => { + try { + const issue = await fetchJson('api/v1/repos/' + repository + '/issues', { + method: 'POST', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify({ + title: item.title, body: item.body, label_ids: item.labelIds, + ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), + ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), + }), + }); + discard(item.id); + return { issue }; + } catch (error) { + const status = Number(error?.status || 0); + if (status >= 400 && status < 500) { + write(read().map(candidate => candidate.id === item.id ? { + ...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240), + } : candidate)); + } + return { error, transient: !(status >= 400 && status < 500) }; + } + })(); + pending.set(item.id, request); + try { return await request; } + finally { if (pending.get(item.id) === request) pending.delete(item.id); } + } + + async function flush() { + const confirmed = []; + for (const item of read()) { + if (item.status === 'attention') continue; + const result = await sendItem(item); + if (result.issue) confirmed.push(result.issue); + if (result.transient) break; + } + return { confirmed, remaining: read() }; + } + + async function retry(id) { + const item = read().find(candidate => candidate.id === id); + if (!item) return { confirmed: [], remaining: read() }; + update(id, item); + const result = await sendItem({ ...item, status: 'queued' }); + return { confirmed: result.issue ? [result.issue] : [], remaining: read() }; + } + + return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index e3db038..aa21115 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'stackchain-dashboard-shell-v4'; +const CACHE = 'stackchain-dashboard-shell-v5'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const BASE = new URL('./', self.location.href).pathname; const SHELL = [ @@ -11,6 +11,7 @@ const SHELL = [ BASE + 'static/search-preview.js', BASE + 'static/widgets.js', BASE + 'static/drafts.js', + BASE + 'static/issue-outbox.js', BASE + 'static/offline-work.js', BASE + 'static/my-work.js', BASE + 'static/pick-work.js', diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 97fd698..7a48ac8 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -102,6 +102,25 @@ process.stdout.write(JSON.stringify(drafts)); assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18} +def test_draft_inbox_expands_issue_outbox_items_with_actionable_states(): + script = f""" +const createDraftInbox = require({json.dumps(str(DRAFTS))}); +const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:1,items:[ + {{id:'queued-1',repository:'stackchain/api',title:'Offline capture',body:'Context',status:'queued',queuedAt:100}}, + {{id:'attention-2',repository:'stackchain/web',title:'Fix labels',body:'Details',status:'attention',error:'Unknown label',queuedAt:200}}, +]}})]]); +const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const drafts = createDraftInbox({{storage,now:()=>300}}).list(); +process.stdout.write(JSON.stringify(drafts)); +""" + output = run_node(script) + + assert [item["outbox_id"] for item in output] == ["attention-2", "queued-1"] + assert [item["status"] for item in output] == ["attention", "queued"] + assert output[0]["label"] == "Needs attention" + assert output[1]["label"] == "Queued issue" + + @pytest.mark.anyio async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): html = await dashboard() diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py new file mode 100644 index 0000000..21db5d4 --- /dev/null +++ b/tests/test_issue_outbox.py @@ -0,0 +1,150 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from src.views import dashboard + + +OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js" + + +def run_node(script: str): + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{ + getItem:key => values.has(key) ? values.get(key) : null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +let sequence = 0; +const outbox = createIssueOutbox({{storage, createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}}); +outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}}); +outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}}); +const reloaded = createIssueOutbox({{storage}}).list(); +process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}})); +""" + output = run_node(script) + + assert [(item["repository"], item["title"]) for item in output["items"]] == [ + ("stackchain/api", "First"), + ("stackchain/web", "Second"), + ] + assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"] + assert all(item["status"] == "queued" for item in output["items"]) + assert output["stored"]["version"] == 1 + + +def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +let sequence = 0; +const calls = []; +const outbox = createIssueOutbox({{ + storage, + createOperationId:() => 'stable-' + (++sequence), + fetchJson:async (url, options) => {{ + calls.push({{url, key:options.headers['Idempotency-Key']}}); + if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }} + return {{repository:'stackchain/api',number:41,title:'First'}}; + }}, +}}); +outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}}); +outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}}); +outbox.flush().then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}}))); +""" + output = run_node(script) + + assert output["result"]["confirmed"][0]["number"] == 41 + assert output["calls"] == [ + {"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"}, + {"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"}, + ] + assert len(output["remaining"]) == 1 + assert output["remaining"][0]["title"] == "Second" + assert output["remaining"][0]["status"] == "queued" + + +def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +let invalid = true; +const calls = []; +const outbox = createIssueOutbox({{ + storage, createOperationId:() => 'stable-edit', + fetchJson:async (_url, options) => {{ + calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}}); + if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }} + return {{repository:'stackchain/api',number:42,title:'Fixed title'}}; + }}, +}}); +const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}}); +outbox.flush().then(async () => {{ + const attention = outbox.list()[0]; + outbox.update(queued.id, {{...attention,title:'Fixed title'}}); + invalid = false; + const result = await outbox.retry(queued.id); + process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}})); +}}); +""" + output = run_node(script) + + assert output["attention"]["status"] == "attention" + assert output["attention"]["error"] == "Title is invalid" + assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"] + assert output["calls"][1]["body"]["title"] == "Fixed title" + assert output["result"]["confirmed"][0]["number"] == 42 + assert output["remaining"] == [] + + +def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +let calls = 0; +let release; +const gate = new Promise(resolve => {{ release = resolve; }}); +const outbox = createIssueOutbox({{ + storage, createOperationId:() => 'single-flight', + fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }}, +}}); +const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}}); +const reconnect = outbox.flush(); +const sendNow = outbox.retry(queued.id); +release(); +Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}}))); +""" + output = run_node(script) + + assert output["calls"] == 1 + assert output["remaining"] == [] + assert sum(len(result["confirmed"]) for result in output["results"]) >= 1 + + +@pytest.mark.anyio +async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions(): + html = await dashboard() + + assert '' in html + assert "createIssueOutbox({ storage: localStorage" in html + assert "issueOutbox.enqueue(captureDraft)" in html + assert "navigator.onLine" in html + assert "window.addEventListener('online', flushIssueOutbox)" in html + assert 'class="draft-send"' in html + assert 'class="draft-edit"' in html + assert 'Queued for sync' in html + assert 'Needs attention' in html + assert "issueOutbox.discard(item.outbox_id)" in html + assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index e925c43..3fe1453 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -83,6 +83,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/search-preview.js", "/dashboard/static/widgets.js", "/dashboard/static/drafts.js", + "/dashboard/static/issue-outbox.js", "/dashboard/static/offline-work.js", "/dashboard/static/my-work.js", "/dashboard/static/pick-work.js", -- 2.43.0