diff --git a/apps/web/tests/e2e/smoke.offline.spec.ts b/apps/web/tests/e2e/smoke.offline.spec.ts new file mode 100644 index 0000000..2bbdbad --- /dev/null +++ b/apps/web/tests/e2e/smoke.offline.spec.ts @@ -0,0 +1,242 @@ +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; +} diff --git a/specs/001-glowtrack-a-mood/tasks.md b/specs/001-glowtrack-a-mood/tasks.md index 692c378..2bd8ccf 100644 --- a/specs/001-glowtrack-a-mood/tasks.md +++ b/specs/001-glowtrack-a-mood/tasks.md @@ -103,7 +103,7 @@ Integration scenarios from quickstart.md → e2e smoke tests [P] - Steps: render month → export PNG (toBlob) → file within size/time budget - Dependencies: T007, T005 -- [ ] T015 [P] E2E: offline PWA works +- [X] T015 [P] E2E: offline PWA works - Create /home/jawz/Development/Projects/GlowTrack/apps/web/tests/e2e/smoke.offline.spec.ts - Steps: install SW → go offline → app loads and writes mood/entries; on reconnect, state persists - Dependencies: T007, T008, T005