182 lines
8.7 KiB
Python
182 lines
8.7 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
|
|
|
|
|
|
def run_node(script: str):
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_draft_inbox_discovers_existing_formats_orders_activity_and_ignores_corruption():
|
|
script = f"""
|
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
|
const values = new Map([
|
|
['stackchain.issue-capture.v1', JSON.stringify({{repository:'stackchain/api',title:'Ship recovery',body:'Detailed plan',labelIds:[]}})],
|
|
['stackchain.issue-comment.v1:stackchain/api#17', 'Please add a regression test'],
|
|
['stackchain.pull-comment.v1:stackchain/web#9', 'Looks good on mobile'],
|
|
['stackchain.update-reply.v1.44', 'I can take this'],
|
|
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{'app.js':'Handle offline state'}},comments:[],summary:'Needs one fix',decision:'request_changes'}})],
|
|
['stackchain.issue-content.v1:stackchain/api#18', '{{broken-json'],
|
|
['stackchain.issue-comment.v1:stackchain/api#20:operation', 'not-a-draft'],
|
|
]);
|
|
const storage = {{
|
|
get length() {{ return values.size; }},
|
|
key: index => Array.from(values.keys())[index] || null,
|
|
getItem: key => values.has(key) ? values.get(key) : null,
|
|
setItem: (key, value) => values.set(key, value),
|
|
removeItem: key => values.delete(key),
|
|
}};
|
|
let clock = 1000;
|
|
const inbox = createDraftInbox({{storage, now:() => ++clock}});
|
|
const first = inbox.list();
|
|
values.set('stackchain.issue-comment.v1:stackchain/api#17', 'Newest comment text');
|
|
const second = inbox.list();
|
|
process.stdout.write(JSON.stringify({{
|
|
kinds:first.map(item => item.kind).sort(),
|
|
newest:second[0],
|
|
count:second.length,
|
|
}}));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["kinds"] == ["issue-comment", "new-issue", "pull-comment", "review", "update-reply"]
|
|
assert output["count"] == 5
|
|
assert output["newest"]["kind"] == "issue-comment"
|
|
assert output["newest"]["repository"] == "stackchain/api"
|
|
assert output["newest"]["number"] == 17
|
|
assert output["newest"]["preview"] == "Newest comment text"
|
|
assert output["newest"]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 17}
|
|
|
|
|
|
def test_draft_discard_removes_only_selected_content_and_operation_identity():
|
|
script = f"""
|
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
|
const values = new Map([
|
|
['stackchain.issue-comment.v1:stackchain/api#17', 'One'],
|
|
['stackchain.issue-comment.v1:stackchain/api#17:operation', 'operation-one'],
|
|
['stackchain.pull-comment.v1:stackchain/web#9', 'Two'],
|
|
]);
|
|
const storage = {{
|
|
get length() {{ return values.size; }}, key:i => Array.from(values.keys())[i] || null,
|
|
getItem:key => values.has(key) ? values.get(key) : null,
|
|
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key),
|
|
}};
|
|
const inbox = createDraftInbox({{storage, now:() => 1000}});
|
|
const selected = inbox.list().find(item => item.kind === 'issue-comment');
|
|
inbox.discard(selected.id);
|
|
process.stdout.write(JSON.stringify({{remaining:inbox.list(), keys:Array.from(values.keys()).sort()}}));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert [item["kind"] for item in output["remaining"]] == ["pull-comment"]
|
|
assert "stackchain.issue-comment.v1:stackchain/api#17" not in output["keys"]
|
|
assert "stackchain.issue-comment.v1:stackchain/api#17:operation" not in output["keys"]
|
|
assert "stackchain.pull-comment.v1:stackchain/web#9" in output["keys"]
|
|
|
|
|
|
def test_draft_inbox_recovers_issue_edit_and_review_content_without_exposing_empty_records():
|
|
script = f"""
|
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
|
const values = new Map([
|
|
['stackchain.issue-content.v1:stackchain/api#18', JSON.stringify({{title:'Clarify acceptance',body:'Describe mobile flow',expectedUpdatedAt:'2026-08-07T10:00:00Z'}})],
|
|
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{}},comments:[],summary:'',decision:'approve'}})],
|
|
['stackchain.pull-comment.v1:stackchain/web#10', ' '],
|
|
]);
|
|
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const drafts = createDraftInbox({{storage,now:()=>42}}).list();
|
|
process.stdout.write(JSON.stringify(drafts));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert len(output) == 1
|
|
assert output[0]["kind"] == "issue-edit"
|
|
assert output[0]["preview"] == "Clarify acceptance — Describe mobile flow"
|
|
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
|
|
|
|
|
|
def test_draft_inbox_expands_issue_outbox_items_with_actionable_states():
|
|
script = f"""
|
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
|
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:1,items:[
|
|
{{id:'queued-1',repository:'stackchain/api',title:'Offline capture',body:'Context',status:'queued',queuedAt:100}},
|
|
{{id:'attention-2',repository:'stackchain/web',title:'Fix labels',body:'Details',status:'attention',error:'Unknown label',queuedAt:200}},
|
|
]}})]]);
|
|
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const drafts = createDraftInbox({{storage,now:()=>300}}).list();
|
|
process.stdout.write(JSON.stringify(drafts));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert [item["outbox_id"] for item in output] == ["attention-2", "queued-1"]
|
|
assert [item["status"] for item in output] == ["attention", "queued"]
|
|
assert output[0]["label"] == "Needs attention"
|
|
assert output[1]["label"] == "Queued issue"
|
|
|
|
|
|
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
|
|
script = f"""
|
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
|
const values = new Map([
|
|
['stackchain.issue-outbox.v1', JSON.stringify({{version:2,items:[
|
|
{{id:'other',repository:'o/r',title:'Other issue',body:'Private context',ownerLogin:'alexander',status:'queued'}},
|
|
{{id:'legacy',repository:'o/r',title:'Legacy issue',body:'Old context',status:'queued'}}
|
|
]}})],
|
|
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
|
{{id:'mine',kind:'issue-comment',repository:'o/r',number:2,body:'My comment',ownerLogin:'timmy',status:'queued'}}
|
|
]}})],
|
|
]);
|
|
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
|
|
process.stdout.write(JSON.stringify(drafts));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
by_id = {item["outbox_id"]: item for item in output}
|
|
assert by_id["other"]["quarantined"] is True
|
|
assert by_id["other"]["ownership"] == "Queued by alexander — current account is timmy"
|
|
assert by_id["other"]["copy_text"] == "Other issue\n\nPrivate context"
|
|
assert by_id["legacy"]["ownership"] == "Queued by an unknown account — current account is timmy"
|
|
assert by_id["mine"]["quarantined"] is False
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/drafts.js"></script>' in html
|
|
assert 'data-work-filter="draft"' in html
|
|
assert 'data-work-count="draft"' in html
|
|
assert 'class="draft-resume"' in html
|
|
assert 'class="draft-discard"' in html
|
|
assert 'Discard this unfinished draft?' in html
|
|
assert '.draft-actions button { min-height:44px;' in html
|
|
assert 'createDraftInbox({ storage: localStorage' in html
|
|
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
|
html = await dashboard()
|
|
|
|
assert "let confirmedOwnerLogin = '';" in html
|
|
assert "let activeFlushLogin = '';" in html
|
|
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
|
|
assert "getCurrentLogin: () => activeFlushLogin" in html
|
|
assert "const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&" in html
|
|
assert "!contextFreshness?.degraded && !contextFreshness?.revalidating;" in html
|
|
assert "activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';" in html
|
|
assert "issueOutbox.flush(activeFlushLogin)" in html
|
|
assert "authoredOutbox.flush(activeFlushLogin)" in html
|
|
assert "activeFlushLogin = '';" in html
|
|
assert 'class="draft-copy"' in html
|
|
assert "navigator.clipboard.writeText(item.copy_text)" in html
|
|
assert "item.quarantined" in html
|