import json import subprocess from pathlib import Path import pytest from tests.dashboard_bundle import dashboard COMMENT_NEXT = Path(__file__).parents[1] / "frontend" / "comment-next.js" MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" def run_node(script): return subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True ).stdout def test_comment_and_next_posts_then_completes_current_today_item_once(): script = f""" const createCommentNext = require({json.dumps(str(COMMENT_NEXT))}); const calls = []; let finishPost; const controller = createCommentNext({{ post: (item, body) => {{ calls.push(`post:${{item.number}}:${{body}}`); return new Promise(resolve => {{ finishPost = resolve; }}); }}, queue: () => {{ throw new Error('must not queue'); }}, canQueue: () => false, complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }}, }}); const item = {{kind:'issue', repository:'stackchain/dashboard', number:429}}; const first = controller.submit(item, 'Handoff ready'); const second = controller.submit(item, 'Handoff ready'); if (first !== second) throw new Error('submission was not single-flight'); finishPost({{id:7, body:'Handoff ready'}}); (async () => {{ const result = await first; process.stdout.write(JSON.stringify({{result, calls, busy:controller.busy()}})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ assert json.loads(run_node(script)) == { "result": { "accepted": True, "delivery": "posted", "comment": {"id": 7, "body": "Handoff ready"}, "completed": True, }, "calls": ["post:429:Handoff ready", "complete:429"], "busy": False, } def test_comment_and_next_advances_after_durable_offline_admission(): script = f""" const createCommentNext = require({json.dumps(str(COMMENT_NEXT))}); const calls = []; const retryable = new Error('offline'); retryable.status = 503; const controller = createCommentNext({{ post: () => Promise.reject(retryable), canQueue: error => error.status === 503, queue: message => {{ calls.push(message); return Promise.resolve({{durable:true, background:false}}); }}, complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }}, }}); (async () => {{ const result = await controller.submit({{kind:'pull', repository:'stackchain/dashboard', number:12}}, 'Please review', 'op-12'); process.stdout.write(JSON.stringify({{result, calls}})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ assert json.loads(run_node(script)) == { "result": { "accepted": True, "delivery": "saved", "background": False, "completed": True, }, "calls": [ { "kind": "pull-comment", "repository": "stackchain/dashboard", "number": 12, "body": "Please review", "operationId": "op-12", }, "complete:12", ], } def test_update_reply_and_next_preserves_notification_identity_online_and_offline(): script = f""" const createCommentNext = require({json.dumps(str(COMMENT_NEXT))}); const calls = []; const retryable = Object.assign(new Error('offline'), {{status:503}}); let offline = false; const controller = createCommentNext({{ queueKind: 'update-reply', post: (item, body, operationId) => {{ calls.push({{post:[item.notification_id, body, operationId]}}); return offline ? Promise.reject(retryable) : Promise.resolve({{id:17}}); }}, canQueue: () => true, queue: message => {{ calls.push({{queue:message}}); return Promise.resolve({{durable:true}}); }}, complete: item => {{ calls.push({{complete:item.notification_id}}); return true; }}, }}); (async () => {{ const item = {{kind:'issue', notification_id:91, repository:'stackchain/dashboard', number:8}}; const posted = await controller.submit(item, 'Online reply', 'reply-91-a'); offline = true; const saved = await controller.submit(item, 'Offline reply', 'reply-91-b'); process.stdout.write(JSON.stringify({{posted, saved, calls}})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ assert json.loads(run_node(script)) == { "posted": { "accepted": True, "delivery": "posted", "comment": {"id": 17}, "completed": True, }, "saved": { "accepted": True, "delivery": "saved", "background": False, "completed": True, }, "calls": [ {"post": [91, "Online reply", "reply-91-a"]}, {"complete": 91}, {"post": [91, "Offline reply", "reply-91-b"]}, { "queue": { "kind": "update-reply", "notificationId": 91, "body": "Offline reply", "operationId": "reply-91-b", } }, {"complete": 91}, ], } def test_comment_and_next_reads_retry_identity_after_the_failed_post(): script = f""" const createCommentNext = require({json.dumps(str(COMMENT_NEXT))}); let operationId = ''; let queued; const controller = createCommentNext({{ post: () => {{ operationId = 'post-attempt-id'; return Promise.reject(Object.assign(new Error('timeout'), {{status:503}})); }}, canQueue: () => true, queue: message => {{ queued = message; return Promise.resolve({{durable:true}}); }}, complete: () => true, }}); (async () => {{ const result = await controller.submit( {{kind:'issue', repository:'r', number:3}}, 'Status', () => operationId ); process.stdout.write(JSON.stringify({{result, queued}})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ output = json.loads(run_node(script)) assert output["result"]["accepted"] is True assert output["result"]["delivery"] == "saved" assert output["queued"]["operationId"] == "post-attempt-id" def test_comment_and_next_clears_the_completed_draft_before_opening_next_item(): script = f""" const createCommentNext = require({json.dumps(str(COMMENT_NEXT))}); const calls = []; const controller = createCommentNext({{ post: () => Promise.resolve({{id:1}}), queue: () => null, canQueue: () => false, accept: item => calls.push(`clear:${{item.number}}`), complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }}, }}); (async () => {{ await controller.submit({{kind:'issue',repository:'r',number:8}}, 'Done'); process.stdout.write(JSON.stringify(calls)); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ assert json.loads(run_node(script)) == ["clear:8", "complete:8"] def test_comment_and_next_keeps_today_position_when_delivery_or_completion_fails(): script = f""" const createCommentNext = require({json.dumps(str(COMMENT_NEXT))}); const failures = []; const permanent = createCommentNext({{ post: () => Promise.reject(Object.assign(new Error('forbidden'), {{status:403}})), canQueue: () => false, queue: () => Promise.resolve({{durable:true}}), complete: () => {{ failures.push('advanced'); return true; }}, }}); const notDurable = createCommentNext({{ post: () => Promise.reject(Object.assign(new Error('offline'), {{status:503}})), canQueue: () => true, queue: () => Promise.resolve({{durable:false, background:false}}), complete: () => {{ failures.push('advanced'); return true; }}, }}); const cannotComplete = createCommentNext({{ post: () => Promise.resolve({{id:9}}), canQueue: () => false, queue: () => null, complete: () => false, }}); (async () => {{ for (const [name, controller] of [['permanent', permanent], ['notDurable', notDurable]]) {{ try {{ await controller.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); }} catch (error) {{ failures.push(`${{name}}:${{error.message}}`); }} }} const partial = await cannotComplete.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); process.stdout.write(JSON.stringify({{failures, partial}})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ assert json.loads(run_node(script)) == { "failures": ["permanent:forbidden", "notDurable:Comment was not saved for delivery."], "partial": { "accepted": True, "delivery": "posted", "comment": {"id": 9}, "completed": False, }, } def test_today_checkpoint_only_matches_the_current_session_item(): script = f""" const buildMyWork = require({json.dumps(str(MY_WORK))}); const items = [ {{kind:'issue', repository:'r', number:1}}, {{kind:'pull', repository:'r', number:2}}, ]; const session = buildMyWork.createWorkSession({{ getItems: () => items, getFilter: () => 'all', checkpointEnabled: () => true, checkpoint: {{save:()=>undefined, clear:()=>undefined, read:()=>null}}, onOpen:()=>undefined, onProgress:()=>undefined, onFinish:()=>undefined, }}); session.start(); process.stdout.write(JSON.stringify({{ current: session.checkpointed(items[0]), other: session.checkpointed(items[1]), generic: session.checkpointed(), }})); """ assert json.loads(run_node(script)) == { "current": True, "other": False, "generic": True, } def test_comment_and_next_can_admit_a_complete_screenshot_message_before_advancing(): script = f""" const createCommentNext=require({json.dumps(str(COMMENT_NEXT))}); const calls=[]; const controller=createCommentNext({{ post:()=>{{throw new Error('must not post');}},canQueue:()=>true, queue:async message=>{{calls.push({{queue:message}});return{{item:{{id:'queued'}},background:true}};}}, accept:item=>calls.push({{accept:item.number}}), complete:item=>{{calls.push({{complete:item.number}});return true;}}, }}); (async()=>{{const item={{kind:'issue',repository:'stackchain/dashboard',number:477}}; const result=await controller.admit(item,{{kind:'issue-comment',repository:item.repository,number:item.number, body:'Mobile evidence',operationId:'image-op',attachment:{{filename:'phone.png',contentType:'image/png',data:'abc'}}}}); process.stdout.write(JSON.stringify({{result,calls}})); }})(); """ output = json.loads(run_node(script)) assert output["result"] == { "accepted": True, "delivery": "queued", "background": True, "completed": True } assert output["calls"][0]["queue"]["attachment"]["data"] == "abc" assert output["calls"][1:] == [{"accept": 477}, {"complete": 477}] @pytest.mark.anyio async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint(): html = await dashboard() assert 'id="send-issue-comment-next"' in html assert 'id="send-pull-comment-next"' in html assert html.count('>Comment & next') == 2 assert "setCommentNextVisibility('issue')" in html assert "setCommentNextVisibility('pull')" in html assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.comment-actions button { min-height:44px;' in html @pytest.mark.anyio async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today(): html = await dashboard() assert 'id="send-update-reply-read-next"' in html assert '>Reply, mark read & next' in html assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html assert "markRead: markNotificationRead" in html assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" in html assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() assert "stackchain-dashboard-shell-v92" in worker