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
@@ -40,6 +40,8 @@ yarn-error.log
|
|||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
# PocketBase
|
# PocketBase
|
||||||
|
|||||||
@@ -48,21 +48,21 @@ pnpm start # приложение на http://localhost:4200/
|
|||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
| Команда | Что делает |
|
| Команда | Что делает |
|
||||||
| ----------------------- | ------------------------------------------------------------------------------------- |
|
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
| `pnpm start` | dev-сервер приложения |
|
| `pnpm start` | dev-сервер приложения |
|
||||||
| `pnpm build` | production-сборка в `dist/` |
|
| `pnpm build` | production-сборка в `dist/` |
|
||||||
| `pnpm lint` | ESLint |
|
| `pnpm lint` | ESLint |
|
||||||
| `pnpm test` | unit-тесты |
|
| `pnpm test` | unit-тесты |
|
||||||
| `pnpm test:api` | API-тесты PocketBase: схема, правила доступа, изоляция пользователей |
|
| `pnpm test:api` | API-тесты PocketBase: схема, правила доступа, изоляция пользователей |
|
||||||
| `pnpm test:integration` | репозитории приложения против тестового PocketBase |
|
| `pnpm test:integration` | репозитории приложения против тестового PocketBase |
|
||||||
| `pnpm test:lint` | тесты конфигов ESLint и dev-прокси; первый шаг `test`, `test:api`, `test:integration` |
|
| `pnpm test:lint` | тесты конфигов ESLint, dev-прокси и Playwright; первый шаг `test`, `test:api`, `test:integration`, `e2e` |
|
||||||
| `pnpm e2e` | сценарии в браузере и проверка доступности (axe) |
|
| `pnpm e2e` | сценарии в браузере и проверка доступности (axe) |
|
||||||
| `pnpm e2e:prod` | сценарии на production-сборке |
|
| `pnpm e2e:prod` | сценарии на production-сборке |
|
||||||
| `pnpm pb:serve` | PocketBase для разработки |
|
| `pnpm pb:serve` | PocketBase для разработки |
|
||||||
| `pnpm pb:migrate` | применить миграции |
|
| `pnpm pb:migrate` | применить миграции |
|
||||||
| `pnpm pb:superuser` | создать суперпользователя или сбросить его пароль |
|
| `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`.
|
||||||
|
|
||||||
## Структура репозитория
|
## Структура репозитория
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -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` программы не объединяют.
|
`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`.
|
`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/`.
|
- Проверяют, что 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`.
|
- `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` при этом остаётся зелёным; тест это ловит.
|
- Способ 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-папке).
|
1. Создаётся временная папка данных (в системной temp-папке).
|
||||||
2. Создаётся суперпользователь (`pocketbase superuser upsert`) со сгенерированными email и паролем: инстанс одноразовый, поэтому `.env` тестам не нужен.
|
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`.
|
4. Ожидание готовности по `GET /api/health`.
|
||||||
5. После прогона процесс останавливается, папка удаляется.
|
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.
|
Стенд убирает за собой процесс и папку после успешного прогона, после падения (в том числе неудачного старта), по `SIGINT`/`SIGTERM` (Ctrl+C) и при выходе процесса. Предел: `SIGKILL` процесса-владельца перехватить нельзя — тогда PocketBase может продолжить работу, а папка `pchelpcrm-pb-*` остаться в temp.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"test:lint": "tsc -p tsconfig.tests.json && vitest run --config vitest.lint.config.mts",
|
"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:api": "pnpm test:lint && vitest run --config vitest.api.config.mts",
|
||||||
"test:integration": "pnpm test:lint && ng run pchelpcrm:test-integration",
|
"test:integration": "pnpm test:lint && ng run pchelpcrm:test-integration",
|
||||||
|
"e2e": "pnpm test:lint && playwright test",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs",
|
"serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs",
|
||||||
@@ -36,7 +37,9 @@
|
|||||||
"@angular/build": "^22.2.3",
|
"@angular/build": "^22.2.3",
|
||||||
"@angular/cli": "^22.2.3",
|
"@angular/cli": "^22.2.3",
|
||||||
"@angular/compiler-cli": "^22.2.0",
|
"@angular/compiler-cli": "^22.2.0",
|
||||||
|
"@axe-core/playwright": "^4.14.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/node": "^22.12.0",
|
"@types/node": "^22.12.0",
|
||||||
"angular-eslint": "^22.6.0",
|
"angular-eslint": "^22.6.0",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+48
@@ -54,9 +54,15 @@ importers:
|
|||||||
'@angular/compiler-cli':
|
'@angular/compiler-cli':
|
||||||
specifier: ^22.2.0
|
specifier: ^22.2.0
|
||||||
version: 22.2.3(@angular/compiler@22.2.3)(typescript@6.0.3)
|
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':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(eslint@10.13.0(supports-color@8.1.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':
|
'@types/express':
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
@@ -300,6 +306,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==}
|
resolution: {integrity: sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==}
|
||||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
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':
|
'@babel/code-frame@8.0.6':
|
||||||
resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==}
|
resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==}
|
||||||
engines: {node: ^22.18.0 || >=24.11.0}
|
engines: {node: ^22.18.0 || >=24.11.0}
|
||||||
@@ -1217,6 +1229,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
|
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
|
||||||
engines: {node: '>= 10.0.0'}
|
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':
|
'@rolldown/binding-android-arm-eabi@1.2.8':
|
||||||
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
|
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1507,6 +1524,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
axe-core@4.14.0:
|
||||||
|
resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
axobject-query@4.1.0:
|
axobject-query@4.1.0:
|
||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2309,6 +2330,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-vv7l/mM7B9WgrhDaudIqv1x6v4LOBRYYmpWGdWushhpbCmVHp11sUB2v4hj1CAeTGXVxuZFz6xOU1RalAiPKfQ==}
|
resolution: {integrity: sha512-vv7l/mM7B9WgrhDaudIqv1x6v4LOBRYYmpWGdWushhpbCmVHp11sUB2v4hj1CAeTGXVxuZFz6xOU1RalAiPKfQ==}
|
||||||
engines: {node: '>=20.x'}
|
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:
|
pocketbase@0.28.1:
|
||||||
resolution: {integrity: sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA==}
|
resolution: {integrity: sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA==}
|
||||||
|
|
||||||
@@ -3161,6 +3192,11 @@ snapshots:
|
|||||||
is-potential-custom-element-name: 1.0.1
|
is-potential-custom-element-name: 1.0.1
|
||||||
lru-cache: 11.5.3
|
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':
|
'@babel/code-frame@8.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 8.0.6
|
'@babel/helper-validator-identifier': 8.0.6
|
||||||
@@ -3838,6 +3874,10 @@ snapshots:
|
|||||||
'@parcel/watcher-win32-arm64': 2.6.0
|
'@parcel/watcher-win32-arm64': 2.6.0
|
||||||
'@parcel/watcher-win32-x64': 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':
|
'@rolldown/binding-android-arm-eabi@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4122,6 +4162,8 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
axe-core@4.14.0: {}
|
||||||
|
|
||||||
axobject-query@4.1.0: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
@@ -4976,6 +5018,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@napi-rs/nice': 1.1.1
|
'@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: {}
|
pocketbase@0.28.1: {}
|
||||||
|
|
||||||
postcss-media-query-parser@0.2.3: {}
|
postcss-media-query-parser@0.2.3: {}
|
||||||
|
|||||||
+5
-1
@@ -1 +1,5 @@
|
|||||||
<router-outlet />
|
<main>
|
||||||
|
<!-- shortcut: временная заглушка для смоука e2e; перенести h1 в компонент главной, когда он появится. -->
|
||||||
|
<h1>Pchelpcrm</h1>
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||||
|
|
||||||
export const serverRoutes: ServerRoute[] = [
|
export const serverRoutes: ServerRoute[] = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
renderMode: RenderMode.Server,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
renderMode: RenderMode.Client,
|
renderMode: RenderMode.Client,
|
||||||
|
|||||||
@@ -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`, при выходе
|
* Процесс и каталог данных удаляются при `stop()`, по `SIGINT`/`SIGTERM`, при выходе
|
||||||
* и при ошибке запуска.
|
* и при ошибке запуска.
|
||||||
@@ -33,7 +33,7 @@ export interface PocketBaseServer {
|
|||||||
* @throws Если `pocketbase` нет в PATH, не создался суперпользователь или сервер не ответил
|
* @throws Если `pocketbase` нет в PATH, не создался суперпользователь или сервер не ответил
|
||||||
* за {@link START_TIMEOUT_MS}. В сообщение ошибки включён вывод PocketBase.
|
* за {@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 superuser = { email: `superuser-${randomUUID()}@test.local`, password: randomUUID() };
|
||||||
const dataDir = await mkdtemp(join(tmpdir(), 'pchelpcrm-pb-'));
|
const dataDir = await mkdtemp(join(tmpdir(), 'pchelpcrm-pb-'));
|
||||||
let child: ChildProcess | undefined;
|
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}`);
|
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 output = '';
|
||||||
let spawnError: Error | undefined;
|
let spawnError: Error | undefined;
|
||||||
const proc = spawn('pocketbase', [
|
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