Files
familysync/apps/pwa/e2e/admin.spec.ts
Lucas Berger 944045cd7e test(17-06): add admin two-tab ARIA and keyboard assertions to admin.spec.ts
- Add 'Admin two-tab ARIA strip (D-10)' describe block: tablist visible,
  both named tabs visible, ArrowRight/ArrowLeft keyboard switching,
  panel aria-labelledby, phone overflow check
- Add 'Admin success toast structure (D-08)' describe: role=status absent on load
- Satisfies Wave 0 admin-ARIA CI requirement — 12/12 tests pass on pixel profile
2026-06-18 12:56:34 -04:00

192 lines
8.7 KiB
TypeScript
Raw Permalink 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.
/**
* admin.spec.ts — Phase 10 Plan 04 admin route gate + Phase 17 Plan 06 two-tab ARIA
*
* Tests:
* - Admin user (id=1, seeded is_admin=true by global-setup) sees the Admin nav entry
* and reaches /admin with "Admin Settings" heading + Members section.
* - Non-admin (route-mocked isAdmin:false) does NOT see the Admin nav entry and is
* redirected from /admin to /calendar.
* - Phase 17 (D-10): Two-tab ARIA strip — tablist + named tabs visible, ArrowRight
* moves selection to the Settings tab (keyboard nav).
* - Phase 17 (D-08): Success toast — role=status element structure present.
*
* Requires the dev stack running with DEV_AUTH_BYPASS=true (see e2e/README.md).
* global-setup seeds: users id=1 is_admin=true (Plan 10-01 note).
*
* Route-mock pattern for non-admin simulation:
* page.route('/api/me', ...) → { user: { ..., isAdmin: false, needsProviderSetup: false } }
* per [[dev-data-user1-no-calendars]] idiom + lists.spec page.route precedent.
*
* Runs on all three device profiles automatically (playwright.config.ts matrix):
* iphone: iPhone 14 / WebKit / 390×844
* pixel: Pixel 7 / Chromium / 412×915
* desktop: Desktop Chrome / Chromium / 1280×720
*
* Run:
* pnpm --filter @familysync/pwa test:e2e
* pnpm --filter @familysync/pwa exec playwright test admin.spec.ts
* pnpm --filter @familysync/pwa test:e2e -- admin
*/
import { test, expect } from '@playwright/test';
// ── Admin user (seeded is_admin=true) ─────────────────────────────────────────
test.describe('Admin user — admin nav entry + /admin route', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/calendar');
// Wait for auth and nav to be visible before asserting
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
});
test('admin sees the Admin nav entry (ShieldCheck, aria-label="Admin settings")', async ({
page,
}) => {
// The admin nav link is rendered with aria-label="Admin settings" in both
// AppNav (desktop) and BottomTabBar (mobile).
const adminEntry = page.getByRole('link', { name: 'Admin settings' });
await expect(adminEntry).toBeVisible();
});
test('admin reaches /admin and sees "Admin Settings" heading', async ({ page }) => {
// Navigate directly — also verifies the route guard does NOT redirect admins
await page.goto('/admin');
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
// The page heading is "Admin Settings"
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
});
test('admin /admin page renders the MEMBERS section', async ({ page }) => {
await page.goto('/admin');
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
// The section is labeled "Members" (aria-label on <section>)
await expect(page.getByRole('region', { name: 'Members' })).toBeVisible();
});
});
// ── Non-admin user (route-mocked isAdmin:false) ───────────────────────────────
test.describe('Non-admin user — admin nav entry hidden + /admin redirect', () => {
// Route-mock /api/me to return isAdmin:false BEFORE navigation so the PWA
// never sees isAdmin:true in this test context.
const mockNonAdminMe = async (page: import('@playwright/test').Page) => {
await page.route('/api/me', (route) => {
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: {
id: 1,
displayName: 'Dev User',
color: '#4A90D9',
isAdmin: false,
needsProviderSetup: false,
},
}),
});
});
};
test('non-admin does NOT see the Admin nav entry', async ({ page }) => {
await mockNonAdminMe(page);
await page.goto('/calendar');
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
// Admin entry must be absent
const adminEntry = page.getByRole('link', { name: 'Admin settings' });
await expect(adminEntry).toHaveCount(0);
});
test('non-admin navigating to /admin is redirected to /calendar', async ({ page }) => {
await mockNonAdminMe(page);
await page.goto('/admin');
// Wait for meQuery to resolve and redirect to fire — the Navigate component
// replaces the URL once meQuery.isLoading = false + isAdmin = false.
await page.waitForURL(/\/calendar/, { timeout: 10_000 });
// Should have landed on /calendar
const url = new URL(page.url());
expect(url.pathname, `Expected /calendar but got ${url.pathname}`).toMatch(/^\/(calendar)?$/);
// "Admin Settings" heading must NOT be present
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toHaveCount(0);
});
});
// ── Phase 17 D-10: Two-tab ARIA strip ────────────────────────────────────────
test.describe('Admin two-tab ARIA strip (D-10)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin');
// Wait for the heading to confirm /admin loaded (admin session via DEV_AUTH_BYPASS)
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
});
test('tab strip has correct ARIA roles — tablist and both named tabs visible', async ({
page,
}) => {
await expect(page.getByRole('tablist')).toBeVisible();
await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible();
});
test('Members & Accounts tab is selected by default', async ({ page }) => {
const membersTab = page.getByRole('tab', { name: 'Members & Accounts' });
await expect(membersTab).toHaveAttribute('aria-selected', 'true');
});
test('ArrowRight switches selection to the Settings tab', async ({ page }) => {
const membersTab = page.getByRole('tab', { name: 'Members & Accounts' });
const settingsTab = page.getByRole('tab', { name: 'Settings' });
await membersTab.focus();
await page.keyboard.press('ArrowRight');
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
});
test('ArrowLeft from Settings tab switches back to Members & Accounts tab', async ({ page }) => {
const membersTab = page.getByRole('tab', { name: 'Members & Accounts' });
const settingsTab = page.getByRole('tab', { name: 'Settings' });
// Navigate to Settings first
await membersTab.focus();
await page.keyboard.press('ArrowRight');
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
// Then go back
await page.keyboard.press('ArrowLeft');
await expect(membersTab).toHaveAttribute('aria-selected', 'true');
});
test('both tab panels exist with correct ARIA labelledby', async ({ page }) => {
// Both panels are in the DOM; the inactive one uses the HTML `hidden` attribute
const membersPanel = page.locator('#admin-panel-members');
const settingsPanel = page.locator('#admin-panel-settings');
await expect(membersPanel).toHaveAttribute('aria-labelledby', 'admin-tab-members');
await expect(settingsPanel).toHaveAttribute('aria-labelledby', 'admin-tab-settings');
});
test('tab strip fits without horizontal overflow on phone viewport (390px)', async ({
page,
viewport,
}) => {
// Only meaningful on narrow viewports; skip on desktop
if ((viewport?.width ?? 1280) >= 768) return;
const tablist = page.getByRole('tablist');
// Verify both tabs are visible (no overflow clipping them)
await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible();
// scrollWidth <= clientWidth (no horizontal overflow)
const overflows = await tablist.evaluate((el) => el.scrollWidth > el.clientWidth);
expect(overflows, 'Tab strip must not overflow horizontally').toBe(false);
});
});
// ── Phase 17 D-08: Success toast structure ───────────────────────────────────
test.describe('Admin success toast structure (D-08)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
});
test('role=status live region is not present when no toast is active', async ({ page }) => {
// On page load no toast should be showing
// The toast is rendered conditionally only when toast !== null
await expect(page.locator('[role="status"]')).toHaveCount(0);
});
});