feat: queue offline checklist progress (Closes #907)
This commit is contained in:
1 parent
88e5f3f33b
commit
411e40d117
11 files changed
+267
-17
No files matched your search
@@ -77,6 +77,42 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{
|
||||
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-content',repository:'stackchain/dashboard',number:17,operationId:'check-op',
|
||||
title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z',
|
||||
}});
|
||||
const restored = createAuthoredOutbox({{storage}}).list()[0];
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["expectedUpdatedAt"] == "2026-08-15T10:00:00Z"
|
||||
assert output["restored"]["title"] == "Ship"
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/content",
|
||||
"method": "PATCH",
|
||||
"key": "check-op",
|
||||
"body": {
|
||||
"title": "Ship", "body": "- [x] Test",
|
||||
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||
},
|
||||
}]
|
||||
assert output["result"]["confirmed"][0]["body"] == "- [x] Test"
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -96,6 +96,41 @@ const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'check-1',operationId:'check-1',kind:'issue-content',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z',
|
||||
}};
|
||||
const state={{calls:[],completed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>item?{{...item}}:null,
|
||||
complete:async id=>{{state.completed.push(id);item=null;}},
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
|
||||
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||
return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}};
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["calls"][1] == {
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/content",
|
||||
"method": "PATCH",
|
||||
"key": "check-1",
|
||||
"body": {
|
||||
"title": "Ship", "body": "- [x] Test",
|
||||
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||
},
|
||||
}
|
||||
assert output["state"]["completed"] == ["check-1"]
|
||||
assert output["result"]["confirmed"][0]["body"] == "- [x] Test"
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -664,6 +664,45 @@ async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_response():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17,
|
||||
"title": "My draft",
|
||||
"body": "Draft body",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-07T10:02:00Z",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"pull_request": None,
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_assigned_issue(
|
||||
"stackchain/api", 17, "My draft", "Draft body",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "title": "My draft",
|
||||
"body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
||||
requests = []
|
||||
|
||||
@@ -155,7 +155,7 @@ def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
||||
"renderMarkdown(detail.body || 'No description provided.')",
|
||||
"renderMarkdown(item.body || 'No description provided.')",
|
||||
"renderMarkdown(review.body)",
|
||||
"issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline",
|
||||
"issueController.renderTasks(qs('#issue-sheet-body'), detail,",
|
||||
)
|
||||
for path in expected_paths:
|
||||
assert path in dashboard
|
||||
|
||||
+69
-2
@@ -2206,12 +2206,16 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
|
||||
html = await dashboard()
|
||||
controller = ISSUE_SHEET.read_text()
|
||||
|
||||
assert "issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline" in html
|
||||
assert "issueController.renderTasks(qs('#issue-sheet-body'), detail," in html
|
||||
assert "!issueController.readOnly(selectedIssue) && detail.state === 'open'" in html
|
||||
assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in html
|
||||
assert "issueController.bindTaskToggles({" in html
|
||||
assert "container.addEventListener('change', async event =>" in controller
|
||||
assert "event.target.closest('input.task-list-toggle')" in controller
|
||||
assert "state.item, state.detail, Number(control.dataset.taskIndex), control.checked" in controller
|
||||
assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail})" in html
|
||||
assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline})" in html
|
||||
assert "state.offline ? this.queueTask" in controller
|
||||
assert "status.textContent = 'Checklist queued. Pending sync.'" in controller
|
||||
assert "buildMyWork.replaceIssueContent" in html
|
||||
assert "status.textContent = 'Checklist updated.'" in controller
|
||||
assert "restore(state.detail)" in controller
|
||||
@@ -2220,6 +2224,69 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
|
||||
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
|
||||
|
||||
|
||||
def test_offline_issue_checklist_toggle_waits_for_durable_admission_and_returns_pending_detail():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let admit; const queued = [];
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
toggleTask:(body, index, checked) => body.replace(index === 0 ? '[ ]' : '[X]', checked ? '[x]' : '[ ]'),
|
||||
enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit = resolve); }},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const detail = {{title:'Release', body:'- [ ] Build\\n- [X] Ship', updated_at:'2026-08-15T10:00:00Z'}};
|
||||
let settled = false;
|
||||
const pending = controller.queueTask(item, detail, 0, true).then(result => {{ settled = true; return result; }});
|
||||
const before = settled;
|
||||
admit({{item:{{id:'queued-check'}}}});
|
||||
pending.then(result => process.stdout.write(JSON.stringify({{before,queued,result}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["before"] is False
|
||||
assert output["queued"] == [{
|
||||
"kind": "issue-content", "repository": "stackchain/api", "number": 17,
|
||||
"title": "Release", "body": "- [x] Build\n- [X] Ship",
|
||||
"expectedUpdatedAt": "2026-08-15T10:00:00Z",
|
||||
}]
|
||||
assert output["result"] == {
|
||||
"queued": True,
|
||||
"detail": {
|
||||
"title": "Release", "body": "- [x] Build\n- [X] Ship",
|
||||
"updated_at": "2026-08-15T10:00:00Z", "checklist_pending": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_offline_issue_checklist_restores_account_bound_pending_body_after_reload():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const controller = createIssueSheet({{storage:null}});
|
||||
const detail = {{title:'Release',body:'- [ ] Build',updated_at:'old'}};
|
||||
const items = [
|
||||
{{kind:'issue-content',repository:'o/r',number:7,ownerLogin:'alexander',title:'Wrong',body:'- [x] Wrong',expectedUpdatedAt:'wrong',status:'queued'}},
|
||||
{{kind:'issue-content',repository:'o/r',number:7,ownerLogin:'timmy',title:'Release',body:'- [x] Build',expectedUpdatedAt:'old',status:'queued'}},
|
||||
];
|
||||
process.stdout.write(JSON.stringify({{
|
||||
restored:controller.pendingTask({{repository:'o/r',number:7}}, detail, items, 'timmy'),
|
||||
isolated:controller.pendingTask({{repository:'o/r',number:7}}, detail, items, 'hou3'),
|
||||
}}));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["restored"] == {
|
||||
"title": "Release", "body": "- [x] Build", "updated_at": "old",
|
||||
"checklist_pending": True,
|
||||
}
|
||||
assert output["isolated"] == {
|
||||
"title": "Release", "body": "- [ ] Build", "updated_at": "old",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_checklist_toggle_submits_exact_revision_checked_body_once():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
||||
Reference in new issue
Block a user