Accessibility fixes.
This commit is contained in:
parent
4aacb26c4b
commit
88c94aaaf4
12 changed files with 276 additions and 260 deletions
147
e2e/a11y.spec.js
147
e2e/a11y.spec.js
|
|
@ -1,112 +1,127 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { loginAsAdmin } from './helpers/auth.js'
|
||||
import { test, expect } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { loginAsAdmin } from "./helpers/auth.js";
|
||||
|
||||
const publicPages = [
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Join', path: '/join' },
|
||||
{ name: 'Events', path: '/events' },
|
||||
{ name: 'Coming Soon', path: '/coming-soon' },
|
||||
]
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Join", path: "/join" },
|
||||
{ name: "Events", path: "/events" },
|
||||
{ name: "Coming Soon", path: "/coming-soon" },
|
||||
];
|
||||
|
||||
const memberPages = [
|
||||
{ name: 'Member Dashboard', path: '/member/dashboard' },
|
||||
{ name: 'Member Profile', path: '/member/profile' },
|
||||
]
|
||||
{ name: "Member Dashboard", path: "/member/dashboard" },
|
||||
{ name: "Member Profile", path: "/member/profile" },
|
||||
];
|
||||
|
||||
const adminPages = [
|
||||
{ name: 'Admin Members', path: '/admin/members' },
|
||||
{ name: 'Admin Events Create', path: '/admin/events/create' },
|
||||
]
|
||||
{ name: "Admin Members", path: "/admin/members" },
|
||||
{ name: "Admin Events Create", path: "/admin/events/create" },
|
||||
];
|
||||
|
||||
test.describe('accessibility — public pages', () => {
|
||||
test.describe("accessibility — public pages", () => {
|
||||
for (const { name, path } of publicPages) {
|
||||
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
||||
await page.goto(path)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze()
|
||||
.withTags(["wcag2a", "wcag2aa"])
|
||||
.analyze();
|
||||
|
||||
const critical = results.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious'
|
||||
)
|
||||
(v) => v.impact === "critical" || v.impact === "serious",
|
||||
);
|
||||
|
||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
|
||||
})
|
||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test.describe('accessibility — member pages', () => {
|
||||
test.describe("accessibility — member pages", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
})
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
for (const { name, path } of memberPages) {
|
||||
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
||||
await page.goto(path)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze()
|
||||
.withTags(["wcag2a", "wcag2aa"])
|
||||
.analyze();
|
||||
|
||||
const critical = results.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious'
|
||||
)
|
||||
(v) => v.impact === "critical" || v.impact === "serious",
|
||||
);
|
||||
|
||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
|
||||
})
|
||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test.describe('accessibility — admin pages', () => {
|
||||
test.describe("accessibility — admin pages", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
})
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
for (const { name, path } of adminPages) {
|
||||
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
||||
await page.goto(path)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze()
|
||||
.withTags(["wcag2a", "wcag2aa"])
|
||||
.analyze();
|
||||
|
||||
const critical = results.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious'
|
||||
)
|
||||
(v) => v.impact === "critical" || v.impact === "serious",
|
||||
);
|
||||
|
||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
|
||||
})
|
||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test.describe('keyboard navigation', () => {
|
||||
test('tab through join form fields in order', async ({ page }) => {
|
||||
await page.goto('/join')
|
||||
await page.waitForLoadState('networkidle')
|
||||
test.describe("keyboard navigation", () => {
|
||||
test("tab through join form fields in order", async ({ page }) => {
|
||||
await page.goto("/join");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Focus the name field and tab through
|
||||
await page.locator('#join-name').focus()
|
||||
expect(await page.locator('#join-name').evaluate((el) => el === document.activeElement)).toBe(true)
|
||||
await page.locator("#join-name").focus();
|
||||
expect(
|
||||
await page
|
||||
.locator("#join-name")
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
).toBe(true);
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press("Tab");
|
||||
// Email field should receive focus next
|
||||
expect(await page.locator('#join-email').evaluate((el) => el === document.activeElement)).toBe(true)
|
||||
})
|
||||
expect(
|
||||
await page
|
||||
.locator("#join-email")
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('escape closes login modal', async ({ page }) => {
|
||||
await page.goto('/member/dashboard')
|
||||
// Wait for login modal to appear
|
||||
const modal = page.locator('text=Sign in to continue').or(page.locator('text=Sign in to your dashboard'))
|
||||
await expect(modal.first()).toBeVisible({ timeout: 10000 })
|
||||
test("escape closes login modal", async ({ page }) => {
|
||||
await page.goto("/member/dashboard");
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
// The page renders an inline "sign in required" wall for unauthenticated users
|
||||
const signInBlock = page.locator("h2", { hasText: "Sign in required" });
|
||||
await expect(signInBlock).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the Sign In button to open the login modal overlay
|
||||
await page.locator("button", { hasText: "Sign In" }).click();
|
||||
|
||||
const modal = page.locator("text=Sign in to your dashboard");
|
||||
await expect(modal.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Modal should close
|
||||
await expect(modal.first()).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
await expect(modal.first()).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,48 +1,53 @@
|
|||
import { test, expect } from './helpers/fixtures.js'
|
||||
import { test, expect } from "./helpers/fixtures.js";
|
||||
|
||||
test.describe('Admin members page', () => {
|
||||
test('members list loads for admin', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/members')
|
||||
test.describe("Admin members page", () => {
|
||||
test("members list loads for admin", async ({ adminPage }) => {
|
||||
await adminPage.goto("/admin/members");
|
||||
|
||||
await expect(adminPage.locator('h1')).toHaveText('Members')
|
||||
await expect(adminPage.getByText('Manage members, contributions, and access')).toBeVisible()
|
||||
})
|
||||
await expect(adminPage.locator("h1")).toHaveText("Members");
|
||||
await expect(
|
||||
adminPage.getByText("Manage members, contributions, and access"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('search bar works', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/members')
|
||||
test("search bar works", async ({ adminPage }) => {
|
||||
await adminPage.goto("/admin/members");
|
||||
|
||||
const searchInput = adminPage.getByPlaceholder('Search members...')
|
||||
await expect(searchInput).toBeVisible()
|
||||
const searchInput = adminPage.getByPlaceholder("Search members...");
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await searchInput.fill('nonexistent-query-xyz')
|
||||
await searchInput.fill("nonexistent-query-xyz");
|
||||
|
||||
// Page should not crash -- either shows filtered results or the empty state
|
||||
await expect(
|
||||
adminPage.locator('table').or(adminPage.getByText('No members found matching your criteria'))
|
||||
).toBeVisible()
|
||||
})
|
||||
adminPage
|
||||
.locator("table")
|
||||
.or(adminPage.getByText("No members found matching your criteria")),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('non-admin redirect', async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
test("non-admin redirect", async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/admin/members')
|
||||
await page.goto("/admin/members");
|
||||
|
||||
// Admin middleware redirects non-admin users to / or /members
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
||||
expect(page.url()).not.toContain('/admin/members')
|
||||
await page.waitForURL((url) => !url.pathname.startsWith("/admin"));
|
||||
expect(page.url()).not.toContain("/admin/members");
|
||||
|
||||
await context.close()
|
||||
})
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('add member button opens modal', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/members')
|
||||
test("add member button opens modal", async ({ adminPage }) => {
|
||||
await adminPage.goto("/admin/members");
|
||||
await adminPage.waitForLoadState("networkidle"); // ensure Vue hydration is complete
|
||||
|
||||
await adminPage.getByRole('button', { name: 'Add Member' }).click()
|
||||
await adminPage.getByRole("button", { name: "Add Member" }).click();
|
||||
|
||||
// Modal should appear with the form heading and fields
|
||||
await expect(adminPage.getByText('Add New Member')).toBeVisible()
|
||||
await expect(adminPage.getByPlaceholder('Full name')).toBeVisible()
|
||||
await expect(adminPage.getByPlaceholder('email@example.com')).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(adminPage.getByText("Add New Member")).toBeVisible();
|
||||
await expect(adminPage.getByPlaceholder("Full name")).toBeVisible();
|
||||
await expect(adminPage.getByPlaceholder("email@example.com")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,41 +1,45 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe('coming-soon page', () => {
|
||||
test('renders with heading and login form', async ({ page }) => {
|
||||
await page.goto('/coming-soon')
|
||||
test.describe("coming-soon page", () => {
|
||||
test("renders with heading and login form", async ({ page }) => {
|
||||
await page.goto("/coming-soon");
|
||||
|
||||
await expect(page.locator('h1')).toContainText('Ghost Guild')
|
||||
await expect(page.locator('input[type="email"]')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Send Magic Link' })).toBeVisible()
|
||||
})
|
||||
await expect(page.locator("h1")).toContainText("Ghost Guild");
|
||||
await expect(page.locator('input[type="email"]')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send Magic Link" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows "Coming Soon" text for unauthenticated visitors', async ({ page }) => {
|
||||
await page.goto('/coming-soon')
|
||||
test('shows "Coming Soon" text for unauthenticated visitors', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/coming-soon");
|
||||
|
||||
await expect(page.getByText('Coming Soon')).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(page.getByText("Coming Soon")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('public routes accessible when gate is off', () => {
|
||||
test('home page loads', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
test.describe("public routes accessible when gate is off", () => {
|
||||
test("home page loads", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Should not redirect to /coming-soon
|
||||
expect(page.url()).not.toContain('/coming-soon')
|
||||
await expect(page.getByText('Ghost Guild')).toBeVisible()
|
||||
})
|
||||
expect(page.url()).not.toContain("/coming-soon");
|
||||
await expect(page.locator("h1")).toContainText("Ghost Guild");
|
||||
});
|
||||
|
||||
test('events page loads', async ({ page }) => {
|
||||
await page.goto('/events')
|
||||
test("events page loads", async ({ page }) => {
|
||||
await page.goto("/events");
|
||||
|
||||
expect(page.url()).not.toContain('/coming-soon')
|
||||
await expect(page.locator('h1')).toContainText('Events')
|
||||
})
|
||||
expect(page.url()).not.toContain("/coming-soon");
|
||||
await expect(page.locator("h1")).toContainText("Events");
|
||||
});
|
||||
|
||||
test('join page loads', async ({ page }) => {
|
||||
await page.goto('/join')
|
||||
test("join page loads", async ({ page }) => {
|
||||
await page.goto("/join");
|
||||
|
||||
expect(page.url()).not.toContain('/coming-soon')
|
||||
await expect(page.locator('h1')).toContainText('Join Ghost Guild')
|
||||
})
|
||||
})
|
||||
expect(page.url()).not.toContain("/coming-soon");
|
||||
await expect(page.locator("h1")).toContainText("Join Ghost Guild");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue