mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 05:49:38 +00:00
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:
co-authored by
Claude Opus 5.5
parent
bf72af9ef4
commit
82e097f876
@@ -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}`);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -0,0 +1,86 @@
|
||||
// Проверяет playwright.config.ts: режим CI, где искать тесты, браузер, артефакты падений и тестовый PocketBase.
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
async function loadConfig(ci: string | undefined): Promise<PlaywrightTestConfig> {
|
||||
vi.stubEnv('CI', ci);
|
||||
vi.resetModules();
|
||||
return (await import('../../playwright.config')).default;
|
||||
}
|
||||
|
||||
function webServers(config: PlaywrightTestConfig) {
|
||||
const { webServer } = config;
|
||||
return webServer === undefined ? [] : [webServer].flat();
|
||||
}
|
||||
|
||||
function isGitIgnored(path: string): boolean {
|
||||
try {
|
||||
execFileSync('git', ['check-ignore', '-q', path]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('конфиг Playwright', () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it('при CI=true: retries 1, ни один webServer не переиспользуется', async () => {
|
||||
const config = await loadConfig('true');
|
||||
|
||||
expect(config.retries).toBe(1);
|
||||
expect(webServers(config)).not.toHaveLength(0);
|
||||
for (const server of webServers(config)) expect(server.reuseExistingServer).toBe(false);
|
||||
});
|
||||
|
||||
it('без CI: webServer переиспользуются, retries 0', async () => {
|
||||
const config = await loadConfig(undefined);
|
||||
|
||||
expect(config.retries ?? 0).toBe(0);
|
||||
expect(webServers(config)).not.toHaveLength(0);
|
||||
for (const server of webServers(config)) expect(server.reuseExistingServer).toBe(true);
|
||||
});
|
||||
|
||||
it('ищет тесты tests/e2e/**/*.e2e.ts', async () => {
|
||||
const config = await loadConfig(undefined);
|
||||
|
||||
expect(resolve(config.testDir ?? '.')).toBe(resolve('tests/e2e'));
|
||||
expect(config.testMatch).toBe('**/*.e2e.ts');
|
||||
});
|
||||
|
||||
it('только Chromium', async () => {
|
||||
const config = await loadConfig(undefined);
|
||||
|
||||
expect(config.projects?.length).toBeGreaterThan(0);
|
||||
for (const project of config.projects ?? []) {
|
||||
expect(project.use?.browserName ?? project.use?.defaultBrowserType).toBe('chromium');
|
||||
}
|
||||
});
|
||||
|
||||
it('сохраняет трейс и скриншот при падении в папку, игнорируемую git', async () => {
|
||||
const config = await loadConfig(undefined);
|
||||
|
||||
expect(config.use?.screenshot).toBe('only-on-failure');
|
||||
expect(['retain-on-failure', 'retain-on-first-failure', 'on-first-retry']).toContain(
|
||||
config.use?.trace,
|
||||
);
|
||||
expect(isGitIgnored(resolve(config.outputDir ?? 'test-results'))).toBe(true);
|
||||
});
|
||||
|
||||
it('приложение смотрит на тестовый PocketBase, а не на dev', async () => {
|
||||
const config = await loadConfig(undefined);
|
||||
const servers = webServers(config);
|
||||
const app = servers.find((s) => s.env?.['PB_URL']);
|
||||
const pbUrl = app?.env?.['PB_URL'] ?? '';
|
||||
const pocketBase = servers.find((s) => s !== app && s.url?.startsWith(pbUrl));
|
||||
|
||||
expect(pbUrl).not.toBe('');
|
||||
expect(pocketBase).toBeDefined();
|
||||
expect(new URL(pbUrl).port).not.toBe('8090');
|
||||
expect(config.use?.baseURL).toBeDefined();
|
||||
expect(new URL(config.use?.baseURL ?? '').port).not.toBe('4200');
|
||||
expect(new URL(app?.url ?? '').origin).toBe(new URL(config.use?.baseURL ?? '').origin);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ export interface PocketBaseServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускает одноразовый PocketBase на свободном порту и ждёт, пока он ответит на `/api/health`.
|
||||
* Запускает одноразовый PocketBase на свободном порту (или на `port`, если он задан) и ждёт, пока он ответит на `/api/health`.
|
||||
*
|
||||
* Процесс и каталог данных удаляются при `stop()`, по `SIGINT`/`SIGTERM`, при выходе
|
||||
* и при ошибке запуска.
|
||||
@@ -33,7 +33,7 @@ export interface PocketBaseServer {
|
||||
* @throws Если `pocketbase` нет в PATH, не создался суперпользователь или сервер не ответил
|
||||
* за {@link START_TIMEOUT_MS}. В сообщение ошибки включён вывод PocketBase.
|
||||
*/
|
||||
export async function startPocketBase(): Promise<PocketBaseServer> {
|
||||
export async function startPocketBase({ port }: { port?: number } = {}): Promise<PocketBaseServer> {
|
||||
const superuser = { email: `superuser-${randomUUID()}@test.local`, password: randomUUID() };
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'pchelpcrm-pb-'));
|
||||
let child: ChildProcess | undefined;
|
||||
@@ -79,7 +79,7 @@ export async function startPocketBase(): Promise<PocketBaseServer> {
|
||||
throw new Error(`pocketbase superuser upsert failed:\n${error.stdout}${error.stderr}`);
|
||||
});
|
||||
|
||||
const url = `http://127.0.0.1:${await freePort()}`;
|
||||
const url = `http://127.0.0.1:${port ?? (await freePort())}`;
|
||||
let output = '';
|
||||
let spawnError: Error | undefined;
|
||||
const proc = spawn('pocketbase', [
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Держит тестовый PocketBase на порту из argv[2], пока процесс не получит сигнал; запускается как webServer Playwright.
|
||||
// Выполняется под чистым Node, как fixtures/hold-pocketbase.ts.
|
||||
import { startPocketBase } from './pocketbase-server.ts';
|
||||
|
||||
const port = Number(process.argv[2]);
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
throw new Error(`Usage: node serve-test-pocketbase.ts <port>; got ${process.argv[2]}`);
|
||||
}
|
||||
const { url } = await startPocketBase({ port });
|
||||
console.log(`Test PocketBase listening on ${url}`);
|
||||
setInterval(() => undefined, 1 << 30);
|
||||
Reference in New Issue
Block a user