Files
familysync/apps/pwa/e2e/calendar.spec.ts
T
Lucas BergerandClaude Opus 4.8 53c3ca56b8 fix(07-04): make calendar populated-state test non-vacuous (BL-01) + deterministic seed window (BL-02)
Deep review found the calendar 'populated state' assertions were vacuous:
- getByText('Nothing here').toHaveCount(0) targeted CalendarShell's EmptyState,
  which CalendarShell NEVER renders (success branch always mounts ScheduleXCalendar;
  EmptyState.tsx is dead code, imported by nothing). The check was permanently green
  regardless of the seed — a regression dropping all events would have shipped green.
- .sx-react-calendar-wrapper renders on any successful auth, with or without events,
  so it never proved the seed reached the UI.

Replaced the dead-EmptyState check with a real DB→UI proof: assert the seeded event
title 'Seeded Test Event' is rendered in the grid. Verified non-vacuous — passes with
the seed on both profiles; with /api/events mocked to [] the title is absent (would fail).

BL-02: the seed anchored the event at now+24h. Both phone profiles render the
month-agenda view of the CURRENT month, so on a month's last day 'tomorrow' falls into
the next month and vanishes from the grid, making the new visibility assertion date-fragile.
Re-anchored to noon-today (UTC) — always today's local date, always in the current-month view.

Verified: full 58-test suite passes both profiles; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:38:41 -04:00

167 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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*')
})
})