Merge pull request 'Add checklist steps inline from mobile issue detail' (#916) from timmy/915-mobile-add-checklist-step into main
This commit is contained in:
commit
d28140bbd1
|
|
@ -115,7 +115,12 @@ explicit **Add blocked item anyway** override, while an unavailable dependency l
|
|||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
|
||||
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
|
||||
**Add step** directly beside the plan. A new step is normalized, checked for duplicates, and appended without
|
||||
exposing or replacing the full issue description. Offline additions enter the same account-bound durable
|
||||
issue-content queue as checklist toggles; reconnect conflict review applies unique local additions to the
|
||||
latest remote body while preserving remote prose and task-state changes.
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
|
|
|
|||
|
|
@ -42,6 +42,22 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
|
|||
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
|
||||
}
|
||||
|
||||
for (const [key, localMatches] of local) {
|
||||
if (base.has(key)) continue;
|
||||
if (localMatches.length !== 1) {
|
||||
conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
const remoteMatches = remote.get(key) || [];
|
||||
if (remoteMatches.length > 1) {
|
||||
conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
if (remoteMatches.length === 0) {
|
||||
changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length) return { body: null, changes, conflicts };
|
||||
const desired = new Map(changes.map(change => [
|
||||
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
|
||||
|
|
@ -52,6 +68,9 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
|
|||
const marker = desired.get(entry.key) ? 'x' : ' ';
|
||||
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
|
||||
});
|
||||
changes.filter(change => change.added).forEach(change => {
|
||||
lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
|
||||
});
|
||||
return { body: lines.join('\n'), changes, conflicts: [] };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -474,6 +474,13 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.completed-filed-actions button { min-height:44px; min-width:0; }
|
||||
#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.checklist-add { display:grid; gap:8px; margin:10px 0 16px; }
|
||||
.checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; }
|
||||
.checklist-add form[hidden] { display:none; }
|
||||
.checklist-add input { min-width:0; width:100%; box-sizing:border-box; }
|
||||
.checklist-add button, .checklist-add input { min-height:44px; }
|
||||
#issue-sheet.read-only .checklist-add { display:none; }
|
||||
@media(max-width:340px) { .checklist-add form { grid-template-columns:1fr 1fr; } .checklist-add input { grid-column:1 / -1; } }
|
||||
.checklist-completion { position:fixed; right:0; bottom:0; z-index:58; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) repeat(2,minmax(0,auto)); align-items:center; gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid #4ade80; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.checklist-completion[hidden] { display:none; }
|
||||
.checklist-completion button { min-height:44px; min-width:0; }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
entries: [
|
||||
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
|
||||
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.checklist-add'), composer:qs('#add-checklist-step'), submit:qs('#save-checklist-step'), status:qs('#add-checklist-step-status') },
|
||||
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
|
||||
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
|
||||
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
|
||||
|
|
@ -3455,6 +3456,7 @@
|
|||
function renderIssueBody(detail) {
|
||||
const interactive = !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at;
|
||||
issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive);
|
||||
qs('#open-add-checklist-step').disabled = !interactive;
|
||||
renderChecklistCompletion(detail);
|
||||
}
|
||||
|
||||
|
|
@ -3503,6 +3505,10 @@
|
|||
qs('#completed-filed-progress').textContent = item.is_completed ?
|
||||
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
|
||||
qs('#issue-sheet-body').textContent = '';
|
||||
qs('#open-add-checklist-step').disabled = true;
|
||||
qs('#add-checklist-step-form').hidden = true;
|
||||
qs('#add-checklist-step').value = '';
|
||||
qs('#add-checklist-step-status').textContent = '';
|
||||
qs('#checklist-completion').hidden = true;
|
||||
qs('#issue-labels').textContent = '';
|
||||
qs('#issue-assignees').textContent = '';
|
||||
|
|
@ -5666,6 +5672,42 @@
|
|||
confirmed:applyIssueContent,
|
||||
restore:renderIssueBody,
|
||||
});
|
||||
function closeAddChecklistStep() {
|
||||
qs('#add-checklist-step-form').hidden = true;
|
||||
qs('#add-checklist-step').value = '';
|
||||
qs('#open-add-checklist-step').focus();
|
||||
}
|
||||
qs('#open-add-checklist-step').addEventListener('click', () => {
|
||||
qs('#add-checklist-step-form').hidden = false;
|
||||
qs('#add-checklist-step-status').textContent = 'Add one required step.';
|
||||
qs('#add-checklist-step').focus();
|
||||
});
|
||||
qs('#cancel-checklist-step').addEventListener('click', closeAddChecklistStep);
|
||||
qs('#add-checklist-step-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const state = {item:selectedIssue, detail:selectedIssueDetail, offline:selectedIssueOffline};
|
||||
if (!state.item || !state.detail?.updated_at) return;
|
||||
const submit = qs('#save-checklist-step');
|
||||
submit.disabled = true;
|
||||
qs('#add-checklist-step-status').textContent = state.offline ? 'Queueing checklist step…' : 'Adding checklist step…';
|
||||
try {
|
||||
const result = await (selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(
|
||||
issueController, state.item, state.detail, qs('#add-checklist-step').value
|
||||
);
|
||||
if (selectedIssue !== state.item) return;
|
||||
applyIssueContent(state.item, state.detail, state.offline ? result.detail : result);
|
||||
closeAddChecklistStep();
|
||||
qs('#add-checklist-step-status').textContent = state.offline ?
|
||||
'Checklist step queued. Pending sync.' : 'Checklist step added.';
|
||||
} catch (error) {
|
||||
if (selectedIssue === state.item) {
|
||||
qs('#add-checklist-step-status').textContent = error.message;
|
||||
qs('#add-checklist-step').focus();
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#complete-checklist-issue').addEventListener('click', () => {
|
||||
qs('#close-issue').click();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -605,6 +605,16 @@
|
|||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
||||
<section class="checklist-add" aria-label="Add checklist step">
|
||||
<button id="open-add-checklist-step" type="button" disabled>Add step</button>
|
||||
<form id="add-checklist-step-form" hidden>
|
||||
<label class="visually-hidden" for="add-checklist-step">Checklist step</label>
|
||||
<input id="add-checklist-step" type="text" maxlength="240" autocomplete="off" placeholder="New checklist step" />
|
||||
<button id="save-checklist-step" type="submit">Add</button>
|
||||
<button id="cancel-checklist-step" type="button">Cancel</button>
|
||||
</form>
|
||||
<div id="add-checklist-step-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="checklist-completion" id="checklist-completion" aria-label="Completed checklist" hidden>
|
||||
<span id="checklist-completion-status" role="status" aria-live="polite">Checklist complete</span>
|
||||
<button id="complete-checklist-issue" type="button">Close issue</button>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,19 @@ function escapeOptionHtml(value) {
|
|||
})[character]);
|
||||
}
|
||||
|
||||
function appendChecklistTask(body, label) {
|
||||
const normalized = String(label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) throw new Error('Enter a checklist step.');
|
||||
const key = normalized.toLocaleLowerCase();
|
||||
const duplicate = String(body || '').split('\n').some(line => {
|
||||
const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : '';
|
||||
return prefix + '- [ ] ' + normalized;
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
|
|
@ -152,6 +165,23 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
|||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
async addTask(item, detail, label) {
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body,
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
async queueAddedTask(item, detail, label) {
|
||||
if (typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
}
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueTask(item, detail, taskIndex, checked) {
|
||||
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v107';
|
||||
const CACHE = 'stackchain-dashboard-shell-v108';
|
||||
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;
|
||||
|
|
@ -73,6 +73,7 @@ const SHELL = [
|
|||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
|
|
|
|||
|
|
@ -47,3 +47,38 @@ process.stdout.write(JSON.stringify({{missing,duplicate}}));
|
|||
assert output["missing"]["conflicts"] == [{"label": "Ship", "reason": "missing"}]
|
||||
assert output["duplicate"]["body"] is None
|
||||
assert output["duplicate"]["conflicts"] == [{"label": "Ship", "reason": "ambiguous"}]
|
||||
|
||||
|
||||
def test_merge_appends_a_locally_added_step_to_remote_edits_without_reverting_them():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'Intro\\n- [ ] Build',
|
||||
localBody:'Intro\\n- [ ] Build\\n- [ ] Verify rollback',
|
||||
remoteBody:'Updated intro\\n- [x] Build\\n- [ ] Notify support',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "Updated intro\n- [x] Build\n- [ ] Notify support\n- [ ] Verify rollback",
|
||||
"changes": [{"label": "Verify rollback", "checked": False, "added": True}],
|
||||
"conflicts": [],
|
||||
}
|
||||
|
||||
|
||||
def test_merge_rejects_an_ambiguous_locally_added_duplicate():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'- [ ] Build',
|
||||
localBody:'- [ ] Build\\n- [ ] Verify rollback\\n- [ ] verify rollback ',
|
||||
remoteBody:'- [x] Build',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["body"] is None
|
||||
assert output["conflicts"] == [{"label": "Verify rollback", "reason": "ambiguous"}]
|
||||
|
|
|
|||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
|||
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-v107" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
|
|
|
|||
|
|
@ -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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -182,4 +182,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-v107" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ 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-v107" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
|||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v107" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
|
|
|||
|
|
@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
|||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v107" in service_worker
|
||||
assert "stackchain-dashboard-shell-v108" in service_worker
|
||||
|
|
|
|||
|
|
@ -2225,6 +2225,26 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
|
|||
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_detail_adds_a_checklist_step_inline_with_accessible_touch_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="open-add-checklist-step"' in html
|
||||
assert 'id="add-checklist-step-form"' in html
|
||||
assert 'id="add-checklist-step" type="text"' in html
|
||||
assert 'id="save-checklist-step" type="submit"' in html
|
||||
assert 'id="cancel-checklist-step"' in html
|
||||
assert 'id="add-checklist-step-status" class="small" aria-live="assertive"' in html
|
||||
assert "(selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(" in html
|
||||
assert "applyIssueContent(state.item, state.detail, state.offline ? result.detail : result)" in html
|
||||
assert "Checklist step queued. Pending sync." in html
|
||||
assert "qs('#add-checklist-step').focus()" in html
|
||||
assert "qs('#open-add-checklist-step').focus()" in html
|
||||
assert '.checklist-add button, .checklist-add input { min-height:44px;' in html
|
||||
assert '#issue-sheet.read-only .checklist-add { display:none;' in html
|
||||
assert 'grid-template-columns:minmax(0,1fr) auto auto' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict():
|
||||
html = await dashboard()
|
||||
|
|
@ -2447,6 +2467,77 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
|||
assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z"
|
||||
|
||||
|
||||
def test_issue_sheet_adds_a_validated_unchecked_step_without_replacing_existing_content():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const calls = [];
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:(url, options) => {{
|
||||
calls.push({{url, body:JSON.parse(options.body)}});
|
||||
return Promise.resolve({{number:17,title:'Release',body:'Intro\\n\\n- [x] Build\\n- [ ] Verify rollback',updated_at:'new'}});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api',number:17}};
|
||||
const detail = {{title:'Release',body:'Intro\\n\\n- [x] Build',updated_at:'old'}};
|
||||
Promise.all([
|
||||
controller.addTask(item, detail, ' Verify rollback '),
|
||||
controller.addTask(item, detail, ' build ').catch(error => ({{error:error.message}})),
|
||||
controller.addTask(item, detail, ' ').catch(error => ({{error:error.message}})),
|
||||
]).then(results => process.stdout.write(JSON.stringify({{calls,results}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/api/issues/17/content",
|
||||
"body": {
|
||||
"title": "Release",
|
||||
"body": "Intro\n\n- [x] Build\n- [ ] Verify rollback",
|
||||
"expected_updated_at": "old",
|
||||
},
|
||||
}]
|
||||
assert output["results"][0]["body"].endswith("- [ ] Verify rollback")
|
||||
assert output["results"][1] == {"error": "That checklist step already exists."}
|
||||
assert output["results"][2] == {"error": "Enter a checklist step."}
|
||||
|
||||
|
||||
def test_offline_issue_sheet_adds_a_step_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let admit; const queued = [];
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit=resolve); }},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api',number:17}};
|
||||
const detail = {{title:'Release',body:'Intro',updated_at:'old'}};
|
||||
let settled = false;
|
||||
const pending = controller.queueAddedTask(item, detail, 'Verify rollback').then(result => {{settled=true;return result;}});
|
||||
const before = settled;
|
||||
admit({{item:{{id:'add-step'}}}});
|
||||
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", "baseBody": "Intro", "body": "Intro\n- [ ] Verify rollback",
|
||||
"expectedUpdatedAt": "old",
|
||||
}]
|
||||
assert output["result"] == {
|
||||
"queued": True,
|
||||
"detail": {
|
||||
"title": "Release", "body": "Intro\n- [ ] Verify rollback",
|
||||
"updated_at": "old", "checklist_pending": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ 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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ async function dispatchPush(payload) {{
|
|||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -164,14 +164,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -179,22 +179,32 @@ 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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" 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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -203,21 +213,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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" 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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" 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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -881,7 +891,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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
@ -961,6 +971,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/issue-attachment.js",
|
||||
"/dashboard/static/issue-filing-review.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/checklist-conflict.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
|||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v107';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v108';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
|||
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-v107" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user