From 88146f22b64401452d03f6cb8e94ce3e198992b8 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 19:17:11 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D1=81=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20Playwright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/frontend-playwright.yml | 40 ++++++++ docs/playwright-tests.md | 111 +++++++++++++++++++++++ frontend/.gitignore | 5 + frontend/package.json | 9 +- frontend/playwright.config.ts | 32 +++++++ frontend/pnpm-lock.yaml | 38 ++++++++ frontend/tests/e2e/auth.spec.ts | 9 ++ frontend/tests/e2e/catalog.spec.ts | 23 +++++ frontend/tests/e2e/support/mockApi.ts | 74 +++++++++++++++ frontend/tests/mocks/fixtures.ts | 80 ++++++++++++++++ 10 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/frontend-playwright.yml create mode 100644 docs/playwright-tests.md create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/tests/e2e/auth.spec.ts create mode 100644 frontend/tests/e2e/catalog.spec.ts create mode 100644 frontend/tests/e2e/support/mockApi.ts create mode 100644 frontend/tests/mocks/fixtures.ts diff --git a/.gitea/workflows/frontend-playwright.yml b/.gitea/workflows/frontend-playwright.yml new file mode 100644 index 0000000..e3e0bbc --- /dev/null +++ b/.gitea/workflows/frontend-playwright.yml @@ -0,0 +1,40 @@ +name: Frontend Playwright + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + e2e: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build app + run: pnpm build-only + + - name: Install Playwright browser + run: pnpm exec playwright install --with-deps chromium + + - name: Run e2e + run: pnpm test:e2e diff --git a/docs/playwright-tests.md b/docs/playwright-tests.md new file mode 100644 index 0000000..9070b1e --- /dev/null +++ b/docs/playwright-tests.md @@ -0,0 +1,111 @@ +# Playwright E2E тесты frontend + +## Назначение + +Playwright-тесты проверяют ключевые браузерные сценарии frontend-приложения UniVerse: + +- редирект неавторизованного пользователя на страницу входа; +- отображение каталога открытых лекций; +- запись на доступную лекцию. + +Тесты работают поверх production preview сборки frontend и используют mock API, поэтому для базового запуска не нужен поднятый backend. + +## Где лежат файлы + +- [playwright.config.ts](../frontend/playwright.config.ts) - конфигурация Playwright. +- [auth.spec.ts](../frontend/tests/e2e/auth.spec.ts) - сценарии аутентификации. +- [catalog.spec.ts](../frontend/tests/e2e/catalog.spec.ts) - сценарии каталога лекций. +- [mockApi.ts](../frontend/tests/e2e/support/mockApi.ts) - перехват и mock ответов API. +- [fixtures.ts](../frontend/tests/mocks/fixtures.ts) - тестовые данные. + +## Как устроен запуск + +Конфигурация находится во `frontend/playwright.config.ts`. + +Основные параметры: + +- `testDir: ./tests/e2e` - Playwright ищет тесты в папке `frontend/tests/e2e`. +- `baseURL: http://127.0.0.1:4173` - базовый адрес приложения в тестах. +- `webServer` запускает `pnpm preview --host 127.0.0.1 --port 4173`. +- В CI включены `2` retry и GitHub reporter. +- Локально используется list reporter. +- По умолчанию проект запускается в Chromium. + +## Команды + +Запуск всех E2E-тестов из корня репозитория: + +```bash +pnpm -C frontend test:e2e +``` + +Интерактивный UI Playwright: + +```bash +pnpm -C frontend test:e2e:ui +``` + +Если нужно вручную поднять preview-сервер: + +```bash +pnpm -C frontend build-only +pnpm -C frontend test:e2e:preview +``` + +После этого можно запускать Playwright с переменной `PW_SKIP_WEB_SERVER=1`, чтобы он не стартовал свой `webServer`. + +## Mock API + +Тесты не обращаются к реальному backend. Вместо этого helper `mockApi(page, options)` перехватывает запросы к `/api/v1` через `page.route`. + +Сейчас замоканы: + +- `POST/GET /api/v1/auth/refresh` - refresh авторизации; +- `/api/v1/auth/me` - текущий пользователь; +- `/api/v1/users/me/stats` - статистика студента; +- `/api/v1/lectures` - список лекций; +- `/api/v1/lectures/{id}/enroll` - запись на лекцию. + +Для авторизованного сценария используйте: + +```ts +await mockApi(page, { authenticated: true }) +``` + +Для проверки гостевого сценария: + +```ts +await mockApi(page, { authenticated: false }) +``` + +## Как добавлять новые тесты + +1. Создавайте spec-файлы в `frontend/tests/e2e`. +2. Для страниц, которым нужен backend, сначала добавляйте нужные ответы в `mockApi.ts` и данные в `fixtures.ts`. +3. Проверяйте пользовательский результат через role/text/label locators: `getByRole`, `getByText`, `getByLabel`. +4. Не завязывайтесь на CSS-классы, если сценарий можно проверить через доступные пользователю элементы. +5. Для маршрутов под авторизацией вызывайте `mockApi(page, { authenticated: true })` до `page.goto(...)`. + +## CI + +Workflow находится в [.gitea/workflows/frontend-playwright.yml](../.gitea/workflows/frontend-playwright.yml). + +Пайплайн: + +1. Устанавливает зависимости через `pnpm install --frozen-lockfile`. +2. Собирает frontend командой `pnpm build-only`. +3. Устанавливает браузер Playwright Chromium. +4. Запускает `pnpm test:e2e`. + +## Артефакты и отладка + +Playwright сохраняет trace на первом retry: `trace: on-first-retry`. + +Локально полезные команды: + +```bash +pnpm -C frontend exec playwright show-report +pnpm -C frontend exec playwright show-trace ./test-results/<папка>/trace.zip +``` + +Папки `frontend/test-results` и `frontend/playwright-report` считаются временными артефактами тестовых прогонов. diff --git a/frontend/.gitignore b/frontend/.gitignore index cd68f14..7cda3d2 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -35,5 +35,10 @@ coverage # Vitest __screenshots__/ +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ + # Vite *.timestamp-*-*.mjs diff --git a/frontend/package.json b/frontend/package.json index 759a3bb..8fb4af3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,11 @@ "lint": "run-s lint:*", "lint:oxlint": "oxlint . --fix", "lint:eslint": "eslint . --fix --cache", - "format": "prettier --write --experimental-cli src/" + "format": "prettier --write --experimental-cli src/", + "test:e2e:preview": "vite preview --host 127.0.0.1 --port 4173", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:ui:edge": "PW_USE_SYSTEM_EDGE=1 PW_SKIP_WEB_SERVER=1 playwright test --ui --timeout=0 --workers=1" }, "dependencies": { "pinia": "^3.0.4", @@ -38,7 +42,8 @@ "typescript": "~6.0.0", "vite": "^8.0.8", "vite-plugin-vue-devtools": "^8.1.1", - "vue-tsc": "^3.2.6" + "vue-tsc": "^3.2.6", + "@playwright/test": "^1.54.2" }, "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..159eeb5 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test' + +const useSystemEdge = process.env.PW_USE_SYSTEM_EDGE === '1' +const skipWebServer = process.env.PW_SKIP_WEB_SERVER === '1' + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + webServer: skipWebServer + ? undefined + : { + command: 'pnpm preview --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + cwd: '.', + }, + projects: [ + { + name: useSystemEdge ? 'msedge' : 'chromium', + use: { + ...devices['Desktop Chrome'], + ...(useSystemEdge ? { channel: 'msedge' } : {}), + }, + }, + ], +}) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e9d1756..51e8a94 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: specifier: ^5.0.6 version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) devDependencies: + '@playwright/test': + specifier: ^1.54.2 + version: 1.54.2 '@tsconfig/node24': specifier: ^24.0.4 version: 24.0.4 @@ -433,6 +436,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.54.2': + resolution: {integrity: sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1008,6 +1016,11 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1347,6 +1360,16 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.54.2: + resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.54.2: + resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==} + engines: {node: '>=18'} + hasBin: true + postcss-safe-parser@7.0.1: resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} @@ -2011,6 +2034,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.60.0': optional: true + '@playwright/test@1.54.2': + dependencies: + playwright: 1.54.2 + '@polka/url@1.0.0-next.29': {} '@rolldown/binding-android-arm64@1.0.0-rc.17': @@ -2606,6 +2633,9 @@ snapshots: flatted@3.4.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2889,6 +2919,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.54.2: {} + + playwright@1.54.2: + dependencies: + playwright-core: 1.54.2 + optionalDependencies: + fsevents: 2.3.2 + postcss-safe-parser@7.0.1(postcss@8.5.14): dependencies: postcss: 8.5.14 diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..88469a3 --- /dev/null +++ b/frontend/tests/e2e/auth.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/test' +import { mockApi } from './support/mockApi' + +test('redirects unauthenticated user to login', async ({ page }) => { + await mockApi(page, { authenticated: false }) + await page.goto('/catalog') + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText('Войти через ЮФУ')).toBeVisible() +}) diff --git a/frontend/tests/e2e/catalog.spec.ts b/frontend/tests/e2e/catalog.spec.ts new file mode 100644 index 0000000..abc7d92 --- /dev/null +++ b/frontend/tests/e2e/catalog.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test' +import { mockApi } from './support/mockApi' + +test.beforeEach(async ({ page }) => { + await mockApi(page, { authenticated: true }) +}) + +test('renders catalog items from mock api', async ({ page }) => { + await page.goto('/catalog') + + await expect(page.getByRole('heading', { name: 'Каталог открытых лекций' })).toBeVisible() + await expect(page.getByText('Введение в ML')).toBeVisible() + await expect(page.getByText('Квантовые вычисления')).toBeVisible() +}) + +test('register button works for available lecture', async ({ page }) => { + await page.goto('/catalog') + + const firstRegisterButton = page.getByRole('button', { name: 'Записаться' }).first() + await firstRegisterButton.click() + + await expect(page.getByText('Вы записаны на лекцию.')).toBeVisible() +}) diff --git a/frontend/tests/e2e/support/mockApi.ts b/frontend/tests/e2e/support/mockApi.ts new file mode 100644 index 0000000..e9103cd --- /dev/null +++ b/frontend/tests/e2e/support/mockApi.ts @@ -0,0 +1,74 @@ +import { Page } from '@playwright/test' +import { mockAuthResponse, mockCurrentUser, mockLectures, mockUserStats } from '../../mocks/fixtures' + +export async function mockApi(page: Page, options?: { authenticated?: boolean }) { + const authenticated = options?.authenticated ?? false + + await page.route('**/api/v1/auth/refresh', async (route) => { + if (!authenticated) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Unauthorized' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockAuthResponse), + }) + }) + + await page.route('**/api/v1/auth/me', async (route) => { + if (!authenticated) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Unauthorized' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockCurrentUser), + }) + }) + + await page.route('**/api/v1/users/me/stats', async (route) => { + if (!authenticated) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Unauthorized' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockUserStats), + }) + }) + + await page.route('**/api/v1/lectures/*/enroll', async (route) => { + await route.fulfill({ status: 204 }) + }) + + await page.route(/\/api\/v1\/lectures(\?.*)?$/, async (route) => { + if (route.request().method() !== 'GET') { + await route.continue() + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: mockLectures }), + }) + }) +} diff --git a/frontend/tests/mocks/fixtures.ts b/frontend/tests/mocks/fixtures.ts new file mode 100644 index 0000000..1ad0547 --- /dev/null +++ b/frontend/tests/mocks/fixtures.ts @@ -0,0 +1,80 @@ +export const mockAuthResponse = { + accessToken: 'fake-token', + expiresAt: '2099-01-01T00:00:00.000Z', + user: { + id: 1, + email: 'student@example.com', + displayName: 'Test User', + roles: ['Student'], + }, +} + +export const mockCurrentUser = { + id: 1, + email: 'student@example.com', + displayName: 'Test User', + roles: ['Student'], + avatarUrl: null, + xp: 120, + coins: 10, + level: 2, + createdAt: '2026-01-01T00:00:00.000Z', +} + +export const mockUserStats = { + totalLectures: 0, + attendedLectures: 0, + totalReviews: 0, + xp: 120, + coins: 10, + level: 2, + achievementsCount: 0, + currentLevelXp: 20, + nextLevelXp: 100, + activeEnrollments: 0, + enrollmentSlotLimit: 3, + enrollmentSlotRules: [], +} + +export const mockLectures = [ + { + id: 1, + courseId: 101, + courseName: 'ML', + teacherId: 201, + teacherName: 'Иванов И.И.', + locationId: 301, + locationName: 'B-1 / 101', + title: 'Введение в ML', + description: 'База машинного обучения', + format: 'Offline', + startsAt: '2026-06-12T10:00:00.000Z', + endsAt: '2026-06-12T11:30:00.000Z', + isOpen: true, + maxEnrollments: 30, + enrollmentsCount: 10, + onlineUrl: null, + createdAt: '2026-01-01T00:00:00.000Z', + isEnrolled: false, + }, + { + id: 2, + courseId: 102, + courseName: 'квантовые-вычисления', + teacherId: 202, + teacherName: 'Петров П.П.', + locationId: null, + locationName: null, + title: 'Квантовые вычисления', + description: 'Кубиты и алгоритмы', + format: 'Online', + startsAt: '2026-06-13T10:00:00.000Z', + endsAt: '2026-06-13T11:00:00.000Z', + isOpen: false, + maxEnrollments: 50, + enrollmentsCount: 50, + onlineUrl: 'https://example.com/meet', + createdAt: '2026-01-01T00:00:00.000Z', + isEnrolled: false, + }, +]