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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user