- 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
192 lines
8.7 KiB
TypeScript
192 lines
8.7 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|