feat: attach screenshots to update replies (Closes #527)
This commit is contained in:
1 parent
beaf5e0587
commit
31f6097afc
21 files changed
+437
-32
No files matched your search
@@ -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
|
||||
@@ -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';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"] == ""
|
||||
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:''}};
|
||||
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": ""}
|
||||
assert output["result"]["confirmed"] == [{"status": "read"}]
|
||||
Reference in new issue
Block a user