265 lines
11 KiB
Python
265 lines
11 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_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({{
|
|
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:''}}; }},
|
|
}});
|
|
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"
|
|
)
|
|
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["state"] == {
|
|
"name": "checkout.png", "size": 8, "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:''}};}},
|
|
}});
|
|
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_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_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_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
|
|
|
|
|
|
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
|