Files
familysync/apps/pwa/e2e/layout.spec.ts
T
Lucas Berger 52e14a88db feat(07-03): layout.spec.ts — UI-SPEC Rules 1-4 + harness self-validation
- Rule 1: boundingBox assertions for BottomTabBar Calendar/Lists tabs (≥44px),
  PhoneNav settings button (≥44px), New Event FAB (≥56px) on both profiles
- Rule 2: scrollWidth ≤ clientWidth on /calendar and /lists
- Rule 3: BottomTabBar in-viewport (bottom edge ≤ viewport height), PhoneNav visible
- Rule 4: navigation landmark locatable by role+name (getByRole with accessible name)
- Self-validation: addStyleTag injection proves Rule 1 tracks geometry (20px height <44),
  proves Rule 2 detects overflow (2000px body width); both recover after removal
- Runs on iphone (WebKit/390px) and pixel (Chromium/412px) profiles; 30 tests pass
2026-06-11 02:00:46 -04:00

262 lines
11 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.
/**
* layout.spec.ts — TEST-01
*
* Cross-route structural quality-bar assertions (UI-SPEC Rules 1-4):
* Rule 1: Touch-target minimum ≥44×44px (FAB ≥56×56px)
* Rule 2: No horizontal overflow (scrollWidth ≤ clientWidth)
* Rule 3: Critical elements visible and in-viewport on initial load
* Rule 4: Accessible names on all interactive elements (role+name locators)
*
* Runs on both device profiles automatically (playwright.config.ts matrix):
* iphone: iPhone 14 / WebKit / 390×844
* pixel: Pixel 7 / Chromium / 412×915
*
* STRICT-MODE NOTE:
* On mobile viewports (≤767px), AppNav renders PhoneNav as a <header> element
* (NOT a nav landmark) — it does NOT expose a navigation landmark. BottomTabBar
* renders the sole <nav aria-label="Main navigation"> on mobile. There is no
* strict-mode collision on these profiles.
* The DesktopNav <nav aria-label="Main navigation"> is only rendered at ≥768px
* and is not visible on either test profile.
*
* Run:
* pnpm --filter @familysync/pwa test:e2e
* pnpm --filter @familysync/pwa exec playwright test --project=pixel layout.spec.ts
*/
import { test, expect } from '@playwright/test'
// ── Rule 1 + Rule 3 + Rule 4: BottomTabBar tap targets, visibility, accessible names ──
test.describe('Rule 1/3/4 — BottomTabBar tap targets and in-viewport position', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/calendar')
})
test('BottomTabBar navigation landmark is visible (Rule 4 — accessible name)', async ({
page,
}) => {
// On mobile profiles the sole navigation landmark is the BottomTabBar nav.
// getByRole succeeds ↔ accessible name exists — doubles as Rule 4 gate.
await expect(
page.getByRole('navigation', { name: 'Main navigation' }),
).toBeVisible()
})
test('Calendar tab meets 44×44px touch-target minimum (Rule 1)', async ({ page }) => {
// Scope to the navigation landmark to stay robust if desktop nav ever appears.
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const calTab = nav.getByRole('link', { name: 'Calendar' })
const box = await calTab.boundingBox()
expect(box, 'Calendar tab bounding box must not be null').not.toBeNull()
expect(box!.width, 'Calendar tab width ≥ 44px').toBeGreaterThanOrEqual(44)
expect(box!.height, 'Calendar tab height ≥ 44px').toBeGreaterThanOrEqual(44)
})
test('Lists tab meets 44×44px touch-target minimum (Rule 1)', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const listsTab = nav.getByRole('link', { name: 'Lists' })
const box = await listsTab.boundingBox()
expect(box, 'Lists tab bounding box must not be null').not.toBeNull()
expect(box!.width, 'Lists tab width ≥ 44px').toBeGreaterThanOrEqual(44)
expect(box!.height, 'Lists tab height ≥ 44px').toBeGreaterThanOrEqual(44)
})
test('BottomTabBar is fully in-viewport (Rule 3 — safe-area-inset)', async ({ page }) => {
// The bar uses env(safe-area-inset-bottom, 0px). In emulation there is no
// safe-area-inset, so the bar's bottom edge must be ≤ viewport height.
const nav = page.getByRole('navigation', { name: 'Main navigation' })
await expect(nav).toBeVisible()
const box = await nav.boundingBox()
expect(box, 'BottomTabBar bounding box must not be null').not.toBeNull()
const viewportHeight = page.viewportSize()!.height
expect(
box!.y + box!.height,
`BottomTabBar bottom edge (${box!.y + box!.height}) must be ≤ viewport height (${viewportHeight})`,
).toBeLessThanOrEqual(viewportHeight)
})
test('PhoneNav header is visible (Rule 3)', async ({ page }) => {
// PhoneNav renders a <header> with exact text "FamilySync" (not a nav landmark).
// Use exact:true to avoid matching the "Install FamilySync" install-prompt text.
await expect(page.getByText('FamilySync', { exact: true })).toBeVisible()
})
test('PhoneNav settings button meets 44×44px touch-target minimum (Rule 1)', async ({
page,
}) => {
// aria-label: "${displayName} — open settings" (AppNav.tsx PhoneNav)
const settingsBtn = page.getByRole('button', { name: /open settings/i })
const box = await settingsBtn.boundingBox()
expect(box, 'Settings button bounding box must not be null').not.toBeNull()
expect(box!.width, 'Settings button width ≥ 44px').toBeGreaterThanOrEqual(44)
expect(box!.height, 'Settings button height ≥ 44px').toBeGreaterThanOrEqual(44)
})
test('New Event FAB meets 56×56px touch-target minimum (Rule 1)', async ({ page }) => {
// Phone-only FAB — aria-label="New Event", fixed 56×56px (CalendarShell.tsx)
const fab = page.getByRole('button', { name: 'New Event' })
const box = await fab.boundingBox()
expect(box, 'New Event FAB bounding box must not be null').not.toBeNull()
expect(box!.width, 'New Event FAB width ≥ 56px').toBeGreaterThanOrEqual(56)
expect(box!.height, 'New Event FAB height ≥ 56px').toBeGreaterThanOrEqual(56)
})
})
// ── Rule 1/3/4 repeated on /lists ──
test.describe('Rule 1/3/4 — BottomTabBar on /lists', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/lists')
})
test('BottomTabBar navigation landmark is visible on /lists (Rule 4)', async ({ page }) => {
await expect(
page.getByRole('navigation', { name: 'Main navigation' }),
).toBeVisible()
})
test('Calendar tab meets 44×44px on /lists (Rule 1)', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const calTab = nav.getByRole('link', { name: 'Calendar' })
const box = await calTab.boundingBox()
expect(box).not.toBeNull()
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
})
test('Lists tab meets 44×44px on /lists (Rule 1)', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const listsTab = nav.getByRole('link', { name: 'Lists' })
const box = await listsTab.boundingBox()
expect(box).not.toBeNull()
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
})
test('BottomTabBar is fully in-viewport on /lists (Rule 3)', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
await expect(nav).toBeVisible()
const box = await nav.boundingBox()
expect(box).not.toBeNull()
const viewportHeight = page.viewportSize()!.height
expect(box!.y + box!.height).toBeLessThanOrEqual(viewportHeight)
})
})
// ── Rule 2: No horizontal overflow ──
test.describe('Rule 2 — No horizontal overflow', () => {
test('no overflow on /calendar', async ({ page }) => {
await page.goto('/calendar')
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 /calendar`,
).toBeLessThanOrEqual(overflow.clientWidth)
})
test('no overflow on /lists', async ({ page }) => {
await page.goto('/lists')
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 /lists`,
).toBeLessThanOrEqual(overflow.clientWidth)
})
})
// ── Harness self-validation — injected-defect proofs (TEST-01 acceptance bar) ──
//
// Each test is a PASSING test that proves the assertion would have failed under a
// deliberately injected defect and recovers once the injection is removed.
// The suite stays green; the proofs demonstrate the harness measures rendered
// geometry rather than CSS source values.
test.describe('harness self-validation — injected defects', () => {
test('Rule 1 proof: tap-target assertion fails under 20px injection, passes after removal', async ({
page,
}) => {
await page.goto('/calendar')
// Confirm the nav is visible before injection
const nav = page.getByRole('navigation', { name: 'Main navigation' })
await expect(nav).toBeVisible()
// INJECT: force BottomTabBar links to 20px height — simulates a broken tap target
const styleHandle = await page.addStyleTag({
content:
'nav[aria-label="Main navigation"] a { min-height: 20px !important; height: 20px !important; max-height: 20px !important; }',
})
// Measure WHILE injected — must be < 44px to prove the assertion tracks geometry
const calTab = nav.getByRole('link', { name: 'Calendar' })
const boxWithDefect = await calTab.boundingBox()
expect(
boxWithDefect,
'Calendar tab bounding box must not be null even with defect injected',
).not.toBeNull()
expect(
boxWithDefect!.height,
'Height must be < 44px with 20px injection (proving measurement tracks rendered geometry)',
).toBeLessThan(44)
// REMOVE the injected style by navigating (page.reload drops inline style tags)
// then re-measure — must be ≥ 44px again
await styleHandle.evaluate((el) => el.remove())
const boxAfterRemoval = await calTab.boundingBox()
expect(
boxAfterRemoval,
'Calendar tab bounding box must not be null after defect removal',
).not.toBeNull()
expect(
boxAfterRemoval!.height,
'Height must be ≥ 44px after defect style is removed',
).toBeGreaterThanOrEqual(44)
})
test('Rule 2 proof: overflow assertion fails under 2000px injection, passes after removal', async ({
page,
}) => {
await page.goto('/calendar')
// Confirm baseline — no overflow before injection
const baseOverflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(baseOverflow.scrollWidth).toBeLessThanOrEqual(baseOverflow.clientWidth)
// INJECT: force body width to 2000px — simulates Schedule-X overflow defect
const styleHandle = await page.addStyleTag({
content: 'body { width: 2000px !important; }',
})
// Measure WHILE injected — scrollWidth must exceed clientWidth
const overflowWithDefect = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(
overflowWithDefect.scrollWidth,
`scrollWidth (${overflowWithDefect.scrollWidth}) must be > clientWidth (${overflowWithDefect.clientWidth}) with 2000px injection (proving overflow detection works)`,
).toBeGreaterThan(overflowWithDefect.clientWidth)
// REMOVE the injected style — overflow must clear
await styleHandle.evaluate((el) => el.remove())
const overflowAfterRemoval = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(
overflowAfterRemoval.scrollWidth,
'scrollWidth must be ≤ clientWidth after 2000px injection is removed',
).toBeLessThanOrEqual(overflowAfterRemoval.clientWidth)
})
})