diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4623f73..3eb526a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,57 @@ jobs: - name: Build UI image run: docker build -f apps/ui/Dockerfile . + frontend-e2e: + name: Frontend E2E + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.85.0 + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/ui/package-lock.json + + - name: Install UI dependencies + working-directory: apps/ui + run: npm ci + + - name: Install Playwright browser + working-directory: apps/ui + run: npx playwright install --with-deps chromium + + - name: Run Playwright e2e + working-directory: apps/ui + run: npx playwright test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: apps/ui/playwright-report + if-no-files-found: ignore + + - name: Upload Playwright test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: apps/ui/test-results + if-no-files-found: ignore + deployment: name: Deployment Manifests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7316048..997c83d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ /notes apps/ui/node_modules apps/ui/dist +apps/ui/playwright-report +apps/ui/test-results +.tmp *.tsbuildinfo apps/ui/vite.config.js apps/ui/vite.config.d.ts diff --git a/README.md b/README.md index 162e683..a3eb932 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,27 @@ Crank - платформа для публикации внешних API в в - gRPC `SOAP` сознательно не входит в текущий scope. + +## Frontend e2e + +Для UI настроен Playwright-контур, который поднимает локальный стек: + +- `postgres` в отдельном Docker-контейнере; +- `admin-api` и `mcp-server` через `cargo run`; +- `apps/ui` через локальный Node static+proxy server для e2e; +- `CRANK_DEMO_SEED=true` для предсказуемых demo-данных. + +Локальный запуск: + +```bash +cd apps/ui +npm ci +npm run e2e:install +npm run e2e +``` + +Или через `just`: + +```bash +just ui-e2e +``` diff --git a/TASKS.md b/TASKS.md index 7188e4b..30de76e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,15 +2,14 @@ ## Current -### `feat/alpine-polish` +### `feat/frontend-e2e` -Status: in_progress +Status: completed DoD: -- Alpine UI pages have consistent feedback and shell state -- Settings page exposes only honest live functionality and clearly marks deferred capabilities -- Wizard live actions have stable loading and feedback behavior on real backend requests -- Public UI routes use clean canonical paths instead of exposing `/html/...` +- Playwright covers the core UI journeys against a real local stack +- Frontend e2e can be run locally without manual environment setup +- CI executes frontend e2e as a separate job and publishes failure artifacts ## Next diff --git a/apps/ui/package-lock.json b/apps/ui/package-lock.json index 772d7ab..c12bf7a 100644 --- a/apps/ui/package-lock.json +++ b/apps/ui/package-lock.json @@ -8,6 +8,25 @@ "dependencies": { "alpinejs": "3.15.9", "js-yaml": "4.1.1" + }, + "devDependencies": { + "@playwright/test": "^1.59.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@vue/reactivity": { @@ -40,6 +59,21 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -51,6 +85,38 @@ "bin": { "js-yaml": "bin/js-yaml.js" } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } } } } diff --git a/apps/ui/package.json b/apps/ui/package.json index 4dae30c..09b424d 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -2,10 +2,16 @@ "name": "crank-ui", "private": true, "scripts": { - "sync:vendor": "mkdir -p js/vendor/alpine js/vendor/js-yaml && cp node_modules/alpinejs/dist/cdn.min.js js/vendor/alpine/cdn.min.js && cp node_modules/js-yaml/dist/js-yaml.min.js js/vendor/js-yaml/js-yaml.min.js" + "sync:vendor": "mkdir -p js/vendor/alpine js/vendor/js-yaml && cp node_modules/alpinejs/dist/cdn.min.js js/vendor/alpine/cdn.min.js && cp node_modules/js-yaml/dist/js-yaml.min.js js/vendor/js-yaml/js-yaml.min.js", + "e2e:install": "playwright install chromium", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed" }, "dependencies": { "alpinejs": "3.15.9", "js-yaml": "4.1.1" + }, + "devDependencies": { + "@playwright/test": "^1.59.1" } } diff --git a/apps/ui/playwright.config.js b/apps/ui/playwright.config.js new file mode 100644 index 0000000..badbedf --- /dev/null +++ b/apps/ui/playwright.config.js @@ -0,0 +1,34 @@ +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + workers: 1, + timeout: 45_000, + expect: { + timeout: 10_000, + }, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [['github'], ['html', { open: 'never' }]] + : [['list'], ['html', { open: 'never' }]], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3300', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: { + command: 'bash scripts/playwright-stack.sh', + cwd: __dirname, + url: (process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3300') + '/login', + timeout: 600_000, + reuseExistingServer: false, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/apps/ui/scripts/playwright-stack.sh b/apps/ui/scripts/playwright-stack.sh new file mode 100644 index 0000000..f5ff65d --- /dev/null +++ b/apps/ui/scripts/playwright-stack.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +TMP_DIR="$ROOT_DIR/.tmp/ui-e2e" +LOG_DIR="$TMP_DIR/logs" +POSTGRES_CONTAINER="crank-e2e-postgres" +POSTGRES_PORT="${CRANK_E2E_POSTGRES_PORT:-55433}" +ADMIN_PORT="${CRANK_E2E_ADMIN_PORT:-3301}" +MCP_PORT="${CRANK_E2E_MCP_PORT:-3302}" +UI_PORT="${CRANK_E2E_UI_PORT:-3300}" +POSTGRES_DB="${CRANK_E2E_POSTGRES_DB:-crank}" +POSTGRES_USER="${CRANK_E2E_POSTGRES_USER:-crank}" +POSTGRES_PASSWORD="${CRANK_E2E_POSTGRES_PASSWORD:-crank}" +ADMIN_EMAIL="${CRANK_E2E_ADMIN_EMAIL:-owner@crank.local}" +ADMIN_PASSWORD="${CRANK_E2E_ADMIN_PASSWORD:-change-me-admin-password}" + +mkdir -p "$LOG_DIR" + +kill_port_processes() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + lsof -ti "tcp:$port" | xargs -r kill >/dev/null 2>&1 || true + fi +} + +cleanup() { + set +e + if [[ -f "$TMP_DIR/admin-api.pid" ]]; then + kill "$(cat "$TMP_DIR/admin-api.pid")" >/dev/null 2>&1 || true + rm -f "$TMP_DIR/admin-api.pid" + fi + if [[ -f "$TMP_DIR/mcp-server.pid" ]]; then + kill "$(cat "$TMP_DIR/mcp-server.pid")" >/dev/null 2>&1 || true + rm -f "$TMP_DIR/mcp-server.pid" + fi + if [[ -f "$TMP_DIR/ui-server.pid" ]]; then + kill "$(cat "$TMP_DIR/ui-server.pid")" >/dev/null 2>&1 || true + rm -f "$TMP_DIR/ui-server.pid" + fi + docker rm -f "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true + kill_port_processes "$UI_PORT" + kill_port_processes "$ADMIN_PORT" + kill_port_processes "$MCP_PORT" +} + +trap cleanup EXIT INT TERM + +cleanup + +docker run -d --rm \ + --name "$POSTGRES_CONTAINER" \ + -e POSTGRES_DB="$POSTGRES_DB" \ + -e POSTGRES_USER="$POSTGRES_USER" \ + -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ + -p "$POSTGRES_PORT:5432" \ + postgres:16-alpine >/dev/null + +until docker exec "$POSTGRES_CONTAINER" pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; do + sleep 1 +done + +export CRANK_DATABASE_URL="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@127.0.0.1:$POSTGRES_PORT/$POSTGRES_DB" +export CRANK_STORAGE_ROOT="$TMP_DIR/storage" +export CRANK_ADMIN_BIND="0.0.0.0:$ADMIN_PORT" +export CRANK_MCP_BIND="0.0.0.0:$MCP_PORT" +export CRANK_MCP_REFRESH_MS="1000" +export CRANK_LOG_LEVEL="info" +export CRANK_SECRET_PROVIDER="env" +export CRANK_SESSION_SECRET="e2e-session-secret" +export CRANK_PASSWORD_PEPPER="e2e-password-pepper" +export CRANK_SESSION_TTL_HOURS="24" +export CRANK_BOOTSTRAP_ADMIN_EMAIL="$ADMIN_EMAIL" +export CRANK_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASSWORD" +export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E" +export CRANK_DEMO_SEED="true" +export CRANK_PUBLIC_BASE_URL="http://127.0.0.1:$UI_PORT" +export CRANK_MCP_PUBLIC_URL="http://127.0.0.1:$MCP_PORT" + +mkdir -p "$CRANK_STORAGE_ROOT" + +( + cd "$ROOT_DIR" + cargo run -p admin-api >"$LOG_DIR/admin-api.log" 2>&1 +) & +echo $! > "$TMP_DIR/admin-api.pid" + +until curl -fsS "http://127.0.0.1:$ADMIN_PORT/health" >/dev/null 2>&1; do + sleep 1 +done + +( + cd "$ROOT_DIR" + cargo run -p mcp-server >"$LOG_DIR/mcp-server.log" 2>&1 +) & +echo $! > "$TMP_DIR/mcp-server.pid" + +until curl -fsS "http://127.0.0.1:$MCP_PORT/health" >/dev/null 2>&1; do + sleep 1 +done + +( + cd "$ROOT_DIR/apps/ui" + node scripts/playwright-ui-server.js >"$LOG_DIR/ui-server.log" 2>&1 +) & +echo $! > "$TMP_DIR/ui-server.pid" + +until curl -fsS "http://127.0.0.1:$UI_PORT/login" >/dev/null 2>&1; do + sleep 1 +done + +echo "Crank UI e2e stack is ready on http://127.0.0.1:$UI_PORT" + +while true; do + sleep 1 +done diff --git a/apps/ui/scripts/playwright-ui-server.js b/apps/ui/scripts/playwright-ui-server.js new file mode 100644 index 0000000..2e1f8ef --- /dev/null +++ b/apps/ui/scripts/playwright-ui-server.js @@ -0,0 +1,190 @@ +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const { pipeline } = require('stream'); +const { URL } = require('url'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const UI_PORT = Number(process.env.CRANK_E2E_UI_PORT || 3300); +const ADMIN_PORT = Number(process.env.CRANK_E2E_ADMIN_PORT || 3301); +const ADMIN_BASE = new URL(`http://127.0.0.1:${ADMIN_PORT}`); + +const MIME_TYPES = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', +}; + +function sendNotFound(response) { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Not Found'); +} + +function redirect(response, location) { + response.writeHead(302, { Location: location }); + response.end(); +} + +function mapRoute(urlPath) { + if (urlPath === '/' || urlPath === '/index.html') { + return path.join(ROOT_DIR, 'index.html'); + } + if (urlPath === '/login' || urlPath === '/login.html') { + return path.join(ROOT_DIR, 'html', 'login.html'); + } + if (urlPath === '/agents') { + return path.join(ROOT_DIR, 'html', 'agents.html'); + } + if (urlPath === '/api-keys') { + return path.join(ROOT_DIR, 'html', 'api-keys.html'); + } + if (urlPath === '/logs') { + return path.join(ROOT_DIR, 'html', 'logs.html'); + } + if (urlPath === '/usage') { + return path.join(ROOT_DIR, 'html', 'usage.html'); + } + if (urlPath === '/settings') { + return path.join(ROOT_DIR, 'html', 'settings.html'); + } + if (urlPath === '/workspace-setup') { + return path.join(ROOT_DIR, 'html', 'workspace-setup.html'); + } + if (urlPath === '/wizard/') { + return path.join(ROOT_DIR, 'html', 'wizard', 'index.html'); + } + if (urlPath.startsWith('/wizard/')) { + return path.join(ROOT_DIR, 'html', 'wizard', urlPath.slice('/wizard/'.length)); + } + if (urlPath.startsWith('/css/')) { + return path.join(ROOT_DIR, urlPath.slice(1)); + } + if (urlPath.startsWith('/js/')) { + return path.join(ROOT_DIR, urlPath.slice(1)); + } + if (urlPath.startsWith('/icons/')) { + return path.join(ROOT_DIR, urlPath.slice(1)); + } + if (urlPath.startsWith('/data/')) { + return path.join(ROOT_DIR, urlPath.slice(1)); + } + if (urlPath === '/Crank.png') { + return path.resolve(ROOT_DIR, '..', '..', 'Crank.png'); + } + return null; +} + +function serveFile(filePath, response) { + const normalized = path.normalize(filePath); + if (!normalized.startsWith(ROOT_DIR) && !normalized.endsWith(path.sep + 'Crank.png')) { + sendNotFound(response); + return; + } + + fs.stat(normalized, (error, stats) => { + if (error || !stats.isFile()) { + sendNotFound(response); + return; + } + + const extension = path.extname(normalized).toLowerCase(); + response.writeHead(200, { + 'Content-Length': stats.size, + 'Content-Type': MIME_TYPES[extension] || 'application/octet-stream', + 'Cache-Control': 'no-store', + }); + + pipeline(fs.createReadStream(normalized), response, () => {}); + }); +} + +function proxyRequest(request, response) { + const target = new URL(request.url, ADMIN_BASE); + const proxy = http.request( + target, + { + method: request.method, + headers: { + ...request.headers, + host: `127.0.0.1:${ADMIN_PORT}`, + }, + }, + (proxyResponse) => { + response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers); + pipeline(proxyResponse, response, () => {}); + }, + ); + + proxy.on('error', () => { + response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Bad Gateway'); + }); + + pipeline(request, proxy, () => {}); +} + +const server = http.createServer((request, response) => { + const requestUrl = new URL(request.url, `http://127.0.0.1:${UI_PORT}`); + const urlPath = requestUrl.pathname; + + if (urlPath === '/wizard') { + redirect(response, '/wizard/'); + return; + } + + if (urlPath === '/html/login.html') { + redirect(response, '/login'); + return; + } + if (urlPath === '/html/agents.html') { + redirect(response, '/agents'); + return; + } + if (urlPath === '/html/api-keys.html') { + redirect(response, '/api-keys'); + return; + } + if (urlPath === '/html/logs.html') { + redirect(response, '/logs'); + return; + } + if (urlPath === '/html/usage.html') { + redirect(response, '/usage'); + return; + } + if (urlPath === '/html/settings.html') { + redirect(response, '/settings'); + return; + } + if (urlPath === '/html/workspace-setup.html') { + redirect(response, '/workspace-setup'); + return; + } + if (urlPath === '/html/wizard' || urlPath === '/html/wizard/') { + redirect(response, '/wizard/'); + return; + } + + if (urlPath.startsWith('/api/auth/') || urlPath.startsWith('/api/admin/')) { + proxyRequest(request, response); + return; + } + + const filePath = mapRoute(urlPath); + if (!filePath) { + sendNotFound(response); + return; + } + + serveFile(filePath, response); +}); + +server.listen(UI_PORT, '127.0.0.1', () => { + console.log(`Playwright UI server listening on http://127.0.0.1:${UI_PORT}`); +}); diff --git a/apps/ui/tests/e2e/agents.spec.js b/apps/ui/tests/e2e/agents.spec.js new file mode 100644 index 0000000..d126d2f --- /dev/null +++ b/apps/ui/tests/e2e/agents.spec.js @@ -0,0 +1,11 @@ +const { test, expect } = require('@playwright/test'); +const { login, localized } = require('./helpers'); + +test('agents page shows demo cards and edit drawer opens', async ({ page }) => { + await login(page); + await page.goto('/agents'); + await expect(page.locator('.page-heading')).toHaveText(localized('Agents', 'Агенты')); + await page.getByRole('button', { name: localized('New agent', 'Новый агент') }).click(); + await expect(page.locator('.drawer-title')).toHaveText(localized('New agent', 'Новый агент')); + await expect(page.locator('.drawer')).toContainText(/mcp/i); +}); diff --git a/apps/ui/tests/e2e/api-keys.spec.js b/apps/ui/tests/e2e/api-keys.spec.js new file mode 100644 index 0000000..af0dc83 --- /dev/null +++ b/apps/ui/tests/e2e/api-keys.spec.js @@ -0,0 +1,13 @@ +const { test, expect } = require('@playwright/test'); +const { login, localized } = require('./helpers'); + +test('api keys page opens create key flow', async ({ page }) => { + await login(page); + await page.goto('/api-keys'); + await expect(page.locator('.page-title')).toHaveText(localized('API Keys', 'API Ключи')); + await page.locator('#btn-create-key').click(); + await expect(page.locator('.modal-title')).toHaveText(localized('Create API key', 'Создать API-ключ')); + await page.locator('#new-key-name').fill(`playwright-${Date.now()}`); + await page.locator('#modal-confirm-btn').click(); + await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас')); +}); diff --git a/apps/ui/tests/e2e/helpers.js b/apps/ui/tests/e2e/helpers.js new file mode 100644 index 0000000..d7b074f --- /dev/null +++ b/apps/ui/tests/e2e/helpers.js @@ -0,0 +1,25 @@ +const { expect } = require('@playwright/test'); + +const ADMIN_EMAIL = process.env.CRANK_E2E_ADMIN_EMAIL || 'owner@crank.local'; +const ADMIN_PASSWORD = process.env.CRANK_E2E_ADMIN_PASSWORD || 'change-me-admin-password'; + +function localized(en, ru) { + return new RegExp(`(?:${en}|${ru})`, 'i'); +} + +async function login(page) { + await page.goto('/login'); + await expect(page.locator('#login-form')).toBeVisible(); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await page.locator('.btn-signin').click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.locator('.page-title, .page-heading').first()).toBeVisible(); +} + +module.exports = { + ADMIN_EMAIL, + ADMIN_PASSWORD, + login, + localized, +}; diff --git a/apps/ui/tests/e2e/login.spec.js b/apps/ui/tests/e2e/login.spec.js new file mode 100644 index 0000000..d450833 --- /dev/null +++ b/apps/ui/tests/e2e/login.spec.js @@ -0,0 +1,19 @@ +const { test, expect } = require('@playwright/test'); +const { ADMIN_EMAIL, ADMIN_PASSWORD, localized } = require('./helpers'); + +test('login page rejects invalid credentials', async ({ page }) => { + await page.goto('/login'); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill('wrong-password'); + await page.locator('.btn-signin').click(); + await expect(page.locator('#login-error')).toBeVisible(); +}); + +test('login page signs in and redirects to operations', async ({ page }) => { + await page.goto('/login'); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await page.locator('.btn-signin').click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.locator('.page-title, .page-heading').first()).toHaveText(localized('Operations', 'Операции')); +}); diff --git a/apps/ui/tests/e2e/logs-usage.spec.js b/apps/ui/tests/e2e/logs-usage.spec.js new file mode 100644 index 0000000..5cd618a --- /dev/null +++ b/apps/ui/tests/e2e/logs-usage.spec.js @@ -0,0 +1,16 @@ +const { test, expect } = require('@playwright/test'); +const { login, localized } = require('./helpers'); + +test('logs and usage pages show seeded data', async ({ page }) => { + await login(page); + + await page.goto('/logs'); + await expect(page.locator('.page-title')).toHaveText(localized('Logs', 'Логи')); + await expect(page.locator('#log-list')).toBeVisible(); + await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible(); + + await page.goto('/usage'); + await expect(page.locator('.page-title')).toHaveText(localized('Usage', 'Использование')); + await expect(page.locator('#chart-bars .chart-col')).toHaveCount(7); + await expect(page.locator('#usage-tbody tr')).toHaveCount(3); +}); diff --git a/apps/ui/tests/e2e/operations.spec.js b/apps/ui/tests/e2e/operations.spec.js new file mode 100644 index 0000000..978493d --- /dev/null +++ b/apps/ui/tests/e2e/operations.spec.js @@ -0,0 +1,11 @@ +const { test, expect } = require('@playwright/test'); +const { login, localized } = require('./helpers'); + +test('operations page shows demo catalog and filter works', async ({ page }) => { + await login(page); + await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции')); + await expect(page.locator('tbody tr')).toHaveCount(4); + await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('support'); + await expect(page.locator('tbody tr')).toHaveCount(1); + await expect(page.locator('tbody tr').first()).toContainText(/support_lookup_ticket/i); +}); diff --git a/apps/ui/tests/e2e/wizard.spec.js b/apps/ui/tests/e2e/wizard.spec.js new file mode 100644 index 0000000..1df7a4a --- /dev/null +++ b/apps/ui/tests/e2e/wizard.spec.js @@ -0,0 +1,13 @@ +const { test, expect } = require('@playwright/test'); +const { login, localized } = require('./helpers'); + +test('wizard loads and protocol selection updates flow', async ({ page }) => { + await login(page); + await page.goto('/wizard/'); + await expect(page.locator('[data-step-counter]')).toContainText(localized('Step', 'Шаг')); + await expect(page.locator('#step-panel-1 .step-panel-title')).toContainText(localized('Choose a protocol', 'Выберите протокол')); + await page.getByText(/graphql/i).first().click(); + await page.locator('#btn-continue').click(); + await expect(page.locator('[data-step-counter]')).toContainText(/2/); + await expect(page.locator('#step-panel-2 .step-panel-title')).toContainText(localized('Select upstream', 'Выберите upstream')); +}); diff --git a/apps/ui/tests/e2e/workspace-settings.spec.js b/apps/ui/tests/e2e/workspace-settings.spec.js new file mode 100644 index 0000000..1435b9d --- /dev/null +++ b/apps/ui/tests/e2e/workspace-settings.spec.js @@ -0,0 +1,16 @@ +const { test, expect } = require('@playwright/test'); +const { login, localized } = require('./helpers'); + +test('workspace and settings pages show live session data', async ({ page }) => { + await login(page); + + await page.goto('/workspace-setup'); + await expect(page.locator('#section-members')).toBeVisible(); + await expect(page.locator('#members-count-label')).toBeVisible(); + await expect(page.locator('#pending-invites')).toBeVisible(); + + await page.goto('/settings'); + await expect(page.locator('.page-title')).toHaveText(localized('Account settings', 'Настройки аккаунта')); + await expect(page.locator('#settings-session-summary')).toBeVisible(); + await expect(page.locator('#section-security')).toBeVisible(); +}); diff --git a/justfile b/justfile index 45fce82..0839910 100644 --- a/justfile +++ b/justfile @@ -23,3 +23,6 @@ ui-build: verify-ui: just ui-build + +ui-e2e: + cd apps/ui && npm run e2e