feat: surface mobile device readiness (Closes #559)
This commit is contained in:
1 parent
f446e10670
commit
7f8ee771dc
15 files changed
+151
-26
No files matched your search
@@ -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-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v88" in worker
|
||||
@@ -141,7 +141,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v87';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v88';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" 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-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v88" in worker
|
||||
@@ -41,7 +41,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-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v88" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -27,6 +27,16 @@ const offlineStatus = new FakeTarget();
|
||||
const pushStatus = new FakeTarget();
|
||||
const readyStatus = new FakeTarget();
|
||||
const escapeTarget = new FakeTarget();
|
||||
const promptCard = new FakeTarget(); promptCard.hidden = true;
|
||||
const promptSummary = new FakeTarget();
|
||||
const promptLauncher = new FakeTarget();
|
||||
const promptDismiss = new FakeTarget();
|
||||
let now = 1000;
|
||||
const promptStorage = {
|
||||
values:{},
|
||||
getItem(key) { return this.values[key] ?? null; },
|
||||
setItem(key, value) { this.values[key] = String(value); },
|
||||
};
|
||||
let readiness = {
|
||||
install:{state:'complete', detail:'Stackchain is installed.'},
|
||||
offline:{state:'incomplete', detail:'Offline work is off.'},
|
||||
@@ -35,12 +45,14 @@ let readiness = {
|
||||
const setup = createMobileDeviceSetup({
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
|
||||
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
|
||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||
promptStorage, now:() => now,
|
||||
getReadiness:() => readiness,
|
||||
install:async () => { state.installCalls += 1; },
|
||||
enableOffline:async () => { state.offlineCalls += 1; },
|
||||
enablePush:async () => { state.pushCalls += 1; },
|
||||
});
|
||||
(async () => { setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
@@ -70,6 +82,54 @@ process.stdout.write(JSON.stringify({
|
||||
}
|
||||
|
||||
|
||||
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
|
||||
result = run_scenario("""
|
||||
process.stdout.write(JSON.stringify({
|
||||
hidden:promptCard.hidden,
|
||||
summary:promptSummary.textContent,
|
||||
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hidden": False,
|
||||
"summary": "1 of 2 steps complete",
|
||||
"calls": [0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_card_opens_setup_and_receives_focus_back_on_close():
|
||||
result = run_scenario("""
|
||||
await promptLauncher.dispatch('click', {currentTarget:promptLauncher});
|
||||
const opened = !sheet.hidden && state.focused === closeButton;
|
||||
await closeButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
opened,
|
||||
closed:sheet.hidden,
|
||||
promptFocused:state.focused === promptLauncher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {"opened": True, "closed": True, "promptFocused": True}
|
||||
|
||||
|
||||
def test_not_now_hides_prompt_for_seven_days_then_allows_it_to_return():
|
||||
result = run_scenario("""
|
||||
await promptDismiss.dispatch('click');
|
||||
const hiddenAfterDismiss = promptCard.hidden;
|
||||
const dismissedUntil = Number(promptStorage.values['stackchain.device-setup-prompt-dismissed-until']);
|
||||
now = dismissedUntil + 1;
|
||||
await setup.render();
|
||||
process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil - 1000, hiddenAfterExpiry:promptCard.hidden}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hiddenAfterDismiss": True,
|
||||
"delay": 7 * 24 * 60 * 60 * 1000,
|
||||
"hiddenAfterExpiry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_setup_action_rechecks_real_state_before_marking_step_ready():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
@@ -79,6 +139,7 @@ process.stdout.write(JSON.stringify({
|
||||
calls:state.offlineCalls,
|
||||
offline:offlineStatus.textContent,
|
||||
hidden:offlineButton.hidden,
|
||||
promptHidden:promptCard.hidden,
|
||||
summary:readyStatus.textContent,
|
||||
}));
|
||||
""")
|
||||
@@ -87,6 +148,7 @@ process.stdout.write(JSON.stringify({
|
||||
"calls": 1,
|
||||
"offline": "Offline work is saved.",
|
||||
"hidden": True,
|
||||
"promptHidden": True,
|
||||
"summary": "This device is ready.",
|
||||
}
|
||||
|
||||
@@ -112,13 +174,22 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="open-device-setup"' in html
|
||||
assert 'id="device-readiness-card"' in html
|
||||
assert 'id="finish-device-setup"' in html
|
||||
assert 'id="dismiss-device-readiness"' in html
|
||||
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
|
||||
assert 'id="device-setup-sheet"' in html
|
||||
assert 'aria-labelledby="device-setup-heading"' in html
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push"))
|
||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v88" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".device-setup-action { min-height:44px;" in css
|
||||
assert ".device-readiness-actions button { min-height:44px;" in css
|
||||
@@ -292,6 +292,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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -133,7 +133,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -142,14 +142,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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" 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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -157,7 +157,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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -165,14 +165,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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" 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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -181,21 +181,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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" 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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" 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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -432,7 +432,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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -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-v87';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v88';" 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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user