stackchain-dashboard/tests/test_issue_attachment_ui.py
timmy 16604d697b
All checks were successful
CI / lint (pull_request) Successful in 1m6s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
feat: use binary screenshot transport (Closes #491)
2026-08-10 15:03:45 +00:00

292 lines
12 KiB
Python

import json
import re
import subprocess
from pathlib import Path
ATTACHMENT = Path(__file__).parents[1] / "frontend" / "issue-attachment.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
README = Path(__file__).parents[1] / "README.md"
def run_node(script: str) -> str:
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_mobile_attachment_prepares_comment_and_reuses_confirmed_binary_upload():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const calls = [];
const file = new Blob(['png-bytes'],{{type:'image/png'}}); file.name='checkout.png';
const controller = attachment.create({{
createOperationId: () => 'attachment-comment-471',
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:'![checkout.png](https://forge.example/a.png)'}}; }},
}});
controller.select(file);
(async()=>{{
const first = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
const second = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
process.stdout.write(JSON.stringify({{first,second,calls,state:controller.state()}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
output = json.loads(run_node(script))
assert output["first"] == (
"Layout breaks\n\n![checkout.png](https://forge.example/a.png)"
)
assert output["second"] == output["first"]
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": 9, "uploaded": True
}
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const keys=[];
let sequence=0;
let fail=true;
const controller=attachment.create({{
createOperationId:()=> 'attachment-' + (++sequence),
readDataUrl:async file=>'data:'+file.type+';base64,iVBORw0KGgo=',
upload:async payload=>{{keys.push(payload.operation_id);if(fail){{fail=false;throw new Error('offline');}}return {{markdown:'![ok](https://forge.example/a)'}};}},
}});
controller.select({{name:'first.png',type:'image/png',size:8}});
(async()=>{{
try{{await controller.prepareComment({{repository:'stackchain/api',number:17}},'Evidence');}}catch(_error){{}}
await controller.prepareComment({{repository:'stackchain/api',number:17}},'Evidence');
controller.select({{name:'replacement.png',type:'image/png',size:8}});
await controller.prepareComment({{repository:'stackchain/api',number:17}},'Replacement');
process.stdout.write(JSON.stringify(keys));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == [
"attachment-1", "attachment-1", "attachment-2"
]
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()=>{{throw new Error('base64 conversion must not run');}},
upload:async()=>{{calls.push('upload');}},
}});
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 == {
"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",
}
def test_serialized_capture_attachment_can_be_restored_or_removed_while_editing():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
const controller=attachment.create({{readDataUrl:async()=>{{throw new Error('must not reread');}},upload:async()=>{{}}}});
controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0KGgo='}});
(async()=>{{const restored={{state:controller.state(),value:await controller.serialize()}};controller.clear();process.stdout.write(JSON.stringify({{restored,removed:await controller.serialize()}}));}})();
"""
output = json.loads(run_node(script))
assert output["restored"]["state"]["name"] == "saved.png"
assert output["restored"]["value"]["data"] == "iVBORw0KGgo="
assert output["removed"] is None
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
const controller=attachment.create({{readDataUrl:async()=>'',upload:async()=>{{}}}});
try {{
controller.restore({{filename:'saved.png',contentType:'image/png',stored:true}});
process.stdout.write(JSON.stringify({{restored:true,state:controller.state()}}));
}} catch (error) {{
process.stdout.write(JSON.stringify({{restored:false,message:error.message,state:controller.state()}}));
}}
"""
output = json.loads(run_node(script))
assert output["restored"] is False
assert "saved screenshot" in output["message"].lower()
assert output["state"] is None
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
html = INDEX.read_text()
css = CSS.read_text()
assert 'id="issue-attachment"' in html
assert 'type="file"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'for="issue-attachment"' in html
assert 'id="issue-attachment-preview"' in html
assert 'id="remove-issue-attachment"' in html
assert '.issue-attachment-trigger' in css
assert '.issue-attachment-preview' in css
assert 'min-height:44px' in css
def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="create-issue-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachment: await createIssueAttachmentController.serialize()" in source
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert "overflow-x:hidden" in css
def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable():
source = DASHBOARD.read_text()
handler = re.search(
r"list\.querySelectorAll\('\.draft-edit'\).*?addEventListener\('click', async \(\) => \{(?P<body>.*?)\n \}\);",
source,
re.DOTALL,
)
assert handler is not None
body = handler.group("body")
assert "await issueOutbox.hydrateForEdit(queued.id)" in body
assert body.index("await issueOutbox.hydrateForEdit(queued.id)") < body.index(
"openCreateIssueSheet()"
)
assert "catch (error)" in body
assert "saved screenshot" in body.lower()
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
class Element {{
constructor() {{ this.listeners={{}}; this.hidden=true; this.value=''; this.files=[]; this.textContent=''; this.src=''; }}
addEventListener(type, fn) {{ this.listeners[type]=fn; }}
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const input=new Element(), preview=new Element(), image=new Element(), meta=new Element(), remove=new Element(), status=new Element();
const revoked=[];
const controller=attachment.mount({{
input,preview,image,meta,remove,status,
createObjectURL:()=> 'blob:preview', revokeObjectURL:url=>revoked.push(url),
readDataUrl:async()=>'', upload:async()=>{{}},
}});
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; input.dispatch('change');
const invalid={{message:status.textContent,hidden:preview.hidden}};
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; input.dispatch('change');
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
remove.dispatch('click');
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
"""
output = json.loads(run_node(script))
assert output["invalid"] == {
"message": "Choose a PNG, JPEG, or WebP screenshot.", "hidden": True
}
assert output["selected"]["src"] == "blob:preview"
assert output["selected"]["hidden"] is False
assert "screen.png" in output["selected"]["meta"]
assert output["removed"] == {
"hidden": True, "src": "", "revoked": ["blob:preview"], "state": None
}
def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_after_acceptance():
source = DASHBOARD.read_text()
assert "issueAttachment.mount({" in source
assert "'/attachments'" in source
assert "issueAttachmentController.prepareComment(item, body)" in source
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():
source = DASHBOARD.read_text()
close_body = re.search(
r"function closeIssueSheet\(navigate = true\) \{(?P<body>.*?)\n \}",
source,
re.DOTALL,
).group("body")
assert "issueAttachmentController.clear();" in close_body
def test_attachment_runtime_is_available_in_the_offline_app_shell():
assert "static/issue-attachment.js" in SERVICE_WORKER.read_text()
def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
readme = README.read_text()
assert "PNG, JPEG, or WebP" in readme
assert "2 MB" in readme
assert "uploads before the comment is posted" in readme
assert "New issue" in readme
assert "retry resumes with the confirmed issue" in readme
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
source = DASHBOARD.read_text()
assert "async function queueIssueScreenshotComment" in source
assert "attachment: await issueAttachmentController.serialize()" in source
assert "await authoredOutbox.enqueueDurably(message)" in source
assert "await issueCommentNext.admit(item, message)" in source
assert "navigator.onLine === false" in source