Files
familysync/apps/pwa/e2e/calendar.spec.ts
T
Lucas Berger 17b625b6fe feat(07-04): add calendar.spec.ts — populated, error, auth-bypass + SW precondition tests
- Populated state: asserts .sx-react-calendar-wrapper visible + 'Nothing here' absent
- Error state: mocks /api/events* to 500, asserts 'Couldn't load events' heading + Retry ≥44px + no overflow; unroutes after
- TEST-02 preconditions: asserts nav landmark visible (authed reach) + URL on localhost (no Authelia redirect) + navigator.serviceWorker.controller null (SW block)
- No absolute URLs; route mocks paired with unroute (T-07-11)
2026-06-11 02:11:36 -04:00

162 lines
7.3 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.
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*')
})
})