feat: use binary screenshot transport (Closes #491)
This commit is contained in:
1 parent
59b4d5e6bf
commit
16604d697b
15 files changed
+306
-130
No files matched your search
@@ -498,7 +498,7 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
assert "authoredOutbox.reconcileBackground(records)" in html
|
||||
|
||||
|
||||
def test_screenshot_comment_admits_bytes_to_indexeddb_without_putting_them_in_localstorage():
|
||||
def test_screenshot_comment_admits_blob_to_indexeddb_without_putting_bytes_in_localstorage():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors=[];
|
||||
@@ -508,12 +508,15 @@ const outbox=createAuthoredOutbox({{
|
||||
backgroundSync:{{reconcile:async(items,lane)=>mirrors.push({{items,lane}}),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const blob=new Blob(['PRIVATE-IMAGE-BYTES'],{{type:'image/png'}});
|
||||
const admission=await outbox.enqueueDurably({{
|
||||
kind:'issue-comment',repository:'stackchain/dashboard',number:477,
|
||||
body:'Broken at 320px',operationId:'comment-image-477',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'PRIVATE-IMAGE-BYTES'}},
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{admission,local:values.get('stackchain.authored-outbox.v1'),mirrored:mirrors[0]}}));
|
||||
const durable=mirrors[0].items[0].attachment;
|
||||
process.stdout.write(JSON.stringify({{admission,local:values.get('stackchain.authored-outbox.v1'),
|
||||
mirrored:{{lane:mirrors[0].lane,isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}}}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
@@ -523,8 +526,9 @@ const outbox=createAuthoredOutbox({{
|
||||
assert local_item["attachment"] == {
|
||||
"filename": "phone.png", "contentType": "image/png", "stored": True
|
||||
}
|
||||
assert output["mirrored"]["lane"] == "authored"
|
||||
assert output["mirrored"]["items"][0]["attachment"]["data"] == "PRIVATE-IMAGE-BYTES"
|
||||
assert output["mirrored"] == {
|
||||
"lane": "authored", "isBlob": True, "size": 19, "text": "PRIVATE-IMAGE-BYTES"
|
||||
}
|
||||
assert output["admission"]["durability"] == "background"
|
||||
|
||||
|
||||
|
||||
@@ -82,7 +82,10 @@ const store = {{
|
||||
}};
|
||||
let uploadAttempts=0;
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
let body=null;
|
||||
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
|
||||
@@ -113,6 +116,37 @@ const fetchJson=async(url,options={{}})=>{{
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const blob=new Blob(['offline-binary'],{{type:'image/png'}});
|
||||
const item={{id:'binary',operationId:'binary',ownerLogin:'timmy',repository:'o/r',title:'Visual',body:'',labelIds:[],
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob}}}};
|
||||
let queued=true;
|
||||
const observed={{}};
|
||||
const store={{claimNext:async()=>queued?(queued=false,{{...item}}):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
if(url.endsWith('/issues'))return{{number:7}};
|
||||
if(url.endsWith('/attachments')){{const file=options.body.get('file');Object.assign(observed,{{
|
||||
isFormData:options.body instanceof FormData,contentTypeHeader:options.headers['Content-Type']||null,
|
||||
filename:file.name,type:file.type,size:file.size,text:await file.text()
|
||||
}});return{{markdown:''}};}}
|
||||
return{{id:8}};
|
||||
}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(observed));}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"isFormData": True,
|
||||
"contentTypeHeader": None,
|
||||
"filename": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 14,
|
||||
"text": "offline-binary",
|
||||
}
|
||||
|
||||
|
||||
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
@@ -175,7 +209,10 @@ const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transfor
|
||||
fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
let body=null;
|
||||
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('Comment unavailable');error.status=503;throw error;}}
|
||||
return{{id:91}};
|
||||
@@ -435,7 +472,7 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
]
|
||||
|
||||
|
||||
def test_reconciling_a_lightweight_attachment_reference_preserves_indexeddb_bytes():
|
||||
def test_reconciling_a_lightweight_attachment_reference_preserves_indexeddb_blob():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
@@ -447,21 +484,23 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',data:'first-image-bytes'}},
|
||||
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
|
||||
}}]);
|
||||
await store.reconcile([
|
||||
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
|
||||
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'second-image-bytes'}}}},
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(await store.snapshot()));
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
|
||||
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output[0]["attachment"]["data"] == "first-image-bytes"
|
||||
assert output[1]["attachment"]["data"] == "second-image-bytes"
|
||||
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
|
||||
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
|
||||
|
||||
|
||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
||||
|
||||
@@ -18,15 +18,19 @@ def run_node(script: str) -> str:
|
||||
).stdout
|
||||
|
||||
|
||||
def test_mobile_attachment_prepares_comment_and_reuses_confirmed_upload():
|
||||
def test_mobile_attachment_prepares_comment_and_reuses_confirmed_binary_upload():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls = [];
|
||||
const file = {{name:'checkout.png', type:'image/png', size:8}};
|
||||
const file = new Blob(['png-bytes'],{{type:'image/png'}}); file.name='checkout.png';
|
||||
const controller = attachment.create({{
|
||||
createOperationId: () => 'attachment-comment-471',
|
||||
readDataUrl: async selected => {{ calls.push('read:' + selected.name); return 'data:image/png;base64,iVBORw0KGgo='; }},
|
||||
upload: async payload => {{ calls.push(payload); return {{markdown:''}}; }},
|
||||
readDataUrl: async () => {{ throw new Error('base64 conversion must not run'); }},
|
||||
upload: async payload => {{ calls.push({{
|
||||
repository:payload.repository,number:payload.number,filename:payload.filename,
|
||||
content_type:payload.content_type,isBlob:payload.blob instanceof Blob,
|
||||
text:await payload.blob.text(),operation_id:payload.operation_id
|
||||
}}); return {{markdown:''}}; }},
|
||||
}});
|
||||
controller.select(file);
|
||||
(async()=>{{
|
||||
@@ -40,19 +44,17 @@ controller.select(file);
|
||||
"Layout breaks\n\n"
|
||||
)
|
||||
assert output["second"] == output["first"]
|
||||
assert output["calls"] == [
|
||||
"read:checkout.png",
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": 17,
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": "iVBORw0KGgo=",
|
||||
"operation_id": "attachment-comment-471",
|
||||
},
|
||||
]
|
||||
assert output["calls"] == [{
|
||||
"repository": "stackchain/api",
|
||||
"number": 17,
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"isBlob": True,
|
||||
"text": "png-bytes",
|
||||
"operation_id": "attachment-comment-471",
|
||||
}]
|
||||
assert output["state"] == {
|
||||
"name": "checkout.png", "size": 8, "uploaded": True
|
||||
"name": "checkout.png", "size": 9, "uploaded": True
|
||||
}
|
||||
|
||||
|
||||
@@ -82,26 +84,50 @@ controller.select({{name:'first.png',type:'image/png',size:8}});
|
||||
]
|
||||
|
||||
|
||||
def test_selected_screenshot_serializes_for_durable_issue_capture_without_uploading():
|
||||
def test_selected_screenshot_serializes_as_binary_blob_without_base64_expansion():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls=[];
|
||||
const file=new Blob(['binary-image'],{{type:'image/webp'}});
|
||||
file.name='phone.webp';
|
||||
const controller=attachment.create({{
|
||||
readDataUrl:async file=>{{calls.push('read:'+file.name);return 'data:image/webp;base64,UklGRg==';}},
|
||||
readDataUrl:async()=>{{throw new Error('base64 conversion must not run');}},
|
||||
upload:async()=>{{calls.push('upload');}},
|
||||
}});
|
||||
controller.select({{name:'phone.webp',type:'image/webp',size:8}});
|
||||
controller.serialize().then(value=>process.stdout.write(JSON.stringify({{value,calls}})));
|
||||
controller.select(file);
|
||||
controller.serialize().then(async value=>process.stdout.write(JSON.stringify({{
|
||||
filename:value.filename,contentType:value.contentType,
|
||||
isBlob:value.blob instanceof Blob,size:value.blob.size,text:await value.blob.text(),calls
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"value": {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"data": "UklGRg==",
|
||||
},
|
||||
"calls": ["read:phone.webp"],
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"isBlob": True,
|
||||
"size": 12,
|
||||
"text": "binary-image",
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_binary_screenshot_builds_multipart_body_with_original_bytes():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
const blob=new Blob(['original-bytes'],{{type:'image/png'}});
|
||||
const form=attachment.multipart({{filename:'phone.png',contentType:'image/png',blob}});
|
||||
const file=form.get('file');
|
||||
(async()=>process.stdout.write(JSON.stringify({{
|
||||
filename:file.name,type:file.type,size:file.size,text:await file.text()
|
||||
}})))();
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"filename": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 14,
|
||||
"text": "original-bytes",
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +246,7 @@ process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.
|
||||
}
|
||||
|
||||
|
||||
def test_issue_comment_actions_upload_before_posting_and_clear_after_acceptance():
|
||||
def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_after_acceptance():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert "issueAttachment.mount({" in source
|
||||
@@ -229,6 +255,7 @@ def test_issue_comment_actions_upload_before_posting_and_clear_after_acceptance(
|
||||
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
|
||||
assert source.count("issueAttachmentController.clear();") >= 2
|
||||
assert "'Idempotency-Key': payload.operation_id" in source
|
||||
assert "body: issueAttachment.multipart(payload)" in source
|
||||
|
||||
|
||||
def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
|
||||
|
||||
@@ -58,6 +58,32 @@ async def test_attachment_endpoint_uploads_valid_screenshot_to_assigned_issue(mo
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_accepts_binary_multipart_without_base64_expansion(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
calls.append((repository, number, filename, content_type, content))
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
files={"file": ("checkout.png", PNG_BYTES, "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert calls == [
|
||||
("stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_replays_confirmed_upload_for_same_key(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -234,20 +234,27 @@ const calls=[];let uploadAttempts=0;
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'image-op',
|
||||
fetchJson:async(url,options={{}})=>{{
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
const form=options.body instanceof FormData ? options.body : null;
|
||||
const file=form?.get('file');
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],multipart:Boolean(form),
|
||||
filename:file?.name,text:file?await file.text():null}});
|
||||
if(url.endsWith('/issues'))return{{repository:'o/r',number:7,title:'Visual bug'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
return{{id:8}};
|
||||
}},
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'abc'}}}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'b2ZmbGluZQ=='}}}});
|
||||
(async()=>{{await outbox.flush('timmy');const partial=outbox.list()[0];const result=await outbox.retry(queued.id,'timmy');process.stdout.write(JSON.stringify({{calls,partial,result,remaining:outbox.list()}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["partial"]["deliveredIssue"]["number"] == 7
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
uploads = [call for call in output["calls"] if call["url"].endswith("/attachments")]
|
||||
assert uploads[-1]["multipart"] is True
|
||||
assert uploads[-1]["filename"] == "screen.png"
|
||||
assert uploads[-1]["text"] == "offline"
|
||||
assert output["calls"][-1]["url"] == "api/v1/repos/o/r/issues/7/comments"
|
||||
assert output["result"]["confirmed"][0]["number"] == 7
|
||||
assert output["remaining"] == []
|
||||
@@ -452,14 +459,14 @@ Promise.resolve().then(async () => {{
|
||||
assert output["items"][0]["title"] == "Keep this"
|
||||
|
||||
|
||||
def test_durable_screenshot_is_mirrored_before_a_payload_free_local_index_is_committed():
|
||||
def test_durable_screenshot_is_mirrored_as_blob_before_a_payload_free_local_index_is_committed():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const events = []; const snapshots = [];
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => {{
|
||||
if (value.includes('base64-screenshot-bytes')) throw new Error('screenshot leaked into localStorage');
|
||||
if (value.includes('binary-screenshot-bytes')) throw new Error('screenshot leaked into localStorage');
|
||||
events.push('local-index'); values.set(key,value);
|
||||
}},
|
||||
}};
|
||||
@@ -471,26 +478,35 @@ const outbox = createIssueOutbox({{
|
||||
}},
|
||||
}});
|
||||
(async () => {{
|
||||
const blob=new Blob(['binary-screenshot-bytes'],{{type:'image/webp'}});
|
||||
const result = await outbox.enqueueDurably({{
|
||||
repository:'stackchain/dashboard', title:'Mobile layout',
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',data:'base64-screenshot-bytes'}},
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',blob}},
|
||||
}});
|
||||
const durable=snapshots[0][0].attachment;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
events, mirrored:snapshots[0][0], local:outbox.list()[0], result:result.item,
|
||||
raw:values.get('stackchain.issue-outbox.v1'),
|
||||
events, mirrored:{{filename:durable.filename,contentType:durable.contentType,
|
||||
isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}},
|
||||
local:outbox.list()[0], result:result.item, raw:values.get('stackchain.issue-outbox.v1'),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == ["indexeddb", "local-index", "sync"]
|
||||
assert output["mirrored"]["attachment"]["data"] == "base64-screenshot-bytes"
|
||||
assert output["mirrored"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"isBlob": True,
|
||||
"size": 23,
|
||||
"text": "binary-screenshot-bytes",
|
||||
}
|
||||
assert output["local"]["attachment"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"stored": True,
|
||||
}
|
||||
assert "base64-screenshot-bytes" not in output["raw"]
|
||||
assert "binary-screenshot-bytes" not in output["raw"]
|
||||
assert output["result"]["attachment"] == output["local"]["attachment"]
|
||||
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
|
||||
main.request_body_limit(
|
||||
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
|
||||
)
|
||||
== 3 * 1024 * 1024
|
||||
== 2 * 1024 * 1024 + 64 * 1024
|
||||
)
|
||||
assert main.request_body_limit("POST", "/unrelated/attachments") is None
|
||||
assert main.request_body_limit("POST", "/unrelated") is None
|
||||
|
||||
Reference in new issue
Block a user