From 31f6097afceeb9b8cfe41f636e556e677bca61e3 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 01:04:18 +0000 Subject: [PATCH] feat: attach screenshots to update replies (Closes #527) --- frontend/authored-outbox.js | 7 +- frontend/background-issue-sync.js | 54 ++++++- frontend/dashboard.css | 6 + frontend/dashboard.js | 43 +++++- frontend/index.html | 9 ++ frontend/my-work.js | 19 ++- frontend/service-worker.js | 2 +- frontend/update-reply-read-next.js | 18 ++- src/gitea_proxy.py | 37 +++++ src/main.py | 54 +++++++ src/request_boundary.py | 6 +- tests/test_comment_next.py | 2 +- tests/test_frontend_bundle.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 20 +-- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- tests/test_update_reply_attachments.py | 178 ++++++++++++++++++++++ 21 files changed, 437 insertions(+), 32 deletions(-) create mode 100644 tests/test_update_reply_attachments.py diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index 68f1e28..05f7252 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -75,7 +75,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, status: 'queued', queuedAt: Number(now()), ...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}), - ...(['issue-comment', 'pull-comment'].includes(message.kind) && message.attachment ? { + ...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? { attachment: { filename: String(message.attachment.filename || ''), contentType: String(message.attachment.contentType || ''), @@ -99,6 +99,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, async function enqueueDurably(message) { const item = enqueue(message, false); + if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) && + (!backgroundSync?.reconcile || !backgroundSync?.requestSync)) { + write(read().filter(candidate => candidate.id !== item.id), false); + throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.'); + } if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) { return { item, background: false, durability: 'foreground-only' }; } diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 24c2213..36e9c0a 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -437,10 +437,32 @@ function createBackgroundIssueSync({ async function deliverReplyRead(item) { let current = item; if (!current.replyConfirmed) { + let attachmentMarkdown = current.attachmentMarkdown; + if (current.attachment && !attachmentMarkdown) { + const uploaded = await requestStage(current, + base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', { + method:'POST', + headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') }, + body:attachmentMultipart(current.attachment), + }); + attachmentMarkdown = String(uploaded?.markdown || ''); + if (!attachmentMarkdown) { + const error = new Error('The server did not confirm the screenshot upload.'); + error.status = 422; + throw error; + } + await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown })); + current = { ...current, attachmentMarkdown }; + } + const text = String(current.body || '').trim(); + const replyBody = attachmentMarkdown ? + (text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text; + const options = authoredRequest('', { ...current, body:replyBody }).options; + if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply'); await requestStage( current, base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', - authoredRequest('', current).options, + options, ); const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true })); if (checkpointed === false) throw new Error('Background delivery claim was lost.'); @@ -548,10 +570,40 @@ function createBackgroundIssueSync({ ); } + async function deliverUpdateScreenshotReply(item) { + let current = item; + let attachmentMarkdown = current.attachmentMarkdown; + if (!attachmentMarkdown) { + const uploaded = await requestStage(current, + base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', { + method:'POST', + headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') }, + body:attachmentMultipart(current.attachment), + }); + attachmentMarkdown = String(uploaded?.markdown || ''); + if (!attachmentMarkdown) { + const error = new Error('The server did not confirm the screenshot upload.'); + error.status = 422; + throw error; + } + await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown })); + current = { ...current, attachmentMarkdown }; + } + const text = String(current.body || '').trim(); + return requestStage(current, + base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', { + method:'POST', + headers:{ Accept:'application/json', 'Content-Type':'application/json', + 'Idempotency-Key':stageOperationId(current.operationId, 'reply') }, + body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }), + }); + } + async function deliver(item) { const request = deliveryRequest(item); try { const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) : + item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) : item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ? await deliverScreenshotComment(item) : item.attachment && !item.kind ? await deliverIssueCapture(item) : await requestStage(item, request.url, request.options); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 428de66..d8cd931 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -255,6 +255,12 @@ textarea { resize: vertical; min-height: 120px; } .update-reply button { min-height:44px; width:100%; } .update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .update-reply-actions button { min-height:44px; width:100%; } +.update-reply .issue-attachment-preview { width:100%; min-width:0; } +@media (max-width:390px) { + .update-reply-actions { grid-template-columns:1fr; } + .update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; } + .update-reply .issue-attachment-preview img { width:56px; height:56px; } +} .update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .update-ownership-actions button { min-width:0; width:100%; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 5786cbe..ef21b86 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -324,6 +324,23 @@ ); }, }); + const updateReplyAttachmentController = issueAttachment.mount({ + input: qs('#update-reply-attachment'), + preview: qs('#update-reply-attachment-preview'), + image: qs('#update-reply-attachment-image'), + meta: qs('#update-reply-attachment-meta'), + remove: qs('#remove-update-reply-attachment'), + status: qs('#update-reply-status'), + readyMessage: 'Screenshot ready to send with this reply.', + removedMessage: 'Screenshot removed. Your reply is unchanged.', + createObjectURL: file => URL.createObjectURL(file), + revokeObjectURL: url => URL.revokeObjectURL(url), + upload: payload => fetchReviewJson( + 'api/v1/notifications/' + encodeURIComponent(payload.notificationId) + '/attachments', + { method:'POST', headers:{ Accept:'application/json', 'Idempotency-Key':payload.operation_id }, + body:issueAttachment.multipart(payload) }, + ), + }); const createIssueAttachmentController = issueAttachment.mount({ input: qs('#create-issue-attachment'), preview: qs('#create-issue-attachment-preview'), @@ -635,6 +652,9 @@ queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId), loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item), onOpen: item => { + if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) { + updateReplyAttachmentController.clear(); + } selectedUpdate = item; updateMentions.dismiss(); qs('#update-sheet').classList.add('open'); @@ -1093,6 +1113,7 @@ post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId), markRead: markNotificationRead, queue: message => authoredOutbox.enqueueDurably(message), + deliver: item => authoredOutbox.retry(item.id, activeFlushLogin), canQueue: canQueueMessage, accept: item => { notificationReplier.saveDraft(item, ''); @@ -3080,6 +3101,7 @@ return; } mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel')); + updateReplyAttachmentController.clear(); qs('#update-sheet').classList.remove('open'); selectedUpdate = null; if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus(); @@ -4448,21 +4470,26 @@ qs('#send-update-reply').addEventListener('click', async () => { if (!selectedUpdate) return; const body = qs('#update-reply').value.trim(); - if (!body) { - qs('#update-reply-status').textContent = 'Write a reply before sending.'; + const attachment = await updateReplyAttachmentController.serialize(); + if (!body && !attachment) { + qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.'; qs('#update-reply').focus(); return; } qs('#send-update-reply').disabled = true; - const result = await notificationReplier.submit(selectedUpdate, body); + updateReplyAttachmentController.setBusy(true); + const result = await notificationReplier.submit(selectedUpdate, body, attachment); qs('#send-update-reply').disabled = false; + updateReplyAttachmentController.setBusy(false); if (result?.queued) { qs('#update-reply').value = ''; + updateReplyAttachmentController.clear(); refreshMyWorkView(); qs('#my-work-action-status').textContent = 'Reply queued for sync.'; } else if (result) { notificationReader.appendReply(result); qs('#update-reply').value = ''; + updateReplyAttachmentController.clear(); qs('#mark-update-read-next').focus(); } else { qs('#update-reply').focus(); @@ -4472,8 +4499,9 @@ if (!selectedUpdate) return; const item = selectedUpdate; const body = qs('#update-reply').value.trim(); - if (!body) { - qs('#update-reply-status').textContent = 'Write a reply before sending.'; + const attachment = await updateReplyAttachmentController.serialize(); + if (!body && !attachment) { + qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.'; qs('#update-reply').focus(); return; } @@ -4481,10 +4509,12 @@ const sendButton = qs('#send-update-reply'); button.disabled = true; sendButton.disabled = true; + updateReplyAttachmentController.setBusy(true); qs('#update-reply-status').textContent = 'Replying, then marking read…'; const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(); try { - const result = await updateReplyReadNext.submit(item, body, operationId); + const result = await updateReplyReadNext.submit(item, body, operationId, attachment); + if (result?.accepted) updateReplyAttachmentController.clear(); if (result?.accepted) qs('#my-work-action-status').textContent = result.delivery === 'posted' ? 'Reply posted and update marked read.' : 'Reply and read acknowledgement queued for sync.'; @@ -4494,6 +4524,7 @@ } finally { button.disabled = false; sendButton.disabled = false; + updateReplyAttachmentController.setBusy(false); } }); qs('#mark-update-read-next').addEventListener('click', async () => { diff --git a/frontend/index.html b/frontend/index.html index e8f14b5..321f547 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -516,6 +516,15 @@
+
+ + +
+
diff --git a/frontend/my-work.js b/frontend/my-work.js index bed62c9..7cc00ec 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -404,7 +404,7 @@ function createNotificationReplier({ } catch (_error) { /* Keep the editable textarea as the fallback. */ } }, - async submit(item, body) { + async submit(item, body, attachment = null) { if (pending) return false; pending = true; this.saveDraft(item, body); @@ -415,6 +415,23 @@ function createNotificationReplier({ } catch (_error) { operationId = String(createOperationId()).slice(0, 128); } onStatus('Sending reply…'); try { + if (attachment) { + onStatus('Saving screenshot for durable delivery…'); + const admission = await authoredOutbox.enqueueDurably({ + kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment, + }); + const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin); + if (!delivery.confirmed?.length) { + const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id); + if (remaining?.status === 'attention') return false; + onStatus('Queued for sync when the connection returns.'); + return { queued:true }; + } + try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); } + catch (_error) { /* Confirmed delivery is authoritative. */ } + onStatus('Reply posted. You can mark this update read when ready.'); + return delivery.confirmed[0]; + } const result = await post(item.notification_id, body, operationId); try { storage.removeItem(keyFor(item)); } catch (_error) { /* The posted reply is still authoritative. */ } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 4b95338..8ce435a 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v86'; +const CACHE = 'stackchain-dashboard-shell-v87'; 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; diff --git a/frontend/update-reply-read-next.js b/frontend/update-reply-read-next.js index 8bd5f82..6e182d8 100644 --- a/frontend/update-reply-read-next.js +++ b/frontend/update-reply-read-next.js @@ -1,5 +1,5 @@ function createUpdateReplyReadNext({ - post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined, + post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined, }) { let inFlight = null; @@ -22,11 +22,25 @@ function createUpdateReplyReadNext({ }; } - function submit(item, body, operationId) { + function submit(item, body, operationId, attachment = null) { if (inFlight) return inFlight; inFlight = (async () => { let replyConfirmed = false; try { + if (attachment) { + const admission = await queue({ + kind:'update-reply-read', notificationId:item.notification_id, body, + operationId, replyConfirmed:false, attachment, + }); + if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.'); + const outcome = deliver ? await deliver(admission.item) : null; + const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id); + if (remaining?.status === 'attention') { + throw new Error(remaining.error || 'Reply and screenshot need attention.'); + } + accept(item); + return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) }; + } try { await post(item, body, operationId); replyConfirmed = true; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 5985236..2d10f4d 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -892,6 +892,43 @@ async def reply_to_notification(thread_id: int, body: str) -> dict: return _normalize_issue_comment(comment) +async def upload_notification_attachment( + thread_id: int, filename: str, content_type: str, content: bytes +) -> dict: + """Upload to the exact issue or pull identified by a trusted notification.""" + thread = await fetch(f"notifications/threads/{thread_id}") + if not isinstance(thread, dict): + raise ValueError("Gitea notification thread response was not an object") + repository = thread.get("repository") + subject = thread.get("subject") + if not isinstance(repository, dict) or not isinstance(subject, dict): + raise ValueError("Notification does not identify a conversation") + subject_path = _gitea_api_path(subject.get("url")) + match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path) + if ( + not match + or match.group(1) != repository.get("full_name") + or subject.get("type") not in {"Issue", "Pull"} + ): + raise ValueError("Notification subject is not a supported conversation") + # Gitea stores pull-request assets on its shared issue asset endpoint. + response = await _get_client().post( + f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/assets", + headers=_auth(), params={"name": filename}, + files={"attachment": (filename, content, content_type)}, + ) + response.raise_for_status() + attachment = response.json() + if not isinstance(attachment, dict): + raise ValueError("Gitea attachment response was not an object") + name = attachment.get("name") + url = _safe_web_url(attachment.get("browser_download_url")) + size = attachment.get("size") + if not isinstance(name, str) or not name or not url or not isinstance(size, int): + raise ValueError("Gitea did not confirm the attachment") + return {"name": name, "url": url, "size": size} + + async def close_issue(repository: str, number: int) -> dict: response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", diff --git a/src/main.py b/src/main.py index ccb2898..2388269 100644 --- a/src/main.py +++ b/src/main.py @@ -2991,6 +2991,60 @@ async def reply_to_notification( return JSONResponse(result, status_code=201) +@app.post("/api/v1/notifications/{thread_id}/attachments", status_code=201) +async def attach_to_notification( + request: Request, + thread_id: int = PathParam(gt=0), + idempotency_key: str | None = Header(default=None, max_length=128), +) -> JSONResponse: + try: + if request.headers.get("content-type", "").lower().startswith("multipart/form-data"): + form = await request.form() + uploaded = form.get("file") + if not isinstance(uploaded, UploadFile): + raise ValueError("screenshot file is required") + filename = str(uploaded.filename or "") + content_type = str(uploaded.content_type or "") + content = _validate_binary_attachment(filename, content_type, await uploaded.read()) + else: + attachment = IssueAttachment.model_validate(await request.json()) + filename = attachment.filename + content_type = attachment.content_type + content = attachment.content() + except (ValueError, ValidationError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + async def upload_attachment(): + result = await gitea_proxy.upload_notification_attachment( + thread_id, filename, content_type, content + ) + safe_name = ( + result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + .replace("\r", " ").replace("\n", " ") + ) + safe_url = result["url"].replace("<", "%3C").replace(">", "%3E") + result["markdown"] = f"![{safe_name}](<{safe_url}>)" + return result + + try: + result = await _run_idempotent_authored_action( + upload_attachment(), idempotency_key=idempotency_key, + fingerprint=("notification-attachment", thread_id, filename, content_type, + hashlib.sha256(content).hexdigest()), + timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, + ) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except Exception: + return JSONResponse( + {"error": "The screenshot could not be uploaded. Your draft is safe; please retry."}, + status_code=503, headers={"Retry-After": "1"}, + ) + return JSONResponse(result, status_code=201) + + @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim") async def claim_available_issue( owner: str, repo: str, number: int = PathParam(gt=0) diff --git a/src/request_boundary.py b/src/request_boundary.py index 20020b2..0914555 100644 --- a/src/request_boundary.py +++ b/src/request_boundary.py @@ -17,8 +17,10 @@ def request_body_limit(method: str, path: str) -> int | None: return SESSION_BODY_LIMIT if ( normalized_method == "POST" - and path.startswith("/api/v1/repos/") - and ("/issues/" in path or "/pulls/" in path) + and ( + (path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path)) + or path.startswith("/api/v1/notifications/") + ) and path.endswith("/attachments") ): return ISSUE_ATTACHMENT_BODY_LIMIT diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 0702e0a..b0c345c 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -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-v86" in worker + assert "stackchain-dashboard-shell-v87" in worker diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 77ac34f..7388c2f 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -89,7 +89,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path): worker = changed_frontend / "service-worker.js" worker.write_text( worker.read_text().replace( - "const CACHE = 'stackchain-dashboard-shell-v86';", + "const CACHE = 'stackchain-dashboard-shell-v87';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 258b9bf..e64f184 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,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-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 2c62d09..4a59c7f 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,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-v86" in worker + assert "stackchain-dashboard-shell-v87" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index e149b73..f5ae168 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -41,7 +41,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-v86" in worker + assert "stackchain-dashboard-shell-v87" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index d70af78..5ea4903 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 31ca15d..1307134 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -125,7 +125,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -134,14 +134,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v86" in source + assert "stackchain-dashboard-shell-v87" 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-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -149,7 +149,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-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -157,14 +157,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v86" in source + assert "stackchain-dashboard-shell-v87" 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-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -173,21 +173,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-v86" in source + assert "stackchain-dashboard-shell-v87" 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-v86" in source + assert "stackchain-dashboard-shell-v87" 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-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/update-ownership.js'" in source @@ -397,7 +397,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-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index aa9f00d..d041f95 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -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-v86';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v87';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index cec781c..6b2d48a 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); 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-v86" in source + assert "stackchain-dashboard-shell-v87" in source assert "BASE + 'static/today-sync.js'" in source diff --git a/tests/test_update_reply_attachments.py b/tests/test_update_reply_attachments.py new file mode 100644 index 0000000..1542bac --- /dev/null +++ b/tests/test_update_reply_attachments.py @@ -0,0 +1,178 @@ +import json +import subprocess +from pathlib import Path + +import httpx +import pytest + +from src import gitea_proxy, main +from tests.dashboard_bundle import dashboard + + +ROOT = Path(__file__).parents[1] +OUTBOX = ROOT / "frontend" / "authored-outbox.js" +SYNC = ROOT / "frontend" / "background-issue-sync.js" +PNG = b"\x89PNG\r\n\x1a\nmobile-update" + + +def run_node(script: str): + return json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + +@pytest.fixture(autouse=True) +def clear_idempotency(): + main._authored_action_operations.clear() + main._idempotency_ledger.clear() + yield + main._authored_action_operations.clear() + main._idempotency_ledger.clear() + + +@pytest.mark.anyio +async def test_notification_attachment_endpoint_resolves_exact_pull_server_side(monkeypatch): + calls = [] + + async def upload(thread_id, filename, content_type, content): + calls.append((thread_id, filename, content_type, content)) + return {"name": filename, "url": "https://forge.example/a/proof.webp", "size": len(content)} + + monkeypatch.setattr(main.gitea_proxy, "upload_notification_attachment", upload, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/notifications/527/attachments", + files={"file": ("proof.png", PNG, "image/png")}, + headers={"Idempotency-Key": "reply-527:attachment"}, + ) + + assert response.status_code == 201 + assert response.json()["markdown"] == "![proof.png]()" + assert calls == [(527, "proof.png", "image/png", PNG)] + assert main.request_body_limit("POST", "/api/v1/notifications/527/attachments") == 2 * 1024 * 1024 + 64 * 1024 + + +@pytest.mark.anyio +async def test_proxy_notification_upload_trusts_only_matching_gitea_subject_path(): + requests = [] + + async def handler(request): + requests.append(request) + if request.method == "GET": + return httpx.Response(200, json={ + "repository": {"full_name": "stackchain/web"}, + "subject": { + "type": "Pull", + "url": "http://127.0.0.1:3000/api/v1/repos/stackchain/web/pulls/31", + }, + }) + return httpx.Response(201, json={ + "name": "proof.png", "size": len(PNG), + "browser_download_url": "https://forge.example/a/proof.png", + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.upload_notification_attachment(527, "proof.png", "image/png", PNG) + finally: + await gitea_proxy.stop_client() + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/api/v1/notifications/threads/527"), + ("POST", "/api/v1/repos/stackchain/web/issues/31/assets"), + ] + assert result["url"] == "https://forge.example/a/proof.png" + + +@pytest.mark.anyio +async def test_update_reply_composer_offers_mobile_safe_removable_screenshot_preview(): + html = await dashboard() + + assert 'id="update-reply-attachment"' in html + assert 'accept="image/png,image/jpeg,image/webp"' in html + assert 'id="update-reply-attachment-preview"' in html + assert 'id="remove-update-reply-attachment"' in html + assert "const updateReplyAttachmentController = issueAttachment.mount({" in html + assert "await updateReplyAttachmentController.serialize()" in html + assert ".update-reply .issue-attachment-preview { width:100%; min-width:0; }" in html + assert "@media (max-width:390px)" in html + + +def test_authored_outbox_durably_keeps_account_bound_update_screenshot_out_of_localstorage(): + script = f""" +const createOutbox=require({json.dumps(str(OUTBOX))}); +const values=new Map();const mirrored=[]; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox=createOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{ + reconcile:async items=>mirrored.push(items),requestSync:async()=>{{}}, +}}}}); +(async()=>{{const result=await outbox.enqueueDurably({{ + kind:'update-reply-read',notificationId:527,body:'',operationId:'reply-image', + attachment:{{filename:'phone.png',contentType:'image/png',blob:new Blob(['private-bytes'],{{type:'image/png'}})}}, +}});process.stdout.write(JSON.stringify({{ + result,local:outbox.list()[0],raw:values.get('stackchain.authored-outbox.v1'), + durable:{{ownerLogin:mirrored[0][0].ownerLogin,text:await mirrored[0][0].attachment.blob.text()}}, +}}));}})(); +""" + output = run_node(script) + + assert output["local"]["attachment"] == { + "filename": "phone.png", "contentType": "image/png", "stored": True + } + assert output["durable"] == {"ownerLogin": "timmy", "text": "private-bytes"} + assert "private-bytes" not in output["raw"] + + +def test_update_screenshot_is_not_admitted_without_indexeddb_durability(): + script = f""" +const createOutbox=require({json.dumps(str(OUTBOX))}); +const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox=createOutbox({{storage,getOwnerLogin:()=>'timmy'}}); +(async()=>{{let error='';try{{await outbox.enqueueDurably({{ + kind:'update-reply',notificationId:527,body:'proof',operationId:'no-db', + attachment:{{filename:'phone.png',contentType:'image/png',data:'private-bytes'}}, +}});}}catch(caught){{error=caught.message;}} +process.stdout.write(JSON.stringify({{error,items:outbox.list(),raw:values.get('stackchain.authored-outbox.v1')}}));}})(); +""" + output = run_node(script) + + assert output["error"] == "Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry." + assert output["items"] == [] + assert "private-bytes" not in output["raw"] + + +def test_background_update_reply_screenshot_checkpoints_upload_then_reply_then_read(): + script = f""" +const createSync=require({json.dumps(str(SYNC))}); +let item={{id:'reply-image',operationId:'reply-image',ownerLogin:'timmy',status:'queued', + kind:'update-reply-read',notificationId:527,body:'',replyConfirmed:false, + attachment:{{filename:'phone.webp',contentType:'image/webp',blob:new Blob(['pixels'],{{type:'image/webp'}})}}}}; +const calls=[];let replyAttempts=0; +const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}}, + complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}}; +const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}}; + calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':options.body?JSON.parse(options.body):null}}); + if(url.endsWith('/attachments'))return{{markdown:'![phone.webp](https://forge.example/phone.webp)'}}; + if(url.endsWith('/reply') && replyAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}} + return url.endsWith('/reply')?{{id:8}}:{{status:'read'}}; +}}; +(async()=>{{const sync=createSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}} + const checkpoint={{attachmentMarkdown:item.attachmentMarkdown,replyConfirmed:item.replyConfirmed}}; + const result=await sync.flush();process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})(); +""" + output = run_node(script) + + assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.webp]") + assert output["checkpoint"]["replyConfirmed"] is False + assert [call["url"] for call in output["calls"]] == [ + "api/v1/notifications/527/attachments", + "api/v1/notifications/527/reply", + "api/v1/notifications/527/reply", + "api/v1/notifications/527/read", + ] + assert [call["key"] for call in output["calls"][:3]] == [ + "reply-image:attachment", "reply-image:reply", "reply-image:reply" + ] + assert output["calls"][2]["body"] == {"body": "![phone.webp](https://forge.example/phone.webp)"} + assert output["result"]["confirmed"] == [{"status": "read"}] -- 2.43.0