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_upload(): script = f""" const attachment = require({json.dumps(str(ATTACHMENT))}); const calls = []; const file = {{name:'checkout.png', type:'image/png', size:8}}; const controller = attachment.create({{ readDataUrl: async selected => {{ calls.push('read:' + selected.name); return 'data:image/png;base64,iVBORw0KGgo='; }}, upload: async payload => {{ calls.push(payload); 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"] == [ "read:checkout.png", { "repository": "stackchain/api", "number": 17, "filename": "checkout.png", "content_type": "image/png", "data": "iVBORw0KGgo=", }, ] assert output["state"] == { "name": "checkout.png", "size": 8, "uploaded": True } 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_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_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 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.*?)\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