style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+9 -7
View File
@@ -11,6 +11,7 @@ Playwright test harness for the FamilySync PWA — mobile-emulated (iPhone 14/We
See `docs/deployment.md` under "Running locally (host-side, no Docker)" for the canonical bring-up command.
The stack must include:
- API on `:3000` started with `DEV_AUTH_BYPASS=true` (see Security Guardrail below)
- PWA dev server on `:5173` (`pnpm --filter @familysync/pwa dev`)
- Dev MariaDB on `:3306` (exposed via `docker-compose.dev.yml`)
@@ -52,14 +53,14 @@ pnpm --filter @familysync/pwa test:e2e:ui
The harness reads these from the environment. DB credentials are env-only — never hardcoded in seed scripts or specs.
| Var | Default | Purpose |
|-----|---------|---------|
| Var | Default | Purpose |
| --------------------- | ----------------------- | --------------------------------------------------------------- |
| `PLAYWRIGHT_BASE_URL` | `http://localhost:5173` | Base URL for all spec navigation and the /health readiness poll |
| `DB_HOST` | `127.0.0.1` | MariaDB host for the global-setup seed script |
| `DB_PORT` | `3306` | MariaDB port |
| `DB_USER` | `familysync` | MariaDB user |
| `DB_PASSWORD` | *(empty)* | MariaDB password — set in environment or `.env` |
| `DB_NAME` | `familysync` | MariaDB database name |
| `DB_HOST` | `127.0.0.1` | MariaDB host for the global-setup seed script |
| `DB_PORT` | `3306` | MariaDB port |
| `DB_USER` | `familysync` | MariaDB user |
| `DB_PASSWORD` | _(empty)_ | MariaDB password — set in environment or `.env` |
| `DB_NAME` | `familysync` | MariaDB database name |
Set `DB_PASSWORD` (and other non-default values) via the shell or the repo root `.env` file before running. The `.env` file is gitignored — never commit credentials.
@@ -106,6 +107,7 @@ This seeding is idempotent — two consecutive runs leave the same row counts, n
## CI (Phase 8)
Phase 8 (Gitea CI) runs these specs unchanged as a PR UI-regression step. The CI workflow owns:
- Bringing up the dev stack (compose) with `DEV_AUTH_BYPASS=true`
- Waiting for the MariaDB health check before starting the API
- Setting `PLAYWRIGHT_BASE_URL`, `DB_*`, and `DEV_AUTH_BYPASS=true` env vars in the runner environment (the global-setup guard requires `DEV_AUTH_BYPASS=true` in the Playwright process, not only the API's)
+58 -57
View File
@@ -18,28 +18,29 @@
* pnpm --filter @familysync/pwa test:e2e
* pnpm --filter @familysync/pwa exec playwright test --project=pixel calendar.spec.ts
*/
import { test, expect } from '@playwright/test'
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')
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()
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)$/,
)
})
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')
await page.goto('/calendar');
// serviceWorkers: 'block' in playwright.config.ts prevents SW registration.
//
@@ -54,28 +55,28 @@ test.describe('TEST-02 preconditions — auth bypass and SW block', () => {
// 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())
const registration = await page.evaluate(() => navigator.serviceWorker.getRegistration());
expect(
registration,
'No service worker should be registered (serviceWorkers:block enforced)',
).toBeUndefined()
})
})
).toBeUndefined();
});
});
// ── Rule 5: Populated state ───────────────────────────────────────────────────
test.describe('Rule 5 — populated calendar state', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/calendar')
await page.goto('/calendar');
// Wait for auth and Schedule-X to render before asserting
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible()
})
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.
@@ -83,30 +84,32 @@ test.describe('Rule 5 — populated calendar state', () => {
// 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()
})
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 }) => {
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()
})
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)
})
})
).toBeLessThanOrEqual(overflow.clientWidth);
});
});
// ── Rule 5: Error state ───────────────────────────────────────────────────────
@@ -116,63 +119,61 @@ test.describe('Rule 5 — calendar error state (API mocked to 500)', () => {
// 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')
await page.goto('/calendar');
// Wait for auth (DEV_AUTH_BYPASS)
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible()
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 errorHeading = page.getByRole('heading', { name: "Couldn't load events" });
await expect(errorHeading).toBeVisible();
const retryBtn = page.getByRole('button', { name: 'Retry' })
await expect(retryBtn).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,
}) => {
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()
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 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)
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()
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()
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)
).toBeLessThanOrEqual(overflow.clientWidth);
// No manual unroute (WR-05): per-test context isolation handles route cleanup.
})
})
});
});
+40 -34
View File
@@ -20,7 +20,7 @@
* pnpm --filter @familysync/pwa test:e2e
*/
import mysql from 'mysql2/promise'
import mysql from 'mysql2/promise';
export default async function globalSetup(): Promise<void> {
// ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ─────
@@ -34,42 +34,42 @@ export default async function globalSetup(): Promise<void> {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'global-setup refused: NODE_ENV=production. The E2E seed TRUNCATEs tables and must never run against production.',
)
);
}
if (process.env.DEV_AUTH_BYPASS !== 'true') {
throw new Error(
'global-setup refused: DEV_AUTH_BYPASS is not "true". The harness only runs against a dev-bypass stack; ' +
'refusing to TRUNCATE/seed an unconfirmed database. Export DEV_AUTH_BYPASS=true (and point DB_* at the dev DB) to proceed.',
)
);
}
// ── Step 1: Readiness gate (D-08) ───────────────────────────────────────────
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'
const deadline = Date.now() + 60_000
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173';
const deadline = Date.now() + 60_000;
// Use an explicit success flag (WR-02): inferring success from `Date.now() >= deadline`
// after the loop can misreport a success that arrived in the final second as a timeout,
// because the `await fetch` itself can push the clock past the deadline before the
// post-loop check runs.
let ready = false
let ready = false;
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseURL}/health`)
const res = await fetch(`${baseURL}/health`);
if (res.ok) {
ready = true
break
ready = true;
break;
}
} catch {
// ECONNREFUSED or network error — stack not ready yet, keep polling
}
await new Promise<void>((r) => setTimeout(r, 1_000))
await new Promise<void>((r) => setTimeout(r, 1_000));
}
if (!ready) {
throw new Error(
`health check never returned 200 at ${baseURL}/health — is the dev stack up?\n` +
`Ensure the API is running with DEV_AUTH_BYPASS=true and the Vite dev server is on ${baseURL}.`,
)
);
}
// ── Step 1b: DEV_AUTH_BYPASS reachability gate (WR-01) ──────────────────────
@@ -79,14 +79,14 @@ export default async function globalSetup(): Promise<void> {
// API fails loudly IN SETUP with a clear message instead of ~40 confusing spec failures.
// redirect:'manual' surfaces the Authelia redirect as an opaque/3xx response instead of
// silently following it.
const meRes = await fetch(`${baseURL}/api/me`, { redirect: 'manual' })
const meRes = await fetch(`${baseURL}/api/me`, { redirect: 'manual' });
if (!meRes.ok) {
throw new Error(
`/api/me did not return 200 (got ${meRes.status} ${meRes.type}) at ${baseURL}/api/me — ` +
`the API is reachable but DEV_AUTH_BYPASS is almost certainly NOT set in the API process.\n` +
`A 3xx/opaqueredirect here means /api/me is redirecting to Authelia. ` +
`Restart the API with DEV_AUTH_BYPASS=true so it serves Dev User 1 without OIDC.`,
)
);
}
// ── Step 2: Reset-and-seed (D-06 deterministic, D-07 in globalSetup) ────────
@@ -98,23 +98,23 @@ export default async function globalSetup(): Promise<void> {
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
})
});
try {
// Disable FK checks so TRUNCATE order is unconstrained
await conn.execute('SET FOREIGN_KEY_CHECKS=0')
await conn.execute('TRUNCATE TABLE list_items')
await conn.execute('TRUNCATE TABLE list_shares')
await conn.execute('TRUNCATE TABLE lists')
await conn.execute('TRUNCATE TABLE calendar_events')
await conn.execute('SET FOREIGN_KEY_CHECKS=1')
await conn.execute('SET FOREIGN_KEY_CHECKS=0');
await conn.execute('TRUNCATE TABLE list_items');
await conn.execute('TRUNCATE TABLE list_shares');
await conn.execute('TRUNCATE TABLE lists');
await conn.execute('TRUNCATE TABLE calendar_events');
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
// CI guard (Pitfall 4): ensure calendar row id=10 exists before inserting events.
// INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB).
await conn.execute(
`INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared)
VALUES (10, 1, 'https://caldav.fastmail.com/dav/calendars/e2e/', 'FamilySync', '#4A90D9', true)`,
)
);
// Seed one timed (not all-day) calendar event on shared calendar id=10.
// Anchor to NOON TODAY (UTC) — deliberately NOT "tomorrow" (deep-review BL-02):
@@ -123,53 +123,59 @@ export default async function globalSetup(): Promise<void> {
// and disappears from the rendered grid, making any "seeded event is visible"
// assertion date-fragile. Noon-today lands on today's local calendar date in every
// project timezone and is always inside the current-month view.
const uid = 'e2e-seed-event-001'
const _now = new Date()
const uid = 'e2e-seed-event-001';
const _now = new Date();
const futureStart = new Date(
Date.UTC(_now.getUTCFullYear(), _now.getUTCMonth(), _now.getUTCDate(), 12, 0, 0),
)
);
// MariaDB TIMESTAMP requires 'YYYY-MM-DD HH:MM:SS' format, not ISO 8601 with 'T'.
const futureStartUtc = futureStart
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, '')
.replace(/\.\d+Z$/, '');
const rawVevent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//FamilySync E2E//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTART:${futureStart.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`,
`DTEND:${new Date(futureStart.getTime() + 60 * 60 * 1000).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`,
`DTSTART:${futureStart
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d+Z$/, 'Z')}`,
`DTEND:${new Date(futureStart.getTime() + 60 * 60 * 1000)
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d+Z$/, 'Z')}`,
'SUMMARY:Seeded Test Event',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')
].join('\r\n');
await conn.execute(
`INSERT INTO calendar_events
(calendar_id, uid, etag, raw_vevent, title, dtstart_utc, all_day, has_rrule)
VALUES (10, ?, 'e2e-etag-001', ?, 'Seeded Test Event', ?, false, false)`,
[uid, rawVevent, futureStartUtc],
)
);
// Seed one shared list owned by user 1 (D-05 populated half).
// belt-and-suspenders: seed BOTH owner_id=1 AND a list_shares row for user_id=1
// so /api/lists returns the list regardless of whether it filters by owner or by share.
const [listResult] = (await conn.execute(
`INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`,
)) as mysql.ResultSetHeader[]
const listId = listResult.insertId
)) as mysql.ResultSetHeader[];
const listId = listResult.insertId;
await conn.execute(`INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)`, [listId])
await conn.execute(`INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)`, [listId]);
// Two active items with fractional-indexing rank strings — appear in the active section.
// 'Milk' and 'Eggs' are the stable anchor texts that lists.spec.ts asserts on.
await conn.execute(
`INSERT INTO list_items (list_id, text, checked, rank) VALUES (?, 'Milk', false, 'a0'), (?, 'Eggs', false, 'a1')`,
[listId, listId],
)
);
} finally {
await conn.end()
await conn.end();
}
}
+101 -107
View File
@@ -23,153 +23,147 @@
* pnpm --filter @familysync/pwa test:e2e
* pnpm --filter @familysync/pwa exec playwright test --project=pixel layout.spec.ts
*/
import { test, expect } from '@playwright/test'
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')
})
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()
})
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)
})
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)
})
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
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)
})
).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()
})
await expect(page.getByText('FamilySync', { exact: true })).toBeVisible();
});
test('PhoneNav settings button meets 44×44px touch-target minimum (Rule 1)', async ({
page,
}) => {
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)
})
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)
})
})
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')
})
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()
})
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)
})
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)
})
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)
})
})
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')
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)
})
).toBeLessThanOrEqual(overflow.clientWidth);
});
test('no overflow on /lists', async ({ page }) => {
await page.goto('/lists')
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)
})
})
).toBeLessThanOrEqual(overflow.clientWidth);
});
});
// ── Harness self-validation — injected-defect proofs (TEST-01 acceptance bar) ──
//
@@ -182,81 +176,81 @@ 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')
await page.goto('/calendar');
// Confirm the nav is visible before injection
const nav = page.getByRole('navigation', { name: 'Main navigation' })
await expect(nav).toBeVisible()
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()
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()
).not.toBeNull();
expect(
boxWithDefect!.height,
'Height must be < 44px with 20px injection (proving measurement tracks rendered geometry)',
).toBeLessThan(44)
).toBeLessThan(44);
// REMOVE the injected style by deleting the <style> element via evaluate
// (styleHandle.evaluate(el => el.remove()) — no page reload), then re-measure — must be ≥ 44px again
await styleHandle.evaluate((el) => (el as Element).remove())
const boxAfterRemoval = await calTab.boundingBox()
await styleHandle.evaluate((el) => (el as Element).remove());
const boxAfterRemoval = await calTab.boundingBox();
expect(
boxAfterRemoval,
'Calendar tab bounding box must not be null after defect removal',
).not.toBeNull()
).not.toBeNull();
expect(
boxAfterRemoval!.height,
'Height must be ≥ 44px after defect style is removed',
).toBeGreaterThanOrEqual(44)
})
).toBeGreaterThanOrEqual(44);
});
test('Rule 2 proof: overflow assertion fails under 2000px injection, passes after removal', async ({
page,
}) => {
await page.goto('/calendar')
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)
}));
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)
).toBeGreaterThan(overflowWithDefect.clientWidth);
// REMOVE the injected style by deleting the <style> element via evaluate
// (styleHandle.evaluate(el => el.remove()) — no page reload) — overflow must clear
await styleHandle.evaluate((el) => (el as Element).remove())
await styleHandle.evaluate((el) => (el as Element).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)
})
})
).toBeLessThanOrEqual(overflowAfterRemoval.clientWidth);
});
});
+31 -33
View File
@@ -21,49 +21,47 @@
* pnpm --filter @familysync/pwa test:e2e
* pnpm --filter @familysync/pwa exec playwright test --project=pixel lists.spec.ts
*/
import { test, expect } from '@playwright/test'
import { test, expect } from '@playwright/test';
// ── Rule 5: Populated state ───────────────────────────────────────────────────
test.describe('Rule 5 — populated lists state', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/lists')
await page.goto('/lists');
// Wait for auth (DEV_AUTH_BYPASS) and lists content to load
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible()
})
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
});
test('seeded "E2E Grocery List" card is visible', async ({ page }) => {
// ListCard renders a button with aria-label="Open list: <name>" (ListCard.tsx).
// This is the primary stable locator — prefer role+name over text content.
const listCard = page.getByRole('button', { name: 'Open list: E2E Grocery List' })
await expect(listCard).toBeVisible()
})
const listCard = page.getByRole('button', { name: 'Open list: E2E Grocery List' });
await expect(listCard).toBeVisible();
});
test('at least one list item is present in the populated state', async ({ page }) => {
// The content area has role="list" (ListsIndex.tsx) with ListCard children
// that each have role="listitem". Assert ≥1 listitem when seeded.
const listitems = page.getByRole('listitem')
await expect(listitems).not.toHaveCount(0)
})
const listitems = page.getByRole('listitem');
await expect(listitems).not.toHaveCount(0);
});
test('ListsEmptyState "No lists yet" is NOT present when lists are seeded', async ({
page,
}) => {
test('ListsEmptyState "No lists yet" is NOT present when lists are seeded', async ({ page }) => {
// With the seeded list, the empty state must not appear.
await expect(page.getByText('No lists yet')).toHaveCount(0)
})
await expect(page.getByText('No lists yet')).toHaveCount(0);
});
test('no horizontal overflow on populated /lists (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 /lists`,
).toBeLessThanOrEqual(overflow.clientWidth)
})
})
).toBeLessThanOrEqual(overflow.clientWidth);
});
});
// ── Rule 5: Empty state (network-simulated) ───────────────────────────────────
@@ -77,20 +75,20 @@ test.describe('Rule 5 — lists empty state (network-simulated, seeded DB untouc
contentType: 'application/json',
body: JSON.stringify({ lists: [] }),
}),
)
);
await page.goto('/lists')
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible()
await page.goto('/lists');
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
// ListsEmptyState renders "No lists yet" heading (ListsEmptyState.tsx)
await expect(page.getByText('No lists yet')).toBeVisible()
await expect(page.getByText('No lists yet')).toBeVisible();
// Body contains "Tap + to create your first shared list" — use partial match
await expect(page.getByText(/Tap \+ to create/)).toBeVisible()
await expect(page.getByText(/Tap \+ to create/)).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.
})
});
test('no horizontal overflow in empty lists state (Rule 2)', async ({ page }) => {
await page.route('/api/lists', (route) =>
@@ -99,22 +97,22 @@ test.describe('Rule 5 — lists empty state (network-simulated, seeded DB untouc
contentType: 'application/json',
body: JSON.stringify({ lists: [] }),
}),
)
);
await page.goto('/lists')
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible()
await page.goto('/lists');
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
// Wait for empty state to render before measuring overflow
await expect(page.getByText('No lists yet')).toBeVisible()
await expect(page.getByText('No lists yet')).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 empty lists state`,
).toBeLessThanOrEqual(overflow.clientWidth)
).toBeLessThanOrEqual(overflow.clientWidth);
// No manual unroute (WR-05): per-test context isolation handles route cleanup.
})
})
});
});