177 lines
7 KiB
Python
177 lines
7 KiB
Python
"""
|
||
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()
|