test(e2e): add playwright e2e stand with test pocketbase and axe checks

- playwright.config.ts: test PocketBase (port 8190) and ng serve (port 4300)
  as webServers, SIGTERM shutdown so the stand removes its temp dir
- fixtures: user via open registration, api SDK client, signedInPage with pb_auth cookie
- expectNoA11yViolations runs all axe rules (WCAG and best-practice)
- render home route on the server; main and h1 placeholder in app shell
- config tests for playwright.config.ts in test:lint; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kamask
2026-10-31 22:12:04 +03:00
co-authored by Claude Opus 5.5
parent bf72af9ef4
commit 82e097f876
15 changed files with 426 additions and 23 deletions
+13
View File
@@ -0,0 +1,13 @@
import AxeBuilder from '@axe-core/playwright';
import type { Page } from '@playwright/test';
/** Запускает axe со всеми правилами (WCAG и best-practice) и падает со списком нарушений. */
export async function expectNoA11yViolations(page: Page): Promise<void> {
const { violations } = await new AxeBuilder({ page }).analyze();
if (violations.length === 0) return;
const list = violations
.map((v) => `- ${v.id}: ${v.help} (элементов: ${v.nodes.length})`)
.join('\n');
throw new Error(`Нарушения доступности:\n${list}`);
}
+53
View File
@@ -0,0 +1,53 @@
// Фикстуры e2e: пользователь и клиент API создаются через открытую регистрацию, без UI входа.
import { test as base, type Page } from '@playwright/test';
import PocketBase, { BaseAuthStore } from 'pocketbase';
import type { TestUser } from '../support/clients';
import { E2E_APP_URL, E2E_PB_URL } from './urls';
interface E2eFixtures {
/** Новый пользователь, созданный через API тестового PocketBase. */
user: TestUser;
/** Страница в своём контексте браузера с cookie `pb_auth` пользователя `user`. */
signedInPage: Page;
/** SDK от имени `user` для подготовки данных. */
api: PocketBase;
}
export const test = base.extend<E2eFixtures>({
// Playwright требует деструктуризацию первым аргументом, даже пустую.
// eslint-disable-next-line no-empty-pattern
user: async ({}, use) => {
const email = `user-${crypto.randomUUID()}@test.local`;
const password = crypto.randomUUID();
const pb = new PocketBase(E2E_PB_URL, new BaseAuthStore());
const record = await pb
.collection('users')
.create({ email, password, passwordConfirm: password });
await use({ id: record.id, email, password });
},
api: async ({ user }, use) => {
const pb = new PocketBase(E2E_PB_URL, new BaseAuthStore());
await pb.collection('users').authWithPassword(user.email, user.password);
await use(pb);
},
// Свой контекст: встроенная `page` остаётся гостевой.
signedInPage: async ({ browser, api }, use) => {
const context = await browser.newContext({ baseURL: E2E_APP_URL });
// Формат cookie тот же, что пишет приложение (exportToCookie); берём из неё только значение.
const cookie = api.authStore.exportToCookie();
const value = cookie.slice('pb_auth='.length, cookie.indexOf(';'));
await context.addCookies([
{
name: 'pb_auth',
value,
url: E2E_APP_URL,
sameSite: 'Lax',
httpOnly: false,
},
]);
await use(await context.newPage());
await context.close();
},
});
export { expect } from '@playwright/test';
+129
View File
@@ -0,0 +1,129 @@
// Смоук стенда e2e: приложение и тестовый PocketBase подняты, прокси /pb работает, axe и фикстуры работают.
import { expectNoA11yViolations } from './a11y';
import { expect, test } from './fixtures';
test.describe('главная', () => {
test('главная отвечает статусом 200', async ({ page }) => {
const response = await page.goto('/');
expect(response?.status()).toBe(200);
});
test.describe('HTML от сервера', () => {
// Без JS браузер показывает ровно ту разметку, что отдал SSR.
test.use({ javaScriptEnabled: false });
test('в HTML от сервера есть main и h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
});
test('на отрисованной главной есть main и h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
test('на главной нет нарушений доступности', async ({ page }) => {
await page.goto('/');
await expectNoA11yViolations(page);
});
});
test.describe('прокси /pb', () => {
test('/pb/api/health с origin приложения отдаёт health PocketBase', async ({ request }) => {
const response = await request.get('/pb/api/health');
expect(response.status()).toBe(200);
// Без прокси ng serve ответил бы на неизвестный путь index.html со статусом 200.
expect(await response.json()).toMatchObject({ code: 200 });
});
});
test.describe('хелпер expectNoA11yViolations', () => {
test('падает на нарушении WCAG и называет правило', async ({ page }) => {
await page.setContent(
'<!doctype html><html lang="ru"><head><title>Тест</title></head>' +
'<body><main><h1>Тест</h1><img src="data:,"></main></body></html>',
);
await expect(expectNoA11yViolations(page)).rejects.toThrow(/image-alt/);
});
test('падает и на правилах best-practice', async ({ page }) => {
// Нет main и h1: это правила best-practice, а не WCAG.
await page.setContent(
'<!doctype html><html lang="ru"><head><title>Тест</title></head>' +
'<body><p>Текст</p></body></html>',
);
await expect(expectNoA11yViolations(page)).rejects.toThrow(/landmark-one-main/);
});
test('не падает на чистой странице', async ({ page }) => {
await page.setContent(
'<!doctype html><html lang="ru"><head><title>Тест</title></head>' +
'<body><main><h1>Тест</h1><p>Текст</p></main></body></html>',
);
await expect(expectNoA11yViolations(page)).resolves.toBeUndefined();
});
});
test.describe('фикстуры', () => {
test('api действует от имени user', async ({ api, user }) => {
expect(api.authStore.record?.id).toBe(user.id);
await expect(api.collection('users').getOne(user.id)).resolves.toMatchObject({
email: user.email,
});
});
test('signedInPage: cookie pb_auth с Path=/, SameSite=Lax, без HttpOnly', async ({
signedInPage,
}) => {
const cookies = await signedInPage.context().cookies();
expect(cookies.find((c) => c.name === 'pb_auth')).toMatchObject({
path: '/',
sameSite: 'Lax',
httpOnly: false,
});
});
test('signedInPage: токен из cookie принимает auth-refresh через /pb', async ({
signedInPage,
user,
}) => {
await signedInPage.goto('/');
// Выполняется в браузере: cookie читается из document.cookie, как её прочитает SDK.
const result = await signedInPage.evaluate(async () => {
const raw = document.cookie
.split('; ')
.find((c) => c.startsWith('pb_auth='))
?.slice('pb_auth='.length);
const { token } = JSON.parse(decodeURIComponent(raw ?? '{}')) as { token?: string };
const response = await fetch('/pb/api/collections/users/auth-refresh', {
method: 'POST',
headers: { Authorization: token ?? '' },
});
const body = (await response.json()) as { record?: { id?: string } };
return { status: response.status, recordId: body.record?.id };
});
expect(result).toEqual({ status: 200, recordId: user.id });
});
test('обычная page остаётся гостевой рядом с signedInPage', async ({ page, signedInPage }) => {
const signedInCookies = await signedInPage.context().cookies();
const guestCookies = await page.context().cookies();
expect(signedInCookies.map((c) => c.name)).toContain('pb_auth');
expect(guestCookies.map((c) => c.name)).not.toContain('pb_auth');
});
});
+4
View File
@@ -0,0 +1,4 @@
// Адреса стенда e2e: порты не пересекаются с `pnpm pb:serve` (8090) и `pnpm start` (4200).
export const E2E_PB_PORT = 8190;
export const E2E_PB_URL = `http://127.0.0.1:${E2E_PB_PORT}`;
export const E2E_APP_URL = 'http://localhost:4300';