stackchain-dashboard/tests/test_comment_next.py
timmy 718f0d1209
Some checks failed
CI / lint (push) Successful in 4m11s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 8m3s
CI / release-candidate (push) Has been skipped
feat: surface Human Gates in mobile preparation (#1418)
Closes #1417
2026-08-26 03:42:45 +00:00

307 lines
12 KiB
Python

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 &amp; next</button>') == 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 &amp; next</button>' 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-v140" in worker