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_selected_screenshot_serializes_for_durable_issue_capture_without_uploading(): script = f""" const attachment = require({json.dumps(str(ATTACHMENT))}); const calls=[]; const controller=attachment.create({{ readDataUrl:async file=>{{calls.push('read:'+file.name);return 'data:image/webp;base64,UklGRg==';}}, 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}}))); """ output = json.loads(run_node(script)) assert output == { "value": { "filename": "phone.webp", "contentType": "image/webp", "data": "UklGRg==", }, "calls": ["read:phone.webp"], } 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_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(queued.attachment);" in source assert "createIssueAttachmentController.clear();" in source assert ".create-issue-attachment" in css assert "overflow-x:hidden" 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 assert "New issue" in readme assert "retry resumes with the confirmed issue" in readme