/** * calendar.spec.ts — TEST-01 + TEST-02 * * Route-specific state assertions for /calendar (UI-SPEC Rules 4/5): * - Populated state: Schedule-X grid visible, EmptyState absent * - Error state: 'Couldn't load events' heading + Retry button ≥44px + no overflow * - Auth-bypass precondition: authed content reached via DEV_AUTH_BYPASS (no OIDC mock) * - SW-block precondition: navigator.serviceWorker.controller is null (no controlling SW) * * Requires the dev stack running with DEV_AUTH_BYPASS=true (see e2e/README.md). * global-setup seeds 'Seeded Test Event' on calendar_id=10 for user_id=1. * * Runs on both device profiles automatically (playwright.config.ts matrix): * iphone: iPhone 14 / WebKit / 390×844 * pixel: Pixel 7 / Chromium / 412×915 * * Run: * pnpm --filter @familysync/pwa test:e2e * pnpm --filter @familysync/pwa exec playwright test --project=pixel calendar.spec.ts */ import { test, expect } from '@playwright/test' // ── TEST-02 preconditions: DEV_AUTH_BYPASS + no SW controller ───────────────── test.describe('TEST-02 preconditions — auth bypass and SW block', () => { test('DEV_AUTH_BYPASS reached authed PWA without OIDC mock', async ({ page }) => { await page.goto('/calendar') // Wait for the authed content to appear — DEV_AUTH_BYPASS should resolve immediately // without Authelia redirect. The BottomTabBar nav landmark is only rendered after auth. const nav = page.getByRole('navigation', { name: 'Main navigation' }) await expect(nav).toBeVisible() // Confirm we are NOT on an external auth host (Authelia login page would redirect the URL) const url = new URL(page.url()) expect(url.hostname, `Expected to remain on localhost or 127.0.0.1, got: ${url.hostname}`).toMatch( /^(localhost|127\.0\.0\.1)$/, ) }) test('no service-worker controller (serviceWorkers: block enforced)', async ({ page }) => { await page.goto('/calendar') // serviceWorkers: 'block' in playwright.config.ts prevents SW registration. // navigator.serviceWorker.controller is null when no SW is controlling the page. // Note: navigator.serviceWorker may be undefined in some contexts (e.g. non-https), // but in the dev server context it is defined. Treat undefined as no-controller (safe). const controller = await page.evaluate(() => { if (typeof navigator === 'undefined') return null if (!('serviceWorker' in navigator)) return null return navigator.serviceWorker.controller }) expect(controller, 'Service worker controller should be null (serviceWorkers:block enforced)').toBeNull() }) }) // ── Rule 5: Populated state ─────────────────────────────────────────────────── test.describe('Rule 5 — populated calendar state', () => { test.beforeEach(async ({ page }) => { await page.goto('/calendar') // Wait for auth and Schedule-X to render before asserting await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible() }) test('Schedule-X calendar grid is visible after seeding', async ({ page }) => { // The Schedule-X React adapter emits a div.sx-react-calendar-wrapper. // Prefer a stable locator: the class name is documented in apps/pwa/src/styles/index.css. // No semantic role exists for the widget wrapper, so CSS class is the documented fallback. // NOTE: the wrapper renders on any successful auth — this proves the grid mounts, NOT that // the seed reached the UI. The DB→UI proof is the separate "seeded event is rendered" test. const calendarGrid = page.locator('.sx-react-calendar-wrapper') await expect(calendarGrid).toBeVisible() }) test('seeded event "Seeded Test Event" is rendered in the grid (DB→UI proof)', async ({ page }) => { // The one assertion that actually proves the seeded row flows DB → API → query → grid. // global-setup seeds a timed event titled 'Seeded Test Event' (noon today) on calendar 10. // Schedule-X renders the event with its title text inside the grid. If the seed broke, the // /api/events join regressed, or hydration dropped events, THIS fails (unlike a wrapper / // dead-EmptyState check, which would stay green). Deep-review BL-01. await expect(page.getByText('Seeded Test Event').first()).toBeVisible() }) test('no horizontal overflow on populated /calendar (Rule 2)', async ({ page }) => { const overflow = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })) expect( overflow.scrollWidth, `scrollWidth (${overflow.scrollWidth}) must be ≤ clientWidth (${overflow.clientWidth}) on populated /calendar`, ).toBeLessThanOrEqual(overflow.clientWidth) }) }) // ── Rule 5: Error state ─────────────────────────────────────────────────────── test.describe('Rule 5 — calendar error state (API mocked to 500)', () => { test('error heading + Retry button visible when /api/events returns 500', async ({ page }) => { // Register route BEFORE page.goto — the intercept must be in place before navigation // so the very first events request is caught (Pattern 5). await page.route('/api/events*', (route) => route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }), ) await page.goto('/calendar') // Wait for auth (DEV_AUTH_BYPASS) await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible() // eventsQuery has retry:2 so Playwright may need to wait for all retries before // the error branch renders. Use default Playwright timeout. const errorHeading = page.getByRole('heading', { name: "Couldn't load events" }) await expect(errorHeading).toBeVisible() const retryBtn = page.getByRole('button', { name: 'Retry' }) await expect(retryBtn).toBeVisible() // Unroute so the mock does not leak to subsequent tests (T-07-11) await page.unroute('/api/events*') }) test('Retry button meets 44px touch-target minimum in error state (Rule 1)', async ({ page, }) => { await page.route('/api/events*', (route) => route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }), ) await page.goto('/calendar') await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible() const retryBtn = page.getByRole('button', { name: 'Retry' }) await expect(retryBtn).toBeVisible() const box = await retryBtn.boundingBox() expect(box, 'Retry button bounding box must not be null').not.toBeNull() expect(box!.height, 'Retry button height must be ≥ 44px (Rule 1)').toBeGreaterThanOrEqual(44) await page.unroute('/api/events*') }) test('no horizontal overflow in error state (Rule 2)', async ({ page }) => { await page.route('/api/events*', (route) => route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }), ) await page.goto('/calendar') await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible() // Wait for error heading to confirm the error branch has rendered await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVisible() const overflow = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })) expect( overflow.scrollWidth, `scrollWidth (${overflow.scrollWidth}) must be ≤ clientWidth (${overflow.clientWidth}) in error state`, ).toBeLessThanOrEqual(overflow.clientWidth) await page.unroute('/api/events*') }) })