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>
167 lines
7.8 KiB
TypeScript
167 lines
7.8 KiB
TypeScript
/**
|
||
* 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*')
|
||
})
|
||
})
|