diff --git a/.gitignore b/.gitignore index 7ee1e5d..d1f7059 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ yarn-error.log testem.log /typings __screenshots__/ +/test-results +/playwright-report .playwright-mcp/ # PocketBase diff --git a/README.md b/README.md index 25ab180..4f09c00 100644 --- a/README.md +++ b/README.md @@ -47,22 +47,22 @@ pnpm start # приложение на http://localhost:4200/ ## Команды -| Команда | Что делает | -| ----------------------- | ------------------------------------------------------------------------------------- | -| `pnpm start` | dev-сервер приложения | -| `pnpm build` | production-сборка в `dist/` | -| `pnpm lint` | ESLint | -| `pnpm test` | unit-тесты | -| `pnpm test:api` | API-тесты PocketBase: схема, правила доступа, изоляция пользователей | -| `pnpm test:integration` | репозитории приложения против тестового PocketBase | -| `pnpm test:lint` | тесты конфигов ESLint и dev-прокси; первый шаг `test`, `test:api`, `test:integration` | -| `pnpm e2e` | сценарии в браузере и проверка доступности (axe) | -| `pnpm e2e:prod` | сценарии на production-сборке | -| `pnpm pb:serve` | PocketBase для разработки | -| `pnpm pb:migrate` | применить миграции | -| `pnpm pb:superuser` | создать суперпользователя или сбросить его пароль | +| Команда | Что делает | +| ----------------------- | -------------------------------------------------------------------------------------------------------- | +| `pnpm start` | dev-сервер приложения | +| `pnpm build` | production-сборка в `dist/` | +| `pnpm lint` | ESLint | +| `pnpm test` | unit-тесты | +| `pnpm test:api` | API-тесты PocketBase: схема, правила доступа, изоляция пользователей | +| `pnpm test:integration` | репозитории приложения против тестового PocketBase | +| `pnpm test:lint` | тесты конфигов ESLint, dev-прокси и Playwright; первый шаг `test`, `test:api`, `test:integration`, `e2e` | +| `pnpm e2e` | сценарии в браузере и проверка доступности (axe) | +| `pnpm e2e:prod` | сценарии на production-сборке | +| `pnpm pb:serve` | PocketBase для разработки | +| `pnpm pb:migrate` | применить миграции | +| `pnpm pb:superuser` | создать суперпользователя или сбросить его пароль | -Тесты, которым нужен PocketBase, сами поднимают временный экземпляр и не трогают `pocketbase/pb_data`. Перед первым запуском e2e установите браузер: `pnpm exec playwright install chromium`. +Тесты, которым нужен PocketBase, сами поднимают временный экземпляр и не трогают `pocketbase/pb_data`. Перед первым запуском e2e установите браузер: `pnpm exec playwright install chromium`. Стенд e2e использует свои порты (PocketBase — 8190, приложение — 4300), поэтому не мешает `pnpm pb:serve` и `pnpm start`. ## Структура репозитория diff --git a/docs/testing.md b/docs/testing.md index 611a783..ef308dc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -37,14 +37,25 @@ `tests/support/clients.ts` компилируется в двух программах — `tsconfig.tests.json` и `tsconfig.spec.json`. Типы Node подключены в обеих (спеки выполняются Vitest в Node, а код в `src/` вроде серверного провайдера `PB_URL` читает `process.env`), а `tests/support/provided-context.d.ts` с типами `inject('pbUrl')` перечислен в `include` обоих tsconfig: `references` в `tsconfig.json` программы не объединяют. -## Тесты конфигов (ESLint, dev-прокси) +## Стенд e2e + +`pnpm e2e` = `pnpm test:lint && playwright test`. Перед первым запуском: `pnpm exec playwright install chromium`. + +- `playwright.config.ts`: тесты `tests/e2e/**/*.e2e.ts`, один проект `chromium`, при падении — скриншот и трейс в `test-results/` (в `.gitignore`). +- Два `webServer`: тестовый PocketBase (`node tests/support/serve-test-pocketbase.ts 8190` — тот же `startPocketBase()`, только с фиксированным портом) и `ng serve --port 4300` с `PB_URL` тестового PocketBase: на него смотрят и SSR, и dev-прокси `/pb`. Адреса и порты — `tests/e2e/urls.ts`; они не пересекаются с `pnpm pb:serve` (8090) и `pnpm start` (4200), а `pb_data` разработчика не используется. +- Серверы останавливаются по `SIGTERM` (`gracefulShutdown`): по умолчанию Playwright шлёт `SIGKILL`, и стенд не успел бы удалить временную папку PocketBase. +- Без `CI` уже запущенные серверы переиспользуются, повторов нет; при `CI=true` серверы всегда свои, повтор упавшего теста один. Это проверяет `tests/lint/playwright-config.spec.ts`. +- Фикстуры (`tests/e2e/fixtures.ts`) не зависят от UI входа и суперпользователя (при переиспользовании PocketBase его пароль неизвестен): `user` регистрируется через открытую регистрацию `users`, `api` — SDK, вошедший как `user`, `signedInPage` — страница в **отдельном** контексте браузера с cookie `pb_auth` в формате `exportToCookie`, поэтому обычная `page` остаётся гостевой. +- `expectNoA11yViolations(page)` (`tests/e2e/a11y.ts`): axe со всеми правилами (WCAG и best-practice, как требует CLAUDE.md), падает со списком `правило: описание (элементов: N)`. + +## Тесты конфигов (ESLint, dev-прокси, Playwright) `pnpm test:lint` = `tsc -p tsconfig.tests.json && vitest run --config vitest.lint.config.mts`: чистый Vitest в Node, файлы `tests/lint/**/*.spec.ts`. - Проверяют, что ESLint держит границы, от которых зависит изоляция данных при SSR: `new PocketBase(` и импорт класса `PocketBase` (в том числе под другим именем и через `pocketbase/*`) только в фабрике `shared/pocketbase/pocketbase-client.ts` (и в тестовых хелперах `tests/`), импорт `pocketbase` и `pocketbase/*` запрещён в `domain/` и `ui/`. - `tests/lint/pb-dev-proxy.spec.ts` проверяет `proxy.conf.mjs` (dev-прокси `ng serve`): правило `/pb/`, `target` из `PB_URL` с тем же значением по умолчанию, что у серверного провайдера (в том числе при пустой переменной), и срезание префикса `/pb`. - Способ ESLint-тестов: `ESLint#lintText(code, { filePath })` на коротких фрагментах кода, проверяется `ruleId` сообщений. Конфиг ESLint ломается молча (опечатка в glob, слишком широкое исключение), и `pnpm lint` при этом остаётся зелёным; тест это ловит. -- Запускаются первым шагом в `pnpm test`, `pnpm test:api` и `pnpm test:integration`, чтобы поломка правила обнаружилась при любом прогоне. ESLint — Node-библиотека, в бандл builder'а Angular её не подключить, поэтому это отдельный набор. +- Запускаются первым шагом в `pnpm test`, `pnpm test:api`, `pnpm test:integration` и `pnpm e2e`, чтобы поломка правила обнаружилась при любом прогоне. ESLint — Node-библиотека, в бандл builder'а Angular её не подключить, поэтому это отдельный набор. ## Контрактные тесты репозиториев @@ -71,11 +82,11 @@ API-, integration- и e2e-тесты работают с **отдельным в 1. Создаётся временная папка данных (в системной temp-папке). 2. Создаётся суперпользователь (`pocketbase superuser upsert`) со сгенерированными email и паролем: инстанс одноразовый, поэтому `.env` тестам не нужен. -3. Запускается `pocketbase serve` на свободном порту `127.0.0.1` с `--dir <временная папка> --migrationsDir pocketbase/pb_migrations --hooksDir pocketbase/pb_hooks --automigrate=false`; миграции применяются при старте. +3. Запускается `pocketbase serve` на свободном порту `127.0.0.1` (или на заданном: e2e — 8190) с `--dir <временная папка> --migrationsDir pocketbase/pb_migrations --hooksDir pocketbase/pb_hooks --automigrate=false`; миграции применяются при старте. 4. Ожидание готовности по `GET /api/health`. 5. После прогона процесс останавливается, папка удаляется. -Код запуска один на все уровни: `tests/support/pocketbase-server.ts` (`startPocketBase(): Promise<{ url, dataDir, superuser, stop }>`). +Код запуска один на все уровни: `tests/support/pocketbase-server.ts` (`startPocketBase({ port? }): Promise<{ url, dataDir, superuser, stop }>`). Стенд убирает за собой процесс и папку после успешного прогона, после падения (в том числе неудачного старта), по `SIGINT`/`SIGTERM` (Ctrl+C) и при выходе процесса. Предел: `SIGKILL` процесса-владельца перехватить нельзя — тогда PocketBase может продолжить работу, а папка `pchelpcrm-pb-*` остаться в temp. diff --git a/package.json b/package.json index 619dd35..31c134c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:lint": "tsc -p tsconfig.tests.json && vitest run --config vitest.lint.config.mts", "test:api": "pnpm test:lint && vitest run --config vitest.api.config.mts", "test:integration": "pnpm test:lint && ng run pchelpcrm:test-integration", + "e2e": "pnpm test:lint && playwright test", "lint": "eslint .", "lint:fix": "eslint . --fix", "serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs", @@ -36,7 +37,9 @@ "@angular/build": "^22.2.3", "@angular/cli": "^22.2.3", "@angular/compiler-cli": "^22.2.0", + "@axe-core/playwright": "^4.14.0", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.64.1", "@types/express": "^5.0.1", "@types/node": "^22.12.0", "angular-eslint": "^22.6.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d674433 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; +import { E2E_APP_URL, E2E_PB_PORT, E2E_PB_URL } from './tests/e2e/urls'; + +const ci = !!process.env['CI']; + +// Стенд e2e: временный PocketBase и `ng serve` (SSR + dev-прокси /pb) на отдельных портах. +export default defineConfig({ + testDir: 'tests/e2e', + testMatch: '**/*.e2e.ts', + outputDir: 'test-results', + retries: ci ? 1 : 0, + use: { + baseURL: E2E_APP_URL, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: [ + { + command: `node tests/support/serve-test-pocketbase.ts ${E2E_PB_PORT}`, + url: `${E2E_PB_URL}/api/health`, + reuseExistingServer: !ci, + // По умолчанию Playwright шлёт SIGKILL: стенд не успел бы удалить временную папку. + // Таймаут больше STOP_TIMEOUT_MS стенда (5 с), чтобы он успел добить PocketBase и убрать папку. + gracefulShutdown: { signal: 'SIGTERM', timeout: 10_000 }, + }, + { + command: `pnpm ng serve --port ${new URL(E2E_APP_URL).port}`, + url: E2E_APP_URL, + env: { PB_URL: E2E_PB_URL }, + reuseExistingServer: !ci, + timeout: 180_000, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae13bce..fd314b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,9 +54,15 @@ importers: '@angular/compiler-cli': specifier: ^22.2.0 version: 22.2.3(@angular/compiler@22.2.3)(typescript@6.0.3) + '@axe-core/playwright': + specifier: ^4.14.0 + version: 4.14.0(playwright-core@1.64.1) '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.13.0(supports-color@8.1.1)) + '@playwright/test': + specifier: ^1.64.1 + version: 1.64.1 '@types/express': specifier: ^5.0.1 version: 5.0.6 @@ -300,6 +306,12 @@ packages: resolution: {integrity: sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@axe-core/playwright@4.14.0': + resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@8.0.6': resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1217,6 +1229,11 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm-eabi@1.2.8': resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1507,6 +1524,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + axe-core@4.14.0: + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2309,6 +2330,16 @@ packages: resolution: {integrity: sha512-vv7l/mM7B9WgrhDaudIqv1x6v4LOBRYYmpWGdWushhpbCmVHp11sUB2v4hj1CAeTGXVxuZFz6xOU1RalAiPKfQ==} engines: {node: '>=20.x'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + pocketbase@0.28.1: resolution: {integrity: sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA==} @@ -3161,6 +3192,11 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.3 + '@axe-core/playwright@4.14.0(playwright-core@1.64.1)': + dependencies: + axe-core: 4.14.0 + playwright-core: 1.64.1 + '@babel/code-frame@8.0.6': dependencies: '@babel/helper-validator-identifier': 8.0.6 @@ -3838,6 +3874,10 @@ snapshots: '@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0 + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@rolldown/binding-android-arm-eabi@1.2.8': optional: true @@ -4122,6 +4162,8 @@ snapshots: assertion-error@2.0.1: {} + axe-core@4.14.0: {} + axobject-query@4.1.0: {} balanced-match@4.0.4: {} @@ -4976,6 +5018,12 @@ snapshots: optionalDependencies: '@napi-rs/nice': 1.1.1 + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + pocketbase@0.28.1: {} postcss-media-query-parser@0.2.3: {} diff --git a/src/app/app.html b/src/app/app.html index 67e7bd4..c2d4fba 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1 +1,5 @@ - +
+ +

Pchelpcrm

+ +
diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 11ea7c5..a2d3059 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -1,6 +1,10 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Server, + }, { path: '**', renderMode: RenderMode.Client, diff --git a/tests/e2e/a11y.ts b/tests/e2e/a11y.ts new file mode 100644 index 0000000..fa33774 --- /dev/null +++ b/tests/e2e/a11y.ts @@ -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 { + 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}`); +} diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..cc51f0d --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -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({ + // 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'; diff --git a/tests/e2e/smoke.e2e.ts b/tests/e2e/smoke.e2e.ts new file mode 100644 index 0000000..dd2a791 --- /dev/null +++ b/tests/e2e/smoke.e2e.ts @@ -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( + 'Тест' + + '

Тест

', + ); + + await expect(expectNoA11yViolations(page)).rejects.toThrow(/image-alt/); + }); + + test('падает и на правилах best-practice', async ({ page }) => { + // Нет main и h1: это правила best-practice, а не WCAG. + await page.setContent( + 'Тест' + + '

Текст

', + ); + + await expect(expectNoA11yViolations(page)).rejects.toThrow(/landmark-one-main/); + }); + + test('не падает на чистой странице', async ({ page }) => { + await page.setContent( + 'Тест' + + '

Тест

Текст

', + ); + + 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'); + }); +}); diff --git a/tests/e2e/urls.ts b/tests/e2e/urls.ts new file mode 100644 index 0000000..9c28e3c --- /dev/null +++ b/tests/e2e/urls.ts @@ -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'; diff --git a/tests/lint/playwright-config.spec.ts b/tests/lint/playwright-config.spec.ts new file mode 100644 index 0000000..58b2714 --- /dev/null +++ b/tests/lint/playwright-config.spec.ts @@ -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 { + 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); + }); +}); diff --git a/tests/support/pocketbase-server.ts b/tests/support/pocketbase-server.ts index 51b09a8..8c05ece 100644 --- a/tests/support/pocketbase-server.ts +++ b/tests/support/pocketbase-server.ts @@ -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 { +export async function startPocketBase({ port }: { port?: number } = {}): Promise { 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 { 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', [ diff --git a/tests/support/serve-test-pocketbase.ts b/tests/support/serve-test-pocketbase.ts new file mode 100644 index 0000000..91b3981 --- /dev/null +++ b/tests/support/serve-test-pocketbase.ts @@ -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 ; got ${process.argv[2]}`); +} +const { url } = await startPocketBase({ port }); +console.log(`Test PocketBase listening on ${url}`); +setInterval(() => undefined, 1 << 30);