feat: review wrap-up commitments in Today (Closes #992)
This commit is contained in:
1 parent
40623f939c
commit
468676d2f4
27 files changed
+537
-53
No files matched your search
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged next-day Today handoff runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.on("pageerror", lambda error: errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Tomorrow review phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate("""localStorage.setItem('stackchain.today-handoff.v1.timmy', JSON.stringify([
|
||||
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
||||
]))""")
|
||||
page.reload(wait_until="networkidle")
|
||||
|
||||
dialog = page.locator("#today-handoff-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(page.locator("#today-handoff-items .today-handoff-item")).to_have_count(2)
|
||||
for control in dialog.locator("button, input").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 24
|
||||
buttons = dialog.locator("button")
|
||||
for control in buttons.all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-today-handoff="issue:acme/mobile:42:"]').uncheck()
|
||||
page.locator("#confirm-today-handoff").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy'))") == [
|
||||
"issue:acme/mobile:41:"
|
||||
]
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-handoff.v1.timmy'))") == [
|
||||
"issue:acme/mobile:42:"
|
||||
]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -60,6 +60,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py -q"
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
@@ -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-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v115" in worker
|
||||
@@ -65,6 +65,30 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
|
||||
legacy = store.apply(
|
||||
"timmy", "legacy", "defer", "issue:r:1:",
|
||||
wake_at="2026-08-17T09:00:00.000Z",
|
||||
)
|
||||
handoff = store.apply(
|
||||
"timmy", "wrap-up", "defer", "issue:r:2:",
|
||||
wake_at="2026-08-17T09:00:00.000Z",
|
||||
handoff="today",
|
||||
)
|
||||
|
||||
assert legacy["records"]["issue:r:1:"] == "2026-08-17T09:00:00.000Z"
|
||||
assert handoff["records"] == {
|
||||
"issue:r:1:": "2026-08-17T09:00:00.000Z",
|
||||
"issue:r:2:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
},
|
||||
}
|
||||
assert LaterStore(store.path).get("timmy") == handoff
|
||||
|
||||
|
||||
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, timeout=0.05)
|
||||
|
||||
@@ -97,6 +97,35 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_today_handoff_intent_replays_through_account_sync():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'wrap-up-op',
|
||||
fetchJson:async(_url,options={{}})=>{{
|
||||
const body=JSON.parse(options.body); requests.push(body);
|
||||
return {{revision:1,records:{{[body.operations[0].item_id]:{{wake_at:body.operations[0].wake_at,handoff:body.operations[0].handoff}}}},accepted_operation_ids:['wrap-up-op'],duplicate_operation_ids:[],rejected_operations:[]}};
|
||||
}},onRemoteRecords:records=>{{globalThis.records=records}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-17T09:00:00.000Z','today');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,records:globalThis.records}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["requests"] == [{"operations": [{
|
||||
"operation_id": "wrap-up-op",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
"base_revision": 0,
|
||||
}]}]
|
||||
assert result["records"] == {"issue:r:2:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z", "handoff": "today",
|
||||
}}
|
||||
|
||||
|
||||
def test_later_queue_expires_ancient_edits_before_replay():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
@@ -294,6 +323,26 @@ sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:
|
||||
}
|
||||
|
||||
|
||||
def test_unsynced_wrap_up_handoff_migrates_after_browser_restart():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'migrated-handoff',
|
||||
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:1,records:{{}},accepted_operation_ids:['migrated-handoff'],duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.migrate({{'issue:r:1:':{{wake_at:'2026-08-17T09:00:00.000Z',handoff:'today'}}}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify(requests));}})();
|
||||
"""
|
||||
assert run_node(script) == [{"operations": [{
|
||||
"operation_id": "migrated-handoff", "action": "defer",
|
||||
"item_id": "issue:r:1:", "wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today", "base_revision": 0,
|
||||
}]}]
|
||||
|
||||
|
||||
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
@@ -324,6 +373,45 @@ process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:
|
||||
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||
|
||||
|
||||
def test_later_work_preserves_synced_today_handoff_until_it_wakes():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
const values=new Map(); const changes=[]; const expired=[];
|
||||
let clock=new Date('2026-08-16T20:00:00Z');
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:992,title:'Commitment'}};
|
||||
const work=createLaterWork({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||
onChange:(action,id,wakeAt,handoff)=>changes.push([action,id,wakeAt,handoff||null]),
|
||||
onExpire:(ids,handoffs)=>expired.push([ids,handoffs]),
|
||||
}});
|
||||
work.defer(item,new Date('2026-08-17T09:00:00Z'),{{handoff:'today'}});
|
||||
const stored=work.read();
|
||||
clock=new Date('2026-08-17T09:00:01Z');
|
||||
const awake=work.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{stored,changes,expired,awake:awake.active}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["stored"] == {
|
||||
"issue:stackchain/dashboard:992:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
}
|
||||
}
|
||||
assert result["changes"] == [[
|
||||
"defer", "issue:stackchain/dashboard:992:",
|
||||
"2026-08-17T09:00:00.000Z", "today",
|
||||
]]
|
||||
assert result["expired"] == [[
|
||||
["issue:stackchain/dashboard:992:"],
|
||||
["issue:stackchain/dashboard:992:"],
|
||||
]]
|
||||
assert result["awake"] == [{
|
||||
"kind": "issue", "repository": "stackchain/dashboard",
|
||||
"number": 992, "title": "Commitment",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
html = await dashboard()
|
||||
@@ -332,9 +420,9 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
assert 'id="later-sync-status"' in html
|
||||
assert "const laterSync = createLaterSync({" in html
|
||||
assert "onRemoteRecords: records =>" in html
|
||||
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||
assert "onExpire: ids =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||
assert "onChange: (action, itemId, wakeAt, handoff) =>" in html
|
||||
assert "onExpire: (ids, handoffs) =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in html
|
||||
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
@@ -347,5 +435,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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -256,4 +256,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-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v115" 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-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v115" 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-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v115" 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-v114" in service_worker
|
||||
assert "stackchain-dashboard-shell-v115" in service_worker
|
||||
@@ -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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" 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
|
||||
@@ -165,13 +165,13 @@ async function dispatchPush(payload) {{
|
||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -180,7 +180,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -189,7 +189,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -197,14 +197,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -212,7 +212,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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -220,7 +220,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" 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
|
||||
@@ -230,14 +230,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -246,21 +246,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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -930,7 +930,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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -989,6 +989,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
"/dashboard/static/today-wrap-up.js",
|
||||
"/dashboard/static/today-handoff.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HANDOFF = Path(__file__).parents[1] / "frontend" / "today-handoff.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_due_wrap_up_commitments_are_reviewed_in_order_and_added_durably():
|
||||
script = f"""
|
||||
const createHandoff=require({json.dumps(str(HANDOFF))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const items=[
|
||||
{{kind:'issue',repository:'r',number:2,title:'Second'}},
|
||||
{{kind:'issue',repository:'r',number:1,title:'First'}},
|
||||
];
|
||||
const identity=item=>'issue:'+item.repository+':'+item.number+':';
|
||||
const today=[]; const operations=[];
|
||||
const controller=createHandoff({{
|
||||
storage,getLogin:()=> 'timmy',identity,
|
||||
todayWork:{{contains:item=>today.includes(identity(item)),add:item=>{{if(today.length>=1)return 'full';today.push(identity(item));return 'added';}}}},
|
||||
todaySync:{{enqueue:(action,id)=>{{operations.push([action,id]);return true;}},flush:async()=>true}},
|
||||
}});
|
||||
controller.capture(['issue:r:1:','issue:r:2:','issue:r:1:']);
|
||||
const review=controller.review(items);
|
||||
controller.choose('issue:r:2:',false);
|
||||
const first=await controller.commit(items);
|
||||
controller.choose('issue:r:2:',true);
|
||||
const blocked=await controller.commit(items);
|
||||
process.stdout.write(JSON.stringify({{review,first,blocked,today,operations,pending:controller.pending()}}));
|
||||
"""
|
||||
result = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert [row["identity"] for row in result["review"]] == ["issue:r:1:", "issue:r:2:"]
|
||||
assert all(row["selected"] for row in result["review"])
|
||||
assert result["first"] == {"scheduled": 1, "blocked": 0, "remaining": 1}
|
||||
assert result["blocked"] == {"scheduled": 0, "blocked": 1, "remaining": 1}
|
||||
assert result["today"] == ["issue:r:1:"]
|
||||
assert result["operations"] == [["add", "issue:r:1:"]]
|
||||
assert result["pending"] == ["issue:r:2:"]
|
||||
|
||||
|
||||
def test_handoff_pending_state_is_account_scoped_and_capture_is_idempotent():
|
||||
script = f"""
|
||||
const createHandoff=require({json.dumps(str(HANDOFF))});
|
||||
const values=new Map();let login='timmy';
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const options={{storage,getLogin:()=>login,identity:item=>item.id,todayWork:{{contains:()=>false,add:()=> 'added'}},todaySync:{{enqueue:()=>true,flush:async()=>true}}}};
|
||||
const controller=createHandoff(options);
|
||||
controller.capture(['one','two']);controller.capture(['two','one']);
|
||||
const timmy=controller.pending();login='alexander';const isolated=controller.pending();
|
||||
login='timmy';const resumed=createHandoff(options).pending();
|
||||
process.stdout.write(JSON.stringify({{timmy,isolated,resumed}}));
|
||||
"""
|
||||
assert run_node(f"(async()=>{{{script}}})()") == {
|
||||
"timmy": ["one", "two"], "isolated": [], "resumed": ["one", "two"],
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_packages_accessible_mobile_commitment_review_and_account_sync():
|
||||
from src import main
|
||||
|
||||
html = main.FRONTEND_BUILD.dashboard_html
|
||||
dashboard = (HANDOFF.parents[0] / "dashboard.js").read_text()
|
||||
css = (HANDOFF.parents[0] / "dashboard.css").read_text()
|
||||
service_worker = (HANDOFF.parents[0] / "service-worker.js").read_text()
|
||||
|
||||
assert 'id="today-handoff-dialog"' in html
|
||||
assert 'aria-labelledby="today-handoff-title"' in html
|
||||
assert 'id="today-handoff-items"' in html
|
||||
assert 'id="confirm-today-handoff"' in html
|
||||
assert '<script src="static/today-handoff.js"></script>' in (HANDOFF.parents[0] / "index.html").read_text()
|
||||
assert "createTodayHandoff({" in dashboard
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in dashboard
|
||||
assert "todayHandoff.capture(handoffs)" in dashboard
|
||||
assert ".today-handoff-actions button { min-height:44px;" in css
|
||||
assert "BASE + 'static/today-handoff.js'" in service_worker
|
||||
assert "static/today-handoff.js" in main.FRONTEND_BUILD.page_sources
|
||||
@@ -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-v114';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v115';" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const todayWork = {{
|
||||
const laterWork = {{
|
||||
identity:todayWork.identity,
|
||||
presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'),
|
||||
defer:(item, until) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push(todayWork.identity(item)); return 'deferred'; }},
|
||||
defer:(item, until, options) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push([todayWork.identity(item), options]); return 'deferred'; }},
|
||||
}};
|
||||
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
|
||||
const wrap = createWrapUp({{todayWork,laterWork,todaySync}});
|
||||
@@ -52,7 +52,7 @@ process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}}));
|
||||
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
|
||||
assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"}
|
||||
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
|
||||
assert output["laterOps"] == ["issue:stackchain/dashboard:2:"]
|
||||
assert output["laterOps"] == [["issue:stackchain/dashboard:2:", {"handoff": "today"}]]
|
||||
|
||||
|
||||
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
|
||||
|
||||
Reference in new issue
Block a user