import { test, expect } from '@playwright/test'; test.describe('Offline PWA Functionality', () => { test('installs SW, works offline, writes mood/entries, persists on reconnect', async ({ page, context }) => { // Step 1: Navigate to app and ensure it loads await page.goto('/'); await expect(page.getByRole('heading', { level: 1 })).toHaveText(/GlowTrack/i); // Step 2: Wait for service worker to install and become ready // Check that service worker is registered const swRegistration = await page.evaluate(async () => { if ('serviceWorker' in navigator) { // Wait for existing registration or new registration let registration = await navigator.serviceWorker.getRegistration(); if (!registration) { // Wait a bit for auto-registration to happen await new Promise(resolve => setTimeout(resolve, 1000)); registration = await navigator.serviceWorker.getRegistration(); } if (registration) { // Wait for service worker to be ready await navigator.serviceWorker.ready; return { scope: registration.scope, active: !!registration.active, installing: !!registration.installing, waiting: !!registration.waiting }; } } return null; }); // Verify service worker is registered and active expect(swRegistration).toBeTruthy(); expect(swRegistration?.active).toBe(true); // Step 3: Add some initial data while online const firstTile = page.locator('[data-testid="day-tile"]').first(); await expect(firstTile).toBeVisible(); // Click on tile to open day editor await firstTile.click(); // Set mood if controls are available const hueInput = page.locator('[data-testid="mood-hue-slider"]'); if (await hueInput.isVisible()) { await hueInput.fill('240'); // Blue mood for initial state } // Add a positive habit const addPositive = page.locator('[data-testid="add-positive-habit"]'); if (await addPositive.isVisible()) { await addPositive.click(); const habitInput = page.locator('[data-testid="habit-input"]'); if (await habitInput.isVisible()) { await habitInput.fill('Online Exercise'); await page.keyboard.press('Enter'); } } // Close day editor const closeButton = page.locator('[data-testid="close-day-editor"]'); if (await closeButton.isVisible()) { await closeButton.click(); } else { await page.keyboard.press('Escape'); } // Wait for any saves to complete await page.waitForTimeout(500); // Step 4: Capture initial state for comparison const initialState = await captureGridState(page); // Step 5: Go offline await context.setOffline(true); // Step 6: Verify app still loads when offline await page.reload(); await expect(page.getByRole('heading', { level: 1 })).toHaveText(/GlowTrack/i); // Verify the grid is still visible and contains our data await expect(firstTile).toBeVisible(); // Step 7: Modify data while offline await firstTile.click(); // Change mood while offline const hueInputOffline = page.locator('[data-testid="mood-hue-slider"]'); if (await hueInputOffline.isVisible()) { await hueInputOffline.fill('60'); // Yellow mood for offline state } // Add another habit while offline const addPositiveOffline = page.locator('[data-testid="add-positive-habit"]'); if (await addPositiveOffline.isVisible()) { await addPositiveOffline.click(); const habitInputOffline = page.locator('[data-testid="habit-input"]'); if (await habitInputOffline.isVisible()) { await habitInputOffline.fill('Offline Reading'); await page.keyboard.press('Enter'); } } // Close day editor const closeButtonOffline = page.locator('[data-testid="close-day-editor"]'); if (await closeButtonOffline.isVisible()) { await closeButtonOffline.click(); } else { await page.keyboard.press('Escape'); } // Wait for offline saves to complete await page.waitForTimeout(500); // Step 8: Capture offline state const offlineState = await captureGridState(page); // Verify that offline changes were applied (state should be different) expect(offlineState).not.toEqual(initialState); // Step 9: Go back online await context.setOffline(false); // Step 10: Reload and verify data persistence await page.reload(); await expect(page.getByRole('heading', { level: 1 })).toHaveText(/GlowTrack/i); // Wait for any sync operations to complete await page.waitForTimeout(1000); // Step 11: Verify all changes persisted after reconnection const reconnectedState = await captureGridState(page); // The reconnected state should match the offline state (data persisted) expect(reconnectedState).toEqual(offlineState); // Step 12: Verify we can still make changes after reconnection await firstTile.click(); // Add one more habit to verify full functionality is restored const addPositiveOnline = page.locator('[data-testid="add-positive-habit"]'); if (await addPositiveOnline.isVisible()) { await addPositiveOnline.click(); const habitInputOnline = page.locator('[data-testid="habit-input"]'); if (await habitInputOnline.isVisible()) { await habitInputOnline.fill('Back Online Meditation'); await page.keyboard.press('Enter'); } } // Close day editor const closeButtonFinal = page.locator('[data-testid="close-day-editor"]'); if (await closeButtonFinal.isVisible()) { await closeButtonFinal.click(); } else { await page.keyboard.press('Escape'); } // Final verification that changes are still being saved await page.waitForTimeout(500); const finalState = await captureGridState(page); expect(finalState).not.toEqual(reconnectedState); }); test('service worker caches essential resources for offline use', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { level: 1 })).toHaveText(/GlowTrack/i); // Wait for service worker to be ready await page.evaluate(async () => { if ('serviceWorker' in navigator) { await navigator.serviceWorker.ready; } }); // Check that essential resources are cached const cacheStatus = await page.evaluate(async () => { if ('caches' in window) { const cacheNames = await caches.keys(); let cachedAssets: string[] = []; for (const cacheName of cacheNames) { if (cacheName.includes('gt-cache')) { const cache = await caches.open(cacheName); const requests = await cache.keys(); cachedAssets = requests.map(req => req.url); break; } } return { cacheNames, cachedAssets: cachedAssets.map(url => new URL(url).pathname), hasCaches: cachedAssets.length > 0 }; } return { cacheNames: [], cachedAssets: [], hasCaches: false }; }); // Verify that service worker has cached some resources expect(cacheStatus.hasCaches).toBe(true); expect(cacheStatus.cacheNames.length).toBeGreaterThan(0); // Check for essential assets that should be cached const essentialAssets = cacheStatus.cachedAssets; expect(essentialAssets.some(asset => asset.includes('app') || asset === '/')).toBe(true); }); }); // Helper function to capture grid state for comparison async function captureGridState(page: import('@playwright/test').Page) { const tiles = page.locator('[data-testid="day-tile"]'); const count = await tiles.count(); const data: Array> = []; for (let i = 0; i < Math.min(count, 10); i++) { // Limit to first 10 tiles for performance const tile = tiles.nth(i); const handle = await tile.elementHandle(); if (!handle) continue; const tileData = await page.evaluate((el) => { const attr = (name: string) => el.getAttribute(name); const selCount = (sel: string) => el.querySelectorAll(sel).length; return { index: i, date: attr('data-date'), netScore: attr('data-net-score'), moodHue: attr('data-mood-hue'), positiveCount: selCount('[data-testid="positive-glyphs"] [data-testid="tick"]'), negativeCount: selCount('[data-testid="negative-glyphs"] [data-testid="dot"]'), hasContent: !!attr('data-has-content') }; }, handle); data.push(tileData); } return data; }