""" Step 24 — App Update Modal (no more silent freeze) - test_modal_appears_when_update_pending: Modal appears when an update is pending - test_later_dismisses_for_session: "Later" closes the modal and it stays closed across navigation - test_update_now_shows_spinner_and_calls_update: "Update now" switches to the spinner state and calls the update function - test_spinner_state_ignores_escape: The updating state cannot be dismissed with Escape - test_spinner_state_has_no_close_button: The updating state has no header close button - test_modal_suppressed_on_execution_route: Modal does not appear during session execution - test_modal_appears_after_leaving_execution: Modal appears once the user navigates away from execution The pending update is forced through the dev-only hook `window.__appUpdateTest` (see src/composables/useAppUpdate.js) — simulating a real service-worker update in Playwright would require two production builds. The real SW flow is validated manually. """ from playwright.sync_api import Page, expect def _wait_for_db(page: Page, app_url: str): page.goto(app_url) page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) def _trigger_update(page: Page): """Force the 'update available' state via the dev-only test hook.""" page.wait_for_function("!!window.__appUpdateTest", timeout=10000) page.evaluate("window.__appUpdateTest.trigger()") def _mock_update_fn(page: Page): """Replace the real SW update call with a recorder that never resolves (the real call ends in a page reload, which the spinner state covers).""" page.evaluate("""() => { window.__updateCalls = [] window.__appUpdateTest.mockUpdateFn = (reload) => { window.__updateCalls.push(reload) return new Promise(() => {}) } }""") def _create_session(page: Page, title: str) -> str: """Create a one-exercise session and return its id.""" return page.evaluate(f"""async () => {{ const userId = await window.__repos.settings.get('user_uuid') const exId = await window.__repos.exercises.create({{ title: {repr(title + ' exercise')}, description: '', asymmetric: false, alternate: false, image_urls: '[]', video_urls: '[]', default_reps: 10, default_weight: 0, created_by: userId }}) const sessId = await window.__repos.sessions.create({{ title: {repr(title)}, description: '', created_by: userId }}) await window.__repos.sessions.setItems(sessId, [{{ item_id: exId, item_type: 'exercise', position: 0, repetitions: 1, weight: 0 }}]) return sessId }}""") def test_modal_appears_when_update_pending(page: Page, app_url: str): """The update modal appears when an update is pending.""" _wait_for_db(page, app_url) expect(page.locator('[data-testid="app-update-modal"]')).not_to_be_visible() _trigger_update(page) expect(page.locator('[data-testid="app-update-modal"]')).to_be_visible() expect(page.locator('[data-testid="app-update-now"]')).to_be_visible() expect(page.locator('[data-testid="app-update-later"]')).to_be_visible() def test_later_dismisses_for_session(page: Page, app_url: str): """'Later' closes the modal and it does not reappear while navigating.""" _wait_for_db(page, app_url) _trigger_update(page) modal = page.locator('[data-testid="app-update-modal"]') expect(modal).to_be_visible() page.locator('[data-testid="app-update-later"]').click() expect(modal).not_to_be_visible() # Navigating within the session does not bring the modal back page.evaluate("window.location.hash = '#/exercises'") page.wait_for_selector('[data-testid="exercise-new-btn"]', timeout=10000) expect(modal).not_to_be_visible() def test_update_now_shows_spinner_and_calls_update(page: Page, app_url: str): """'Update now' switches to the spinner state and calls the update function.""" _wait_for_db(page, app_url) _trigger_update(page) _mock_update_fn(page) page.locator('[data-testid="app-update-now"]').click() expect(page.locator('[data-testid="app-update-updating"]')).to_be_visible() # Choice buttons are gone expect(page.locator('[data-testid="app-update-now"]')).not_to_be_visible() expect(page.locator('[data-testid="app-update-later"]')).not_to_be_visible() calls = page.evaluate("window.__updateCalls") assert calls == [True], f"updateServiceWorker should be called with reload=true, got {calls}" def test_spinner_state_ignores_escape(page: Page, app_url: str): """The updating state cannot be dismissed with Escape or a backdrop click.""" _wait_for_db(page, app_url) _trigger_update(page) _mock_update_fn(page) page.locator('[data-testid="app-update-now"]').click() updating = page.locator('[data-testid="app-update-updating"]') expect(updating).to_be_visible() page.keyboard.press("Escape") expect(updating).to_be_visible() # Backdrop click must not close it either page.locator('[data-testid="modal-overlay"]').click(position={"x": 5, "y": 5}) expect(updating).to_be_visible() def test_spinner_state_has_no_close_button(page: Page, app_url: str): """The updating state renders no header close (×) button.""" _wait_for_db(page, app_url) _trigger_update(page) _mock_update_fn(page) expect(page.locator('[data-testid="modal-close"]')).to_be_visible() page.locator('[data-testid="app-update-now"]').click() expect(page.locator('[data-testid="app-update-updating"]')).to_be_visible() expect(page.locator('[data-testid="modal-close"]')).not_to_be_visible() def test_modal_suppressed_on_execution_route(page: Page, app_url: str): """The modal does not appear while a session is being executed.""" _wait_for_db(page, app_url) sess_id = _create_session(page, "Update modal session") page.evaluate(f"window.location.hash = '#/sessions/{sess_id}/execute'") page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000) _trigger_update(page) expect(page.locator('[data-testid="app-update-modal"]')).not_to_be_visible() def test_modal_appears_after_leaving_execution(page: Page, app_url: str): """The pending modal appears once the user navigates away from execution.""" _wait_for_db(page, app_url) sess_id = _create_session(page, "Update modal deferred") page.evaluate(f"window.location.hash = '#/sessions/{sess_id}/execute'") page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000) _trigger_update(page) expect(page.locator('[data-testid="app-update-modal"]')).not_to_be_visible() # Leave execution (accept the abandon-confirmation dialog) page.on("dialog", lambda dialog: dialog.accept()) page.evaluate("window.location.hash = '#/home'") expect(page.locator('[data-testid="app-update-modal"]')).to_be_visible()