import json import subprocess from pathlib import Path MODULE = Path(__file__).parents[1] / "frontend" / "mobile-device-setup.js" def run_scenario(script: str) -> dict: harness = r""" const createMobileDeviceSetup = require(__MODULE__); class FakeTarget { constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; this.inert = false; } addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); } async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); } focus() { state.focused = this; } } const state = { installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null, events:[], beginCalls:[], finishCalls:0, }; const launcher = new FakeTarget(); const closeButton = new FakeTarget(); const sheet = new FakeTarget(); sheet.hidden = true; const installButton = new FakeTarget(); const offlineButton = new FakeTarget(); const protectionButton = new FakeTarget(); const pushButton = new FakeTarget(); const deadlineButton = new FakeTarget(); const installStatus = new FakeTarget(); const offlineStatus = new FakeTarget(); const protectionStatus = new FakeTarget(); const pushStatus = new FakeTarget(); const deadlineStatus = 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(); const returnButton = new FakeTarget(); const backgroundHeader = new FakeTarget(); const backgroundMain = new FakeTarget(); backgroundMain.inert = true; sheet.querySelectorAll = () => [closeButton, installButton, offlineButton, returnButton]; const historyTarget = new FakeTarget(); const history = { state:{section:'home'}, pushState(state) { this.state = state; }, back() { this.state = {section:'home'}; historyTarget.dispatch('popstate', {state:this.state}); }, }; 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.'}, protection:{state:'incomplete', detail:'Offline work uses best-effort browser storage.'}, push:{state:'unavailable', detail:'Notifications are unavailable.'}, deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'}, }; const setup = createMobileDeviceSetup({ launcher, closeButton, sheet, installButton, offlineButton, protectionButton, pushButton, deadlineButton, installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget, promptCard, promptSummary, promptLauncher, promptDismiss, returnButton, isMobile:() => true, backgroundElements:[backgroundHeader, backgroundMain], history, historyTarget, timerView:{ beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; }, finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; }, }, promptStorage, now:() => now, getReadiness:() => { state.events.push('readiness'); return readiness; }, install:async () => { state.installCalls += 1; }, enableOffline:async () => { state.offlineCalls += 1; }, protectStorage:async () => { state.protectionCalls = (state.protectionCalls || 0) + 1; }, enablePush:async () => { state.pushCalls += 1; }, enableDeadline:async () => { state.deadlineCalls += 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 return json.loads(completed.stdout) def test_opening_setup_reports_actual_readiness_without_triggering_permissions(): result = run_scenario(""" await launcher.dispatch('click', {currentTarget:launcher}); process.stdout.write(JSON.stringify({ hidden:sheet.hidden, install:installStatus.textContent, offline:offlineStatus.textContent, push:pushStatus.textContent, summary:readyStatus.textContent, calls:[state.installCalls,state.offlineCalls,state.pushCalls], })); """) assert result == { "hidden": False, "install": "Stackchain is installed.", "offline": "Offline work is off.", "push": "Notifications are unavailable.", "summary": "1 of 3 available steps ready.", "calls": [0, 0, 0], } def test_storage_protection_runs_only_from_its_explicit_setup_action(): result = run_scenario(""" await launcher.dispatch('click', {currentTarget:launcher}); const callsOnOpen=state.protectionCalls || 0; readiness.protection={state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.'}; await protectionButton.dispatch('click'); process.stdout.write(JSON.stringify({ callsOnOpen, callsAfterAction:state.protectionCalls, detail:protectionStatus.textContent, hidden:protectionButton.hidden, })); """) assert result == { "callsOnOpen": 0, "callsAfterAction": 1, "detail": "Offline work is protected from automatic browser storage cleanup.", "hidden": True, } 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 3 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}); readiness.protection = {state:'complete', detail:'Offline work is protected.'}; readiness.offline = {state:'complete', detail:'Offline work is saved.'}; await offlineButton.dispatch('click'); process.stdout.write(JSON.stringify({ calls:state.offlineCalls, offline:offlineStatus.textContent, hidden:offlineButton.hidden, promptHidden:promptCard.hidden, summary:readyStatus.textContent, })); """) assert result == { "calls": 1, "offline": "Offline work is saved.", "hidden": True, "promptHidden": True, "summary": "This device is ready.", } def test_escape_closes_setup_and_restores_launcher_focus(): result = run_scenario(""" await launcher.dispatch('click', {currentTarget:launcher}); await escapeTarget.dispatch('keydown', {key:'Escape'}); process.stdout.write(JSON.stringify({ hidden:sheet.hidden, launcherFocused:state.focused === launcher, })); """) assert result == {"hidden": True, "launcherFocused": True} def test_open_setup_contains_focus_and_restores_background_inert_state(): result = run_scenario(""" await launcher.dispatch('click', {currentTarget:launcher}); state.focused = returnButton; let preventedForward = false; await escapeTarget.dispatch('keydown', {key:'Tab', target:returnButton, preventDefault(){preventedForward=true;}}); const forwardFocus = state.focused === closeButton; state.focused = closeButton; let preventedReverse = false; await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true, target:closeButton, preventDefault(){preventedReverse=true;}}); const reverseFocus = state.focused === returnButton; const inertWhileOpen = [backgroundHeader.inert, backgroundMain.inert]; await closeButton.dispatch('click'); process.stdout.write(JSON.stringify({ preventedForward, forwardFocus, preventedReverse, reverseFocus, inertWhileOpen, inertAfterClose:[backgroundHeader.inert, backgroundMain.inert], launcherFocused:state.focused === launcher, })); """) assert result == { "preventedForward": True, "forwardFocus": True, "preventedReverse": True, "reverseFocus": True, "inertWhileOpen": [True, True], "inertAfterClose": [False, True], "launcherFocused": True, } def test_browser_back_closes_setup_and_restores_launcher_focus(): result = run_scenario(""" await launcher.dispatch('click', {currentTarget:launcher}); const marked = history.state.deviceSetup === true; history.back(); await new Promise(resolve => setImmediate(resolve)); process.stdout.write(JSON.stringify({ marked, hidden:sheet.hidden, backgroundReleased:backgroundHeader.inert === false, launcherFocused:state.focused === launcher, })); """) assert result == { "marked": True, "hidden": True, "backgroundReleased": True, "launcherFocused": True, } def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once(): result = run_scenario(""" state.events = []; await launcher.dispatch('click', {currentTarget:launcher}); const firstOpen = {events:[...state.events], reasons:[...state.beginCalls]}; await sheet.dispatch('click', {target:sheet}); const backdropFinish = state.finishCalls; await promptLauncher.dispatch('click', {currentTarget:promptLauncher}); await escapeTarget.dispatch('keydown', {key:'Escape'}); const escapeFinish = state.finishCalls; await launcher.dispatch('click', {currentTarget:launcher}); returnButton.addEventListener('click', () => { state.events.push('shared-resume'); state.finishCalls += 1; }); await returnButton.dispatch('click'); process.stdout.write(JSON.stringify({ firstOpen, backdropFinish, escapeFinish, returnFinish:state.finishCalls, hidden:sheet.hidden, })); """) assert result == { "firstOpen": {"events": ["pause", "readiness"], "reasons": ["device-setup"]}, "backdropFinish": 1, "escapeFinish": 2, "returnFinish": 3, "hidden": True, } def test_deadline_step_runs_one_setup_action_and_uses_confirmed_readiness(): result = run_scenario(""" readiness.push = {state:'complete', detail:'New update notifications are enabled.'}; readiness.deadline = {state:'incomplete', detail:'Choose when to receive deadline reminders.'}; await launcher.dispatch('click', {currentTarget:launcher}); readiness.deadline = {state:'complete', detail:'Deadline reminders enabled for 08:00 local time.'}; await deadlineButton.dispatch('click'); process.stdout.write(JSON.stringify({ calls:state.deadlineCalls, detail:deadlineStatus.textContent, hidden:deadlineButton.hidden, summary:readyStatus.textContent, })); """) assert result == { "calls": 1, "detail": "Deadline reminders enabled for 08:00 local time.", "hidden": True, "summary": "3 of 5 available steps ready.", } def test_blocked_notification_step_becomes_a_check_again_action(): result = run_scenario(""" readiness.push = { state:'blocked', detail:'Notifications are blocked. Allow them in browser settings, then check again.', actionLabel:'Check again', }; await launcher.dispatch('click', {currentTarget:launcher}); await pushButton.dispatch('click'); process.stdout.write(JSON.stringify({label:pushButton.textContent,detail:pushStatus.textContent,calls:state.pushCalls,hidden:pushButton.hidden})); """) assert result == { "label": "Check again", "detail": "Notifications are blocked. Allow them in browser settings, then check again.", "calls": 1, "hidden": False, } def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): root = MODULE.parents[1] html = (root / "frontend" / "index.html").read_text() dashboard = (root / "frontend" / "dashboard.js").read_text() storage_module = (root / "frontend" / "device-storage.js").read_text() worker = (root / "frontend" / "service-worker.js").read_text() 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 'id="device-setup-today-detour"' in html assert 'id="return-from-device-setup"' in html assert 'aria-labelledby="device-setup-heading"' in html assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "protection", "push", "deadline")) assert 'id="device-setup-protection-status"' in html assert 'id="device-storage-summary"' in html assert 'id="device-storage-detail"' in html assert 'id="clear-device-caches"' in html assert 'id="clear-private-device-data"' in html assert '' in html assert '' in html assert 'id="device-setup-deadline-hour"' in html assert '' in html assert "createMobileDeviceSetup.mount({" in dashboard assert "timerView," in dashboard assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text() assert "const deviceStorage = createDeviceStorage.mount(document)" in dashboard assert "storageProtectionReadiness:() => deviceStorage.persistenceReadiness()" in dashboard assert "protectStorage:() => deviceStorage.requestPersistence()" in dashboard assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module assert "promptStorage:localStorage" in dashboard assert "notificationReadiness:() => pushController?.notificationReadiness()" in dashboard assert "controller.recoverPermission('updates')" in dashboard assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker assert "stackchain-dashboard-shell-v133" 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