From 65d8f1252f5c9c2b708072237ce410a93dd80f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 21:54:34 +0000 Subject: [PATCH 1/2] fix: re-acquire screen wake lock after visibility change The wake lock was requested when entering the workout screen, but the visibilitychange handler never re-acquired it after the page became visible again. On iOS, switching apps releases the wake lock, so the screen would time out after returning to the workout. Track whether a wake lock is actively wanted and re-request it on visibility change. https://claude.ai/code/session_01MgxBE65U2FSemXB1CKtdZF --- workout-tracker/e2e/wakelock.spec.ts | 134 +++++++++++++++++++++++++++ workout-tracker/src/ui/wakelock.ts | 8 +- 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 workout-tracker/e2e/wakelock.spec.ts diff --git a/workout-tracker/e2e/wakelock.spec.ts b/workout-tracker/e2e/wakelock.spec.ts new file mode 100644 index 0000000..8497d4c --- /dev/null +++ b/workout-tracker/e2e/wakelock.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Wake Lock', () => { + test('requests wake lock on workout screen and re-acquires after visibility change', async ({ page }) => { + // Mock the Wake Lock API before navigating + await page.addInitScript(() => { + const calls: string[] = []; + (window as any).__wakeLockCalls = calls; + + function createSentinel() { + const sentinel = { + released: false, + type: 'screen' as const, + _listeners: [] as Array<() => void>, + addEventListener(_event: string, cb: () => void) { + sentinel._listeners.push(cb); + }, + removeEventListener() {}, + release() { + sentinel.released = true; + for (const cb of sentinel._listeners) cb(); + return Promise.resolve(); + }, + onrelease: null, + dispatchEvent: () => true, + }; + return sentinel; + } + + Object.defineProperty(navigator, 'wakeLock', { + value: { + request: (_type: string) => { + calls.push('request'); + const sentinel = createSentinel(); + (window as any).__currentWakeLockSentinel = sentinel; + return Promise.resolve(sentinel); + }, + }, + configurable: true, + }); + }); + + await page.goto('/'); + await page.waitForSelector('#app'); + await page.click('#start-workout-btn'); + await page.waitForSelector('.workout-screen'); + + // Verify initial wake lock was requested + const callsAfterInit = await page.evaluate(() => (window as any).__wakeLockCalls.length); + expect(callsAfterInit).toBeGreaterThanOrEqual(1); + + // Simulate page becoming hidden then visible (as happens when switching apps on iOS). + // When the page goes hidden, the browser releases the wake lock sentinel automatically. + await page.evaluate(() => { + // Release the current sentinel (simulates browser behavior on hide) + const sentinel = (window as any).__currentWakeLockSentinel; + if (sentinel) sentinel.release(); + + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Simulate becoming visible again — should re-acquire + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Wait briefly for the async re-request + await page.waitForTimeout(100); + + const callsAfterReacquire = await page.evaluate(() => (window as any).__wakeLockCalls.length); + expect(callsAfterReacquire).toBeGreaterThanOrEqual(2); + }); + + test('does not re-acquire wake lock after it has been explicitly released', async ({ page }) => { + await page.addInitScript(() => { + const calls: string[] = []; + (window as any).__wakeLockCalls = calls; + + Object.defineProperty(navigator, 'wakeLock', { + value: { + request: (_type: string) => { + calls.push('request'); + const sentinel = { + released: false, + type: 'screen' as const, + _listeners: [] as Array<() => void>, + addEventListener(_event: string, cb: () => void) { + this._listeners.push(cb); + }, + removeEventListener() {}, + release() { + this.released = true; + for (const cb of this._listeners) cb(); + return Promise.resolve(); + }, + onrelease: null, + dispatchEvent: () => true, + }; + return Promise.resolve(sentinel); + }, + }, + configurable: true, + }); + }); + + await page.goto('/'); + await page.waitForSelector('#app'); + await page.click('#start-workout-btn'); + await page.waitForSelector('.workout-screen'); + + const callsBeforeBack = await page.evaluate(() => (window as any).__wakeLockCalls.length); + + // Navigate away (releases wake lock explicitly) + await page.click('#back-btn'); + await page.waitForSelector('h1'); + + // Simulate visibility change — should NOT re-acquire + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + await page.waitForTimeout(100); + + const callsAfterBack = await page.evaluate(() => (window as any).__wakeLockCalls.length); + expect(callsAfterBack).toBe(callsBeforeBack); + }); +}); diff --git a/workout-tracker/src/ui/wakelock.ts b/workout-tracker/src/ui/wakelock.ts index e0d3e9a..e95ef31 100644 --- a/workout-tracker/src/ui/wakelock.ts +++ b/workout-tracker/src/ui/wakelock.ts @@ -1,6 +1,8 @@ let wakeLock: WakeLockSentinel | null = null; +let wakeLockActive = false; export async function requestWakeLock(): Promise { + wakeLockActive = true; try { if ('wakeLock' in navigator) { wakeLock = await navigator.wakeLock.request('screen'); @@ -14,14 +16,14 @@ export async function requestWakeLock(): Promise { } export function releaseWakeLock(): void { + wakeLockActive = false; wakeLock?.release(); wakeLock = null; } // Re-acquire wake lock when page becomes visible again document.addEventListener('visibilitychange', async () => { - if (document.visibilityState === 'visible' && wakeLock === null) { - // Only re-request if we had one before (i.e., during active workout) - // The workout screen will call requestWakeLock() itself + if (document.visibilityState === 'visible' && wakeLock === null && wakeLockActive) { + await requestWakeLock(); } }); From bfe0554ad34ea0e3e0858137d1c8a3b64408221a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 22:08:43 +0000 Subject: [PATCH 2/2] fix: wait for IndexedDB write before reload in intersperse persistence test The test was flaky because it reloaded the page immediately after checking the intersperse checkbox, without waiting for the async putSettings() IndexedDB write to complete. Add a waitForFunction that reads directly from IndexedDB to confirm the write landed. https://claude.ai/code/session_01MgxBE65U2FSemXB1CKtdZF --- .../e2e/intersperse-accessories.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/workout-tracker/e2e/intersperse-accessories.spec.ts b/workout-tracker/e2e/intersperse-accessories.spec.ts index 8ba9299..ff030a4 100644 --- a/workout-tracker/e2e/intersperse-accessories.spec.ts +++ b/workout-tracker/e2e/intersperse-accessories.spec.ts @@ -71,6 +71,27 @@ test.describe('Intersperse Accessories', () => { // Wait for the setting to be saved to IndexedDB by confirming checkbox state await expect(page.locator('[data-testid="intersperse-checkbox"]')).toBeChecked(); + // Wait for the async IndexedDB write triggered by the change event to complete + await page.waitForFunction(async () => { + const { openDB } = await import('/src/db/database.ts' as any).catch(() => ({ openDB: null })); + // Fallback: read directly from IndexedDB + return new Promise((resolve) => { + const req = indexedDB.open('workout-tracker'); + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction('state', 'readonly'); + const store = tx.objectStore('state'); + const getReq = store.get('settings'); + getReq.onsuccess = () => { + const settings = getReq.result; + resolve(settings?.intersperseAccessories === true); + }; + getReq.onerror = () => resolve(false); + }; + req.onerror = () => resolve(false); + }); + }); + // Reload page await page.reload(); await page.waitForSelector('#app');