fix: make update decisions transaction-safe (Closes #771)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-13 20:58:43 +00:00
1 parent 80042ce561
commit 2e0d64907b
9 files changed
+131 -22

No files matched your search

+2 -2
View File
@@ -68,8 +68,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function attachSecurityCenter" in security_center.runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# One-ahead Updates prefetch stays in the core reader so transitions can reuse its in-flight request.
assert len(first.runtime_gzip_bytes) <= 97 * 1024
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
assert len(first.runtime_gzip_bytes) <= 98 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
+1 -1
View File
@@ -6339,7 +6339,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
assert "createNotificationReader" in html
assert "api/v1/notifications/" in html
assert "notificationReader.markReadAndNext(lastMyWork)" in html
assert "reader:notificationReader, items:()=>lastMyWork" in html
def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
+1
View File
@@ -850,6 +850,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/update-read-position.js",
"/dashboard/static/update-triage-launcher.js",
"/dashboard/static/update-triage-gesture.js",
"/dashboard/static/update-decision-transaction.js",
"/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-app-shortcuts.js",
+70 -2
View File
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
GESTURE = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-gesture.js"
TRANSACTION = Path(__file__).resolve().parents[1] / "frontend" / "update-decision-transaction.js"
def run_gesture(script):
@@ -17,6 +18,71 @@ def run_gesture(script):
return json.loads(result.stdout)
def run_transaction(script):
source = f"const createTransaction = require({json.dumps(str(TRANSACTION))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_update_decision_transaction_locks_every_action_until_success():
result = run_transaction(r"""
const controls = ['keep', 'read', 'reply', 'more'].map(name => ({name, disabled:false, focus(){}}));
const status = {textContent:'Ready'};
let resolveAction;
let calls = 0;
const transaction = createTransaction({controls, status});
const pending = transaction.run('Marking read…', () => new Promise(resolve => {
calls += 1;
resolveAction = resolve;
}));
const locked = controls.every(control => control.disabled) && transaction.busy();
const rejected = transaction.run('Keeping unread…', () => { calls += 1; });
resolveAction(true);
Promise.all([pending, rejected]).then(([completed, second]) => process.stdout.write(JSON.stringify({
locked, pendingStatus: 'Marking read…', calls, completed, second,
unlocked: controls.every(control => !control.disabled) && !transaction.busy(),
finalStatus: status.textContent,
})));
""")
assert result == {
"locked": True,
"pendingStatus": "Marking read…",
"calls": 1,
"completed": True,
"second": False,
"unlocked": True,
"finalStatus": "Marking read…",
}
def test_update_decision_transaction_restores_focus_and_announces_failure():
result = run_transaction(r"""
let focused = 0;
let focusedWhileEnabled = false;
const origin = {disabled:false, focus(){ focused += 1; focusedWhileEnabled = !this.disabled; }};
const other = {disabled:false, focus(){}};
const status = {textContent:''};
const transaction = createTransaction({controls:[origin, other], status});
transaction.run('Marking read…', async () => false, {
origin,
failure:'Could not mark update read. Retry.',
}).then(completed => process.stdout.write(JSON.stringify({
completed, focused, focusedWhileEnabled, status:status.textContent,
unlocked:!origin.disabled && !other.disabled && !transaction.busy(),
})));
""")
assert result == {
"completed": False,
"focused": 1,
"focusedWhileEnabled": True,
"status": "Could not mark update read. Retry.",
"unlocked": True,
}
def test_committed_horizontal_swipes_decide_once_and_lock_while_pending():
result = run_gesture(r"""
class Surface {
@@ -102,9 +168,11 @@ async def test_dashboard_wires_accessible_phone_only_update_swipe_triage():
html = await dashboard()
assert '<script src="static/update-triage-gesture.js"></script>' in html
assert '<script src="static/update-decision-transaction.js"></script>' in html
assert 'id="update-gesture-status"' in html
assert "createUpdateTriageGesture({" in html
assert "qs('#keep-update-unread').click()" in html
assert "qs('#mark-update-read-next').click()" in html
assert "createUpdateDecisionActions({" in html
assert "keepUnread: keepUpdateUnread" in html
assert "markRead: markUpdateRead" in html
assert "data-triage-gesture" in html
assert "prefers-reduced-motion: reduce" in html
+1 -1
View File
@@ -158,7 +158,7 @@ async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>' in html
assert "openUpdates: openUpdateTriage" in html
assert "updateTriage.acceptCompleted()" in html
assert "updateTriage.keepUnreadAndNext()" in html
assert "triage:updateTriage" in html
assert "updateTriage.reconcile()" in html
assert 'id="mobile-update-outcome"' in html
assert 'id="review-kept-updates"' in html