Files
familysync/apps/pwa/e2e/calendar.spec.ts
T

179 lines
8.7 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 registration (serviceWorkers: block enforced)', async ({ page }) => {
await page.goto('/calendar')
// serviceWorkers: 'block' in playwright.config.ts prevents SW registration.
//
// WR-07: asserting `navigator.serviceWorker.controller === null` is near-vacuous —
// (a) on WebKit over plain http://localhost, `serviceWorker` is often *absent* from
// navigator (secure-context strictness), so the old guard returned null and the
// assertion passed without ever proving the block worked; and
// (b) `controller` is null on a first uncontrolled load even when SW *is* available,
// regardless of the block setting.
// Instead probe getRegistration() — when SW is available and `block` is in effect, no
// registration exists, so it resolves to undefined. Where `serviceWorker` is absent
// entirely (WebKit/http), skip rather than let an unavailable API masquerade as a pass.
const swAvailable = await page.evaluate(
() => typeof navigator !== 'undefined' && 'serviceWorker' in navigator,
)
test.skip(
!swAvailable,
'navigator.serviceWorker is unavailable in this context (e.g. WebKit over http://localhost) — block is unobservable here',
)
const registration = await page.evaluate(() => navigator.serviceWorker.getRegistration())
expect(
registration,
'No service worker should be registered (serviceWorkers:block enforced)',
).toBeUndefined()
})
})
// ── 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()
// No manual unroute (WR-05): Playwright gives each test a fresh page/context, so route
// handlers do not leak across tests. A trailing unroute also never runs if an `expect`
// above throws — it was misleading "cleanup" that guaranteed nothing.
})
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)
// No manual unroute (WR-05): per-test context isolation handles route cleanup.
})
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)
// No manual unroute (WR-05): per-test context isolation handles route cleanup.
})
})