diff --git a/apps/pwa/e2e/calendar.spec.ts b/apps/pwa/e2e/calendar.spec.ts new file mode 100644 index 0000000..94ba8eb --- /dev/null +++ b/apps/pwa/e2e/calendar.spec.ts @@ -0,0 +1,161 @@ +/** + * 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. + const calendarGrid = page.locator('.sx-react-calendar-wrapper') + await expect(calendarGrid).toBeVisible() + }) + + test('EmptyState "Nothing here" is NOT present when events are seeded', async ({ page }) => { + // CalendarShell renders EmptyState when the events query succeeds with zero occurrences. + // With the seeded event, EmptyState must not appear. Use toHaveCount(0) for strict absence. + await expect(page.getByText('Nothing here')).toHaveCount(0) + }) + + 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*') + }) +})