feat: queue offline issue closure and continue Today (#427)
This commit is contained in:
parent
ad27db6846
commit
c451cd0bcb
|
|
@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
|
|
@ -133,6 +133,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
|
||||
}
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
|
|
@ -154,6 +157,13 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
throw error;
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
if (item.kind === 'issue-close') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
});
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
} else {
|
||||
const body = item.kind === 'pull-review' ? {
|
||||
body: item.body,
|
||||
|
|
@ -171,6 +181,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!result) return { blocked: true };
|
||||
clearConfirmedReviewState(item);
|
||||
discard(item.id);
|
||||
|
|
|
|||
|
|
@ -249,6 +249,12 @@ function createBackgroundIssueSync({
|
|||
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
id: item.id, status, kind: 'message',
|
||||
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
|
|
@ -267,6 +273,15 @@ function createBackgroundIssueSync({
|
|||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
|
|
@ -332,6 +347,11 @@ function createBackgroundIssueSync({
|
|||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.complete(item.id, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
let selectedUpdate = null;
|
||||
let updateTrigger = null;
|
||||
let selectedIssue = null;
|
||||
let selectedIssueOffline = false;
|
||||
let selectedIssueDetail = null;
|
||||
let issueConversation = null;
|
||||
let issueTrigger = null;
|
||||
|
|
@ -781,6 +782,10 @@
|
|||
warm: warmTodayOffline,
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const closeOfflineIssue = createOfflineIssueClose({
|
||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||
completeToday: (item, options) => completeTodayItem(item, options),
|
||||
});
|
||||
|
||||
function reviewingActiveTodayItem() {
|
||||
return workSession.checkpointed();
|
||||
|
|
@ -1060,7 +1065,7 @@
|
|||
|
||||
function setOfflineDetailControls(kind) {
|
||||
const selectors = kind === 'issue' ? [
|
||||
'#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff',
|
||||
'#edit-issue-content', '#release-issue', '#load-issue-handoff',
|
||||
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
|
||||
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
|
||||
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
|
||||
|
|
@ -1250,6 +1255,7 @@
|
|||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const isUnfiled = item.kind === 'unfiled-issue';
|
||||
const reviewOutbox = item.outbox_kind === 'pull-review';
|
||||
const closureOutbox = item.outbox_kind === 'issue-close';
|
||||
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
|
||||
const outboxActions = item.quarantined ?
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||
|
|
@ -1266,7 +1272,8 @@
|
|||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||||
item.kind === 'authored-outbox' && !reviewOutbox ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
||||
(closureOutbox ? 'Open issue' : 'Open message') + '</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
reviewOutbox ?
|
||||
|
|
@ -1686,6 +1693,7 @@
|
|||
qs('#issue-planning').inert = false;
|
||||
qs('#issue-handoff').inert = false;
|
||||
selectedIssue = item;
|
||||
selectedIssueOffline = Boolean(offlineDetail);
|
||||
selectedIssueDetail = null;
|
||||
issueConversation = null;
|
||||
issueTrigger = trigger;
|
||||
|
|
@ -1728,7 +1736,9 @@
|
|||
qs('#issue-edit-form').hidden = true;
|
||||
qs('#issue-edit-status').textContent = '';
|
||||
qs('#close-issue').disabled = false;
|
||||
qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';
|
||||
qs('#close-issue').textContent = offlineDetail ?
|
||||
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
|
||||
(workSession.active() ? 'Close & next' : 'Close issue');
|
||||
qs('#close-issue-sheet').focus();
|
||||
try {
|
||||
const detail = offlineDetail || await issueController.load(item);
|
||||
|
|
@ -1777,6 +1787,7 @@
|
|||
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
|
||||
qs('#issue-sheet').classList.remove('open');
|
||||
selectedIssue = null;
|
||||
selectedIssueOffline = false;
|
||||
selectedIssueDetail = null;
|
||||
issueConversation = null;
|
||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||
|
|
@ -3458,6 +3469,26 @@
|
|||
const closing = selectedIssue;
|
||||
const button = qs('#close-issue');
|
||||
button.disabled = true;
|
||||
if (selectedIssueOffline) {
|
||||
qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…';
|
||||
try {
|
||||
const outcome = await closeOfflineIssue(closing);
|
||||
closeIssueSheet();
|
||||
refreshMyWorkView({ reconcileSession:false });
|
||||
if (!outcome.advanced) {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.';
|
||||
} else if (outcome.admission.background) {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.';
|
||||
} else {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.';
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
qs('#issue-sheet-status').textContent = 'Closing issue…';
|
||||
try {
|
||||
await issueController.close(selectedIssue);
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
|||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const isReview = item.kind === 'pull-review';
|
||||
const isClosure = item.kind === 'issue-close';
|
||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
|
||||
|
|
@ -158,8 +159,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
|||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' :
|
||||
(isReview ? 'Queued review' : 'Queued message')),
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
|
|
|
|||
|
|
@ -594,6 +594,7 @@
|
|||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-issue-close.js"></script>
|
||||
<script src="static/notification-read-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
|
|
|
|||
19
frontend/offline-issue-close.js
Normal file
19
frontend/offline-issue-close.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
|
||||
return async function closeOfflineIssue(item) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-close',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
body: '',
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
const advanced = Boolean(completeToday(item, {
|
||||
successMessage: 'Issue closure queued. Next Today item opened.',
|
||||
failureMessage: 'Issue closure queued, but Today still needs completion.',
|
||||
}));
|
||||
return { admission, advanced };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v70';
|
||||
const CACHE = 'stackchain-dashboard-shell-v71';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
@ -21,6 +21,7 @@ const SHELL = [
|
|||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
|
|
|
|||
|
|
@ -126,6 +126,36 @@ if (queued) {{
|
|||
assert output["progress"] is None
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
|
||||
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,options}}); return {{number:27,state:'closed'}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
|
||||
}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
queued,calls,result,remaining:outbox.list()
|
||||
}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["kind"] == "issue-close"
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
|
||||
"options": {
|
||||
"method": "PATCH",
|
||||
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
|
||||
},
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
|
|||
|
|
@ -236,6 +236,36 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
|
|||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
|
||||
authored = {
|
||||
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
|
||||
}
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let queued = {json.dumps(authored)}; const calls=[];
|
||||
const store = {{
|
||||
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
|
||||
assert mutation["options"]["method"] == "PATCH"
|
||||
assert mutation["options"]["headers"] == {
|
||||
"Accept": "application/json", "Idempotency-Key": "close-op"
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "close-op", "status": "confirmed", "kind": "message",
|
||||
"route": "#/my-work/issue/stackchain/dashboard/27",
|
||||
}]
|
||||
|
||||
|
||||
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
|
|
|||
|
|
@ -142,6 +142,27 @@ process.stdout.write(JSON.stringify(item));
|
|||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
|
||||
ownerLogin:'timmy',status:'attention',error:'Closure rejected',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)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["label"] == "Issue closure needs attention"
|
||||
assert output["outbox_kind"] == "issue-close"
|
||||
assert output["title"] == "stackchain/dashboard#27"
|
||||
assert output["preview"] == "Closure rejected"
|
||||
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
|
|
@ -218,6 +239,8 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
|||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
|
||||
assert "closureOutbox ? 'Open issue' : 'Open message'" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
|
|
|||
|
|
@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v70" in worker
|
||||
assert "stackchain-dashboard-shell-v71" in worker
|
||||
|
|
|
|||
|
|
@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
|||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v70" in worker
|
||||
assert "stackchain-dashboard-shell-v71" in worker
|
||||
|
|
|
|||
|
|
@ -1672,7 +1672,8 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
|
|||
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert "qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';" in html
|
||||
assert "workSession.active() ? 'Close & next' : 'Close issue'" in html
|
||||
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
|
||||
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-pull-sheet').addEventListener", 1
|
||||
)[0]
|
||||
|
|
|
|||
35
tests/test_offline_issue_close.py
Normal file
35
tests/test_offline_issue_close.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OFFLINE_CLOSE = Path(__file__).parents[1] / "frontend" / "offline-issue-close.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_offline_issue_close_waits_for_durable_admission_before_advancing_today():
|
||||
script = f"""
|
||||
const createOfflineIssueClose = require({json.dumps(str(OFFLINE_CLOSE))});
|
||||
const events=[]; let release;
|
||||
const gate=new Promise(resolve=>release=resolve);
|
||||
const close=createOfflineIssueClose({{
|
||||
enqueueDurably:async message=>{{events.push('admit');await gate;events.push('durable');return {{item:message,background:true}};}},
|
||||
completeToday:item=>{{events.push('complete:' + item.number);return true;}},
|
||||
}});
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:27}};
|
||||
const pending=close(item).then(result=>{{events.push('resolved');return result;}});
|
||||
Promise.resolve().then(async()=>{{
|
||||
const before=events.slice();release();const result=await pending;
|
||||
process.stdout.write(JSON.stringify({{before,events,result}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["before"] == ["admit"]
|
||||
assert output["events"] == ["admit", "durable", "complete:27", "resolved"]
|
||||
assert output["result"]["advanced"] is True
|
||||
assert output["result"]["admission"]["background"] is True
|
||||
|
|
@ -301,3 +301,22 @@ async def test_offline_today_review_queues_durably_before_completing_and_advanci
|
|||
assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler
|
||||
assert "if (!advanced)" in handler
|
||||
assert "Review queued, but Today still needs completion." in handler
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offline_today_issue_queues_closure_before_completing_and_advancing():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/offline-issue-close.js"></script>' in html
|
||||
assert "const closeOfflineIssue = createOfflineIssueClose({" in html
|
||||
assert "enqueueDurably: message => authoredOutbox.enqueueDurably(message)" in html
|
||||
assert "completeToday: (item, options) => completeTodayItem(item, options)" in html
|
||||
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
|
||||
handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-pull-sheet').addEventListener", 1
|
||||
)[0]
|
||||
admission = handler.index("await closeOfflineIssue(closing)")
|
||||
sheet_close = handler.index("closeIssueSheet()")
|
||||
assert admission < sheet_close
|
||||
assert "Issue closure queued for reconnect." in handler
|
||||
assert "The issue remains in Today; retry." in handler
|
||||
|
|
|
|||
|
|
@ -168,6 +168,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
|||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -147,14 +147,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -163,21 +163,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -358,7 +358,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
|||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
@ -389,6 +389,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-issue-close.js",
|
||||
"/dashboard/static/notification-read-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v70" in source
|
||||
assert "stackchain-dashboard-shell-v71" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user