diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a04aa34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +package-lock.json +.env +*.log +.DS_Store + +tests/node_modules/ +tests/visual/current/ +tests/visual/diff/ +tests/reports/ \ No newline at end of file diff --git a/docker/docker-compose.web-testing.yml b/docker/docker-compose.web-testing.yml index 8d01f90..0b9180e 100644 --- a/docker/docker-compose.web-testing.yml +++ b/docker/docker-compose.web-testing.yml @@ -1,133 +1,108 @@ -version: '3.8' - # Web Testing Infrastructure for APAW # Covers: Visual Regression, Link Checking, Form Testing, Console Errors +# +# Usage: +# docker compose -f docker/docker-compose.web-testing.yml up visual-tester +# docker compose -f docker/docker-compose.web-testing.yml run --rm screenshot-baseline +# docker compose -f docker/docker-compose.web-testing.yml run --rm screenshot-current +# docker compose -f docker/docker-compose.web-testing.yml run --rm visual-compare services: - # Main Playwright MCP Server - E2E Testing - playwright-mcp: - image: mcr.microsoft.com/playwright/mcp:latest - container_name: playwright-mcp - ports: - - "8931:8931" + # ─── Screenshot Capture: Create Baselines ───────────────────────── + screenshot-baseline: + image: mcr.microsoft.com/playwright:v1.52.0-noble + container_name: apaw-screenshot-baseline + working_dir: /app volumes: - - ./tests:/app/tests - - ./tests/visual/baseline:/app/baseline - - ./tests/visual/current:/app/current - - ./tests/visual/diff:/app/diff - - ./tests/reports:/app/reports + - ../tests:/app/tests environment: - - PLAYWRIGHT_MCP_BROWSER=chromium - - PLAYWRIGHT_MCP_HEADLESS=true - - PLAYWRIGHT_MCP_NO_SANDBOX=true - - PLAYWRIGHT_MCP_PORT=8931 - - PLAYWRIGHT_MCP_HOST=0.0.0.0 + - TARGET_URL=http://host.docker.internal:3000 + - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright command: > - node cli.js - --headless - --browser chromium - --no-sandbox - --port 8931 - --host 0.0.0.0 - --caps=core,pdf - restart: unless-stopped + sh -c "npm install --prefix /app/tests pixelmatch pngjs 2>/dev/null; + node /app/tests/scripts/capture-screenshots.js baseline" + extra_hosts: + - "host.docker.internal:host-gateway" shm_size: '2gb' ipc: host - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8931/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - # Visual Regression Service - Pixelmatch Comparison - visual-regression: - image: node:20-alpine - container_name: visual-regression + # ─── Screenshot Capture: Create Current ────────────────────────── + screenshot-current: + image: mcr.microsoft.com/playwright:v1.52.0-noble + container_name: apaw-screenshot-current working_dir: /app volumes: - - ./tests/visual:/app - - ./tests/reports:/app/reports + - ../tests:/app/tests + environment: + - TARGET_URL=http://host.docker.internal:3000 + - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + command: > + sh -c "npm install --prefix /app/tests pixelmatch pngjs 2>/dev/null; + node /app/tests/scripts/capture-screenshots.js current" + extra_hosts: + - "host.docker.internal:host-gateway" + shm_size: '2gb' + ipc: host + + # ─── Visual Regression: Compare Screenshots ────────────────────── + visual-compare: + image: node:20-alpine + container_name: apaw-visual-compare + working_dir: /app + volumes: + - ../tests:/app/tests environment: - PIXELMATCH_THRESHOLD=0.05 + - BASELINE_DIR=/app/tests/visual/baseline + - CURRENT_DIR=/app/tests/visual/current + - DIFF_DIR=/app/tests/visual/diff + - REPORTS_DIR=/app/tests/reports command: > - sh -c "npm install pixelmatch pngjs && - node /app/scripts/compare-screenshots.js" - profiles: - - visual - depends_on: - - playwright-mcp + sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null; + node scripts/compare-screenshots.js" - # Console Error Aggregator + # ─── Full Visual Test Pipeline ────────────────────────────────── + # Captures current screenshots and compares against baselines + visual-tester: + image: mcr.microsoft.com/playwright:v1.52.0-noble + container_name: apaw-visual-tester + working_dir: /app + volumes: + - ../tests:/app/tests + environment: + - TARGET_URL=http://host.docker.internal:3000 + - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + - PIXELMATCH_THRESHOLD=0.05 + - BASELINE_DIR=/app/tests/visual/baseline + - CURRENT_DIR=/app/tests/visual/current + - DIFF_DIR=/app/tests/visual/diff + - REPORTS_DIR=/app/tests/reports + command: > + sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null; + node scripts/visual-test-pipeline.js" + extra_hosts: + - "host.docker.internal:host-gateway" + shm_size: '2gb' + ipc: host + + # ─── Console Error Monitor ────────────────────────────────────── console-monitor: - image: node:20-alpine - container_name: console-monitor + image: mcr.microsoft.com/playwright:v1.52.0-noble + container_name: apaw-console-monitor working_dir: /app volumes: - - ./tests/console:/app - - ./tests/reports:/app/reports + - ../tests:/app/tests + environment: + - TARGET_URL=http://host.docker.internal:3000 + - REPORTS_DIR=/app/tests/reports command: > - sh -c "npm install && - node /app/scripts/aggregate-errors.js" - profiles: - - console - depends_on: - - playwright-mcp + sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null; + node scripts/console-error-monitor-standalone.js" + extra_hosts: + - "host.docker.internal:host-gateway" + shm_size: '2gb' + ipc: host - # Link Checker Service - link-checker: - image: node:20-alpine - container_name: link-checker - working_dir: /app - volumes: - - ./tests/links:/app - - ./tests/reports:/app/reports - command: > - sh -c "npm install playwright && - node /app/scripts/check-links.js" - profiles: - - links - depends_on: - - playwright-mcp - - # Form Tester Service - form-tester: - image: node:20-alpine - container_name: form-tester - working_dir: /app - volumes: - - ./tests/forms:/app - - ./tests/reports:/app/reports - command: > - sh -c "npm install playwright && - node /app/scripts/test-forms.js" - profiles: - - forms - depends_on: - - playwright-mcp - - # Full Test Suite - All Tests - full-testing: - image: node:20-alpine - container_name: full-testing - working_dir: /app - volumes: - - ./tests:/app/tests - - ./tests/reports:/app/reports - command: > - sh -c "npm install playwright pixelmatch pngjs && - node /app/tests/run-all-tests.js" - profiles: - - full - depends_on: - - playwright-mcp - -# Networks networks: - test-network: - driver: bridge - -# Volumes for test data persistence -volumes: - baseline-screenshots: - test-results: \ No newline at end of file + default: + name: apaw-testing \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index 8830a90..ec888d3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,254 +1,142 @@ -# Web Testing README +# Web Testing Suite — APAW -Автоматическое тестирование веб-приложений для APAW. +Автоматическое тестирование веб-приложений. Запускается целиком в Docker без зависимостей на хосте. ## Возможности -| Тест | Описание | -|------|----------| -| **Visual Regression** | Обнаружение визуальных дефектов: наложения элементов, смещения шрифтов, не те цвета | -| **Link Checking** | Проверка всех ссылок на 404/500 ошибки | -| **Form Testing** | Тестирование форм: заполнение, валидация, отправка | -| **Console Errors** | Захват JS ошибок, сетевых ошибок, создание Gitea Issues | +| Тест | Скрипт | Описание | +|------|--------|----------| +| **Visual Regression** | `visual-test-pipeline.js` | Скриншоты + pixelmatch + извлечение UI-элементов с bbox | +| **Screenshot Capture** | `capture-screenshots.js` | Захват baseline/current скриншотов в 3 viewport | +| **Screenshot Compare** | `compare-screenshots.js` | Сравнение PNG через pixelmatch | +| **Console Errors** | `console-error-monitor-standalone.js` | Ловит JS ошибки, network 4xx/5xx, request failures | +| **Link Checking** | `link-checker.js` | Проверка ссылок на 404/500 | ## Быстрый старт -### 1. Запуск в Docker (без установки на хост) +### Вариант 1: Docker Compose (рекомендуется) ```bash -# Запустить Playwright MCP контейнер -docker compose -f docker/docker-compose.web-testing.yml up -d +# Полный визуальный пайплайн (захват + сравнение + элементы + ошибки) +docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=https://example.com \ + -e PAGES=/ \ + visual-tester -# Проверить что MCP работает -curl http://localhost:8931/mcp -X POST -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +# Только захват baseline-скриншотов +docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=https://example.com \ + screenshot-baseline + +# Только захват текущих скриншотов +docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=https://example.com \ + screenshot-current + +# Только сравнение (pixelmatch) +docker compose -f docker/docker-compose.web-testing.yml run --rm visual-compare + +# Мониторинг консольных ошибок +docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=https://example.com \ + console-monitor ``` -### 2. Запуск тестов +### Вариант 2: Прямой docker run ```bash -# Указать целевой URL -export TARGET_URL=https://your-app.com - -# Запустить все тесты -cd tests && npm install && npm test - -# Или через скрипт из корня проекта -./scripts/web-test.sh https://your-app.com +docker run --rm \ + --add-host=host.docker.internal:host-gateway \ + --shm-size=2g \ + -v $(pwd)/tests:/app/tests \ + -e TARGET_URL=https://example.com \ + -e PAGES=/ \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + sh -c "cd /app/tests && npm install --ignore-scripts && node scripts/visual-test-pipeline.js" ``` -### 3. Просмотр отчёта - -```bash -# Открыть HTML отчёт -npm run report - -# Или вручную -open tests/reports/web-test-report.html -``` - -## Использование с Kilo Code - -### Команда /web-test - -``` -/web-test https://my-app.com -``` - -Запускает все тесты и генерирует отчёт. - -### Команда /web-test-fix - -``` -/web-test-fix https://my-app.com -``` - -Запускает тесты + автоматически исправляет найденные ошибки через агентов. - -## Структура папок +## Структура ``` tests/ ├── scripts/ -│ ├── compare-screenshots.js # Visual regression -│ ├── link-checker.js # Link checking -│ ├── console-error-monitor.js # Console errors -│ └── aggregate-errors.js # Error aggregation +│ ├── visual-test-pipeline.js # Полный пайплайн (захват + сравнение + элементы) +│ ├── capture-screenshots.js # Захват скриншотов (baseline|current) +│ ├── compare-screenshots.js # Сравнение PNG (pixelmatch) +│ ├── console-error-monitor-standalone.js # Мониторинг console/network ошибок +│ ├── console-error-monitor.js # Мониторинг через Playwright MCP +│ └── link-checker.js # Проверка ссылок ├── visual/ -│ ├── baseline/ # Эталонные скриншоты -│ ├── current/ # Текущие скриншоты -│ └── diff/ # Разница (красное) -├── reports/ -│ ├── web-test-report.html # HTML отчёт -│ ├── web-test-report.json # JSON отчёт -│ └── screenshots/ # Скриншоты -├── console/ -├── links/ -├── forms/ -├── run-all-tests.js # Главный runner -└── package.json +│ ├── baseline/ # Эталонные скриншоты (git-tracked) +│ ├── current/ # Текущие скриншоты (gitignored) +│ └── diff/ # Diff-изображения (gitignored) +├── reports/ # JSON-отчёты (gitignored) +│ ├── visual-test-report.json +│ └── console-error-report.json +├── package.json +└── README.md ``` ## Переменные окружения | Переменная | По умолчанию | Описание | |------------|--------------|----------| -| `TARGET_URL` | `http://localhost:3000` | URL для тестирования | -| `MCP_PORT` | `8931` | Порт Playwright MCP | -| `REPORTS_DIR` | `./reports` | Папка для отчётов | +| `TARGET_URL` | `http://host.docker.internal:3000` | URL тестируемого приложения | +| `PAGES` | `/,/admin/login` | Список путей через запятую | | `PIXELMATCH_THRESHOLD` | `0.05` | Допустимый % отличий (5%) | -| `AUTO_CREATE_ISSUES` | `false` | Авто-создание Gitea Issues | -| `GITEA_TOKEN` | - | Токен Gitea API | -| `GITEA_REPO` | `UniqueSoft/APAW` | Репозиторий | +| `REPORTS_DIR` | `./reports` | Папка для отчётов | -## Visual Regression Testing +## Visual Regression — как работает -### Как работает +1. **Захват скриншотов** — Playwright открывает страницу в 3 viewport (mobile 375x667, tablet 768x1024, desktop 1280x720) +2. **Извлечение элементов** — обходит DOM, собирает bbox, tag, className, text, href для каждого видимого элемента +3. **Сравнение** — pixelmatch сравнивает текущие PNG с baseline, генерирует diff-изображение +4. **Отчёт** — JSON с результатами: элементы, console/network ошибки, diff-процент -1. Делает скриншот каждой страницы в 3 разрешениях (mobile, tablet, desktop) -2. Сравнивает с baseline (эталоном) через pixelmatch -3. Генерирует diff изображение (красные пиксели = отличия) -4. Создаёт отчёт с процентом изменившихся пикселей +### Baseline-скриншоты -### Эталонные скриншоты +При первом запуске без baseline скрипт автоматически создаёт их. Для обновления: ```bash -# Создать эталон для новой страницы -node tests/scripts/compare-screenshots.js --baseline - -# Обновить эталон после изменений -cp tests/visual/current/*.png tests/visual/baseline/ +docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=https://example.com screenshot-baseline ``` ### Обнаруживаемые проблемы -- ✅ Наложение элементов (кнопка на кнопку) -- ✅ Сдвиг шрифтов (текст поехал) -- ✅ Неверные цвета (фон не тот) -- ✅ Отсутствующие элементы (кнопка пропала) -- ✅ Лишние элементы (появился артефакт) +- Наложение элементов (кнопка вне viewportа) +- Сдвиг шрифтов / неверные цвета +- Отсутствующие / лишние элементы +- Микро-кнопки (width < 10px) +- Console JS errors +- Network errors (4xx/5xx) -## Console Error Detection +## Пример отчёта -### Что ловит - -| Тип | Пример | -|-----|--------| -| JavaScript Error | `TypeError: Cannot read property 'x' of undefined` | -| Syntax Error | `Unexpected token '<'` | -| Network Error | `Failed to fetch /api/users` | -| 404 Error | `GET /script.js 404 (Not Found)` | -| 500 Error | `POST /api/submit 500 (Internal Server Error)` | - -### Авто-исправление - -При `AUTO_CREATE_ISSUES=true`: - -``` -[Console Error Detected] - ↓ -[Gitea Issue Created] - ↓ -[@the-fixer Agent] - ↓ -[PR with Fix Created] - ↓ -[Issue Closed] +```json +{ + "summary": { + "screenshotsCaptured": 3, + "totalElements": 702, + "comparisonsPassed": 3, + "comparisonsFailed": 0, + "totalConsoleErrors": 0, + "totalNetworkErrors": 25 + }, + "elements": { + "homepage_desktop": [ + { "tag": "button", "text": "Buy Now", "bbox": {"x":318,"y":349,"width":644,"height":47} } + ] + } +} ``` -## Docker Compose +## Docker-образ -### Основной контейнер - -```yaml -services: - playwright-mcp: - image: mcr.microsoft.com/playwright/mcp:latest - ports: - - "8931:8931" - command: node cli.js --headless --browser chromium --no-sandbox --port 8931 --host 0.0.0.0 - shm_size: '2gb' -``` - -### Профили - -```bash -# Только visual testing -docker compose -f docker-compose.web-testing.yml --profile visual up - -# Все тесты -docker compose -f docker-compose.web-testing.yml --profile full up -``` - -## CI/CD Integration - -### GitHub Actions - -```yaml -name: Web Testing -on: [push] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Start Playwright MCP - run: docker compose -f docker-compose.web-testing.yml up -d - - - name: Run Tests - run: cd tests && npm install && npm test - env: - TARGET_URL: ${{ secrets.APP_URL }} - AUTO_CREATE_ISSUES: true - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - - - name: Upload Report - uses: actions/upload-artifact@v3 - with: - name: web-test-report - path: tests/reports/ -``` - -## Troubleshooting - -### MCP не отвечает - -```bash -# Проверить контейнер -docker ps | grep playwright - -# Перезапустить -docker compose -f docker-compose.web-testing.yml restart - -# Логи -docker compose -f docker-compose.web-testing.yml logs -f -``` - -### Скриншоты пустые - -```bash -# Увеличить timeout -export TIMEOUT=10000 - -# Проверить что headless включён -# (для Docker обязателен) -docker compose -f docker-compose.web-testing.yml config | grep headless -``` - -### Высокий процент ложных срабатываний - -```bash -# Увеличить порог до 10% -export PIXELMATCH_THRESHOLD=0.10 - -# Или отключить для конкретного теста -node tests/scripts/compare-screenshots.js --no-compare --create-baseline -``` +Используется `mcr.microsoft.com/playwright:v1.52.0-noble` — предустановленный Playwright с Chromium. ## See Also -- `.kilo/skills/web-testing/SKILL.md` - Полная документация -- `.kilo/commands/web-test.md` - Команда тестирования -- `.kilo/commands/web-test-fix.md` - Тестирование с авто-исправлением -- `docker-compose.web-testing.yml` - Docker конфигурация \ No newline at end of file +- `docker/docker-compose.web-testing.yml` — Docker Compose конфигурация +- `.kilo/agents/visual-tester.md` — Агент визуального тестирования +- `.kilo/commands/e2e-test.md` — E2E workflow \ No newline at end of file diff --git a/tests/package.json b/tests/package.json index 3111df4..9be71bd 100644 --- a/tests/package.json +++ b/tests/package.json @@ -1,33 +1,31 @@ { "name": "apaw-web-testing", - "version": "1.0.0", + "version": "2.0.0", "description": "Web application testing suite for APAW - Visual regression, link checking, form testing, console error detection", - "main": "tests/run-all-tests.js", + "main": "scripts/visual-test-pipeline.js", "scripts": { - "test": "node tests/run-all-tests.js", - "test:visual": "node tests/scripts/compare-screenshots.js", - "test:links": "node tests/scripts/link-checker.js", - "test:console": "node tests/scripts/console-error-monitor.js", - "docker:up": "docker compose -f docker-compose.web-testing.yml up -d", - "docker:down": "docker compose -f docker-compose.web-testing.yml down", - "docker:logs": "docker compose -f docker-compose.web-testing.yml logs -f", - "report": "open tests/reports/web-test-report.html || xdg-open tests/reports/web-test-report.html" + "test": "node scripts/visual-test-pipeline.js", + "test:visual": "node scripts/visual-test-pipeline.js", + "test:baseline": "node scripts/capture-screenshots.js baseline", + "test:current": "node scripts/capture-screenshots.js current", + "test:compare": "node scripts/compare-screenshots.js", + "test:console": "node scripts/console-error-monitor-standalone.js", + "test:links": "node scripts/link-checker.js" }, "keywords": [ "web-testing", "visual-regression", "e2e", "playwright", - "mcp", "kilo-code" ], "author": "APAW Team", "license": "MIT", "dependencies": { "pixelmatch": "^5.3.0", + "playwright": "^1.52.0", "pngjs": "^7.0.0" }, - "devDependencies": {}, "engines": { "node": ">=18.0.0" } diff --git a/tests/scripts/capture-screenshots.js b/tests/scripts/capture-screenshots.js new file mode 100644 index 0000000..9d4b3b9 --- /dev/null +++ b/tests/scripts/capture-screenshots.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Screenshot Capture Script for Visual Regression Testing + * + * Captures screenshots of web pages at multiple viewports using Playwright. + * Used to create baseline or current screenshots. + * + * Usage: node capture-screenshots.js [baseline|current] + * baseline - Save to tests/visual/baseline/ + * current - Save to tests/visual/current/ + */ + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const TARGET_URL = process.env.TARGET_URL || 'http://host.docker.internal:3000'; +const MODE = process.argv[2] || 'current'; + +const VIEWPORTS = [ + { name: 'mobile', width: 375, height: 667 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 720 }, +]; + +const PAGES = [ + { name: 'homepage', path: '/' }, + { name: 'admin-login', path: '/admin/login' }, +]; + +const SCREENSHOT_BASE = path.join(__dirname, '..', 'visual'); + +async function captureScreenshots() { + const outputDir = path.join(SCREENSHOT_BASE, MODE); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + console.log(`=== Screenshot Capture: ${MODE} ===\n`); + console.log(`Target URL: ${TARGET_URL}`); + console.log(`Output: ${outputDir}\n`); + + const browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + let totalCaptured = 0; + let totalFailed = 0; + + for (const page_config of PAGES) { + for (const viewport of VIEWPORTS) { + const filename = `${page_config.name}_${viewport.name}.png`; + const filePath = path.join(outputDir, filename); + + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + }); + + const page = await context.newPage(); + + try { + const url = `${TARGET_URL}${page_config.path}`; + console.log(` Capturing: ${url} [${viewport.name}]`); + + await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 }); + await page.waitForTimeout(1000); + + await page.screenshot({ + path: filePath, + fullPage: true, + }); + + const fileSize = fs.statSync(filePath).size; + console.log(` ✅ Saved: ${filename} (${(fileSize / 1024).toFixed(1)} KB)`); + totalCaptured++; + } catch (error) { + console.log(` ❌ Failed: ${filename} - ${error.message}`); + totalFailed++; + } finally { + await context.close(); + } + } + } + + await browser.close(); + + console.log(`\n📊 Summary:`); + console.log(` Mode: ${MODE}`); + console.log(` ✅ Captured: ${totalCaptured}`); + console.log(` ❌ Failed: ${totalFailed}`); + console.log(` 📁 Output: ${outputDir}`); + + process.exit(totalFailed > 0 ? 1 : 0); +} + +captureScreenshots().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/tests/scripts/console-error-monitor-standalone.js b/tests/scripts/console-error-monitor-standalone.js new file mode 100644 index 0000000..69a8543 --- /dev/null +++ b/tests/scripts/console-error-monitor-standalone.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Console Error Monitor (Standalone) + * + * Captures console errors from web pages using Playwright directly + * (no Playwright MCP dependency). Detects JS errors, network failures, warnings. + * + * Usage: node console-error-monitor-standalone.js + * + * Environment: + * TARGET_URL - App URL (default: http://host.docker.internal:3000) + * REPORTS_DIR - Reports output dir + */ + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const TARGET_URL = process.env.TARGET_URL || 'http://host.docker.internal:3000'; +const REPORTS_DIR = process.env.REPORTS_DIR || path.join(__dirname, '..', 'reports'); + +const PAGES = [ + { name: 'homepage', path: '/' }, + { name: 'admin-login', path: '/admin/login' }, +]; + +const VIEWPORT = { width: 1280, height: 720 }; + +async function main() { + console.log('═══════════════════════════════════════════════════'); + console.log(' Console Error Monitor (Standalone)'); + console.log('═══════════════════════════════════════════════════\n'); + console.log(`Target: ${TARGET_URL}\n`); + + if (!fs.existsSync(REPORTS_DIR)) fs.mkdirSync(REPORTS_DIR, { recursive: true }); + + const browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const allErrors = []; + const allWarnings = []; + const allNetworkErrors = []; + + for (const pageConf of PAGES) { + const url = `${TARGET_URL}${pageConf.path}`; + console.log(`🔍 Checking: ${pageConf.name} (${url})`); + + const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1 }); + const page = await context.newPage(); + + const consoleErrors = []; + const consoleWarnings = []; + const networkErrors = []; + + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push({ text: msg.text(), location: msg.location() }); + } else if (msg.type() === 'warning') { + consoleWarnings.push({ text: msg.text(), location: msg.location() }); + } + }); + + page.on('requestfailed', request => { + networkErrors.push({ + url: request.url(), + method: request.method(), + failure: request.failure()?.errorText || 'Unknown', + }); + }); + + page.on('response', response => { + if (response.status() >= 400) { + networkErrors.push({ + url: response.url(), + status: response.status(), + method: response.request().method(), + }); + } + }); + + try { + const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 }); + await page.waitForTimeout(2000); + + if (!response || response.status() >= 400) { + console.log(` ❌ HTTP ${response?.status() || 'no response'}`); + } else { + console.log(` ✅ HTTP ${response.status()}`); + } + } catch (err) { + console.log(` ❌ Navigation error: ${err.message}`); + } + + if (consoleErrors.length > 0) { + console.log(` ❌ Console errors: ${consoleErrors.length}`); + consoleErrors.forEach(e => console.log(` - ${e.text.slice(0, 100)}`)); + } else { + console.log(` ✅ No console errors`); + } + + if (consoleWarnings.length > 0) { + console.log(` ⚠️ Console warnings: ${consoleWarnings.length}`); + consoleWarnings.forEach(w => console.log(` - ${w.text.slice(0, 100)}`)); + } + + if (networkErrors.length > 0) { + console.log(` ❌ Network errors: ${networkErrors.length}`); + networkErrors.forEach(e => console.log(` - ${e.status || e.failure} ${e.url.slice(0, 80)}`)); + } else { + console.log(` ✅ No network errors`); + } + + allErrors.push(...consoleErrors.map(e => ({ ...e, page: pageConf.name }))); + allWarnings.push(...consoleWarnings.map(w => ({ ...w, page: pageConf.name }))); + allNetworkErrors.push(...networkErrors.map(e => ({ ...e, page: pageConf.name }))); + + await context.close(); + console.log(''); + } + + await browser.close(); + + const totalIssues = allErrors.length + allNetworkErrors.length; + + const report = { + timestamp: new Date().toISOString(), + targetUrl: TARGET_URL, + pages: PAGES.map(p => p.name), + summary: { + consoleErrors: allErrors.length, + consoleWarnings: allWarnings.length, + networkErrors: allNetworkErrors.length, + totalIssues, + }, + consoleErrors: allErrors, + consoleWarnings: allWarnings, + networkErrors: allNetworkErrors, + }; + + const reportPath = path.join(REPORTS_DIR, 'console-error-report.json'); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + + console.log('═══════════════════════════════════════════════════'); + console.log(` 📊 Results:`); + console.log(` Console errors: ${allErrors.length}`); + console.log(` Console warnings: ${allWarnings.length}`); + console.log(` Network errors: ${allNetworkErrors.length}`); + console.log(` Total issues: ${totalIssues}`); + console.log(` 📄 Report: ${reportPath}`); + console.log('═══════════════════════════════════════════════════\n'); + + process.exit(totalIssues > 0 ? 1 : 0); +} + +main().catch(err => { + console.error('Fatal:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/tests/scripts/visual-test-pipeline.js b/tests/scripts/visual-test-pipeline.js new file mode 100644 index 0000000..498c630 --- /dev/null +++ b/tests/scripts/visual-test-pipeline.js @@ -0,0 +1,325 @@ +#!/usr/bin/env node +/** + * Visual Test Pipeline — Full Analysis + * + * Captures screenshots, extracts UI elements with bounding boxes, + * detects console errors, and compares against baselines. + * + * Usage: node visual-test-pipeline.js [URL] + * + * Environment: + * TARGET_URL - App URL (default: http://host.docker.internal:3000) + * PIXELMATCH_THRESHOLD - Diff threshold (default: 0.05 = 5%) + * PAGES - Comma-separated page paths (default: /,/admin/login) + */ + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const TARGET_URL = process.argv[2] || process.env.TARGET_URL || 'http://host.docker.internal:3000'; +const THRESHOLD = parseFloat(process.env.PIXELMATCH_THRESHOLD || '0.05'); +const PAGES_ARG = process.env.PAGES || '/,/admin/login'; +const PAGE_PATHS = PAGES_ARG.split(',').map(p => p.trim()).filter(Boolean); + +const VISUAL_DIR = path.join(__dirname, '..', 'visual'); +const BASELINE_DIR = path.join(VISUAL_DIR, 'baseline'); +const CURRENT_DIR = path.join(VISUAL_DIR, 'current'); +const DIFF_DIR = path.join(VISUAL_DIR, 'diff'); +const REPORTS_DIR = path.join(__dirname, '..', 'reports'); + +const VIEWPORTS = [ + { name: 'mobile', width: 375, height: 667 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 720 }, +]; + +function pageNameFromPath(p) { + if (p === '/' || p === '') return 'homepage'; + return p.replace(/^\//, '').replace(/[\/\.]/g, '-'); +} + +const PAGES = PAGE_PATHS.map(p => ({ name: pageNameFromPath(p), path: p.startsWith('/') ? p : '/' + p })); + +function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +/** + * Extract UI elements with bounding boxes from page + */ +async function extractElements(page) { + return await page.evaluate(() => { + const elements = []; + const seen = new Set(); + + function processNode(node) { + if (node.nodeType !== 1) return; + const tag = node.tagName.toLowerCase(); + const skipTags = new Set(['script','style','link','meta','noscript','svg','path','br','hr','wbr']); + if (skipTags.has(tag)) return; + + const rect = node.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return; + + const id = `${tag}-` + (node.id || '') + '-' + Math.random().toString(36).slice(2, 8); + if (seen.has(id)) return; + seen.add(id); + + const styles = window.getComputedStyle(node); + const el = { + tag, + id: node.id || null, + className: node.className?.toString()?.slice(0, 120) || null, + text: (node.textContent || '').slice(0, 80).trim() || null, + href: node.href || null, + type: node.type || null, + placeholder: node.placeholder || null, + role: node.getAttribute('role') || null, + ariaLabel: node.getAttribute('aria-label') || null, + visible: styles.display !== 'none' && styles.visibility !== 'hidden' && styles.opacity !== '0', + bbox: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + }; + elements.push(el); + } + + function walk(root) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false); + let node; + while (node = walker.nextNode()) processNode(node); + } + + walk(document.body); + return elements; + }); +} + +/** + * Capture screenshots and extract elements for a single page+viewport + */ +async function capturePage(browser, pageConf, vp, outputDir, mode) { + const filename = `${pageConf.name}_${vp.name}.png`; + const filePath = path.join(outputDir, filename); + const url = `${TARGET_URL}${pageConf.path}`; + + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 1, + }); + const page = await context.newPage(); + + const consoleErrors = []; + const networkErrors = []; + + page.on('console', msg => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + page.on('response', resp => { + if (resp.status() >= 400) networkErrors.push({ url: resp.url(), status: resp.status() }); + }); + page.on('requestfailed', req => { + networkErrors.push({ url: req.url(), failure: req.failure()?.errorText || 'failed' }); + }); + + try { + console.log(` Capturing: ${pageConf.name} @ ${vp.name} (${vp.width}x${vp.height})`); + const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 }); + await page.waitForTimeout(1500); + + await page.screenshot({ path: filePath, fullPage: true }); + const fileSize = fs.statSync(filePath).size; + + const elements = await extractElements(page); + const title = await page.title(); + + console.log(` ✅ ${filename} (${(fileSize / 1024).toFixed(1)} KB, ${elements.length} elements)`); + + return { + filename, page: pageConf.name, viewport: vp.name, status: 'PASS', size: fileSize, + url, httpStatus: response?.status() || null, title, + elements, consoleErrors, networkErrors, + }; + } catch (err) { + console.log(` ❌ ${filename}: ${err.message}`); + return { filename, page: pageConf.name, viewport: vp.name, status: 'FAIL', error: err.message, elements: [], consoleErrors, networkErrors: [] }; + } finally { + await context.close(); + } +} + +async function captureAll(mode) { + ensureDir(mode === 'baseline' ? BASELINE_DIR : CURRENT_DIR); + const outputDir = mode === 'baseline' ? BASELINE_DIR : CURRENT_DIR; + + console.log(`\n📸 Capturing ${mode} screenshots...`); + console.log(` Target: ${TARGET_URL}`); + console.log(` Pages: ${PAGES.map(p => p.path).join(', ')}`); + console.log(` Output: ${outputDir}\n`); + + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); + const results = []; + + for (const pageConf of PAGES) { + for (const vp of VIEWPORTS) { + const r = await capturePage(browser, pageConf, vp, outputDir, mode); + results.push(r); + } + } + + await browser.close(); + return results; +} + +async function compareScreenshots() { + const pixelmatch = require('pixelmatch'); + const PNG = require('pngjs').PNG; + ensureDir(DIFF_DIR); + + console.log(`\n🔍 Comparing screenshots (threshold: ${THRESHOLD * 100}%)...\n`); + + const baselines = fs.existsSync(BASELINE_DIR) + ? fs.readdirSync(BASELINE_DIR).filter(f => f.endsWith('.png')) + : []; + + const results = []; + let passed = 0, failed = 0; + + for (const file of baselines) { + const currentPath = path.join(CURRENT_DIR, file); + const diffPath = path.join(DIFF_DIR, file.replace('.png', '_diff.png')); + + if (!fs.existsSync(currentPath)) { + console.log(` ⚠️ Missing current: ${file}`); + results.push({ filename: file, status: 'MISSING', diffPercent: null }); + failed++; + continue; + } + + try { + const baselineImg = PNG.sync.read(fs.readFileSync(path.join(BASELINE_DIR, file))); + const currentImg = PNG.sync.read(fs.readFileSync(currentPath)); + const { width, height } = baselineImg; + + if (width !== currentImg.width || height !== currentImg.height) { + console.log(` ❌ Size mismatch: ${file}`); + results.push({ filename: file, status: 'SIZE_MISMATCH', diffPercent: null }); + failed++; + continue; + } + + const diffImg = new PNG({ width, height }); + const diffPixels = pixelmatch(baselineImg.data, currentImg.data, diffImg.data, width, height, { threshold: 0.1, diffColor: [255, 0, 0] }); + fs.writeFileSync(diffPath, PNG.sync.write(diffImg)); + + const diffPercent = (diffPixels / (width * height)) * 100; + const ok = diffPercent <= THRESHOLD * 100; + ok ? passed++ : failed++; + console.log(` ${ok ? '✅' : '❌'} ${file}: ${diffPercent.toFixed(2)}% diff`); + results.push({ filename: file, status: ok ? 'PASS' : 'FAIL', diffPercent: diffPercent.toFixed(2), diffPixels, totalPixels: width * height }); + } catch (err) { + console.log(` ❌ Error: ${file}: ${err.message}`); + results.push({ filename: file, status: 'ERROR', error: err.message }); + failed++; + } + } + + return { results, passed, failed }; +} + +async function main() { + console.log('═══════════════════════════════════════════════════'); + console.log(' Visual Test Pipeline — Full Analysis'); + console.log('═══════════════════════════════════════════════════\n'); + + ensureDir(REPORTS_DIR); + + const hasBaselines = fs.existsSync(BASELINE_DIR) && + fs.readdirSync(BASELINE_DIR).filter(f => f.endsWith('.png')).length > 0; + + if (!hasBaselines) { + console.log('⚠️ No baselines — capturing baseline screenshots first.\n'); + await captureAll('baseline'); + console.log('\n✅ Baselines created. Now capturing current screenshots.\n'); + } + + const captureResults = await captureAll('current'); + const compareResult = await compareScreenshots(); + + const allElements = {}; + const allConsoleErrors = []; + const allNetworkErrors = []; + + for (const r of captureResults) { + const key = `${r.page}_${r.viewport}`; + allElements[key] = r.elements || []; + if (r.consoleErrors?.length) allConsoleErrors.push(...r.consoleErrors.map(e => ({ page: r.page, viewport: r.viewport, error: e }))); + if (r.networkErrors?.length) allNetworkErrors.push(...r.networkErrors.map(e => ({ page: r.page, viewport: r.viewport, ...e }))); + } + + const report = { + timestamp: new Date().toISOString(), + targetUrl: TARGET_URL, + pages: PAGES.map(p => p.path), + viewports: VIEWPORTS.map(v => v.name), + threshold: THRESHOLD, + summary: { + screenshotsCaptured: captureResults.filter(r => r.status === 'PASS').length, + screenshotsFailed: captureResults.filter(r => r.status === 'FAIL').length, + comparisonsPassed: compareResult.passed, + comparisonsFailed: compareResult.failed, + totalElements: Object.values(allElements).reduce((s, a) => s + a.length, 0), + totalConsoleErrors: allConsoleErrors.length, + totalNetworkErrors: allNetworkErrors.length, + overallPassed: compareResult.passed >= compareResult.failed && captureResults.filter(r => r.status === 'FAIL').length === 0, + }, + capture: captureResults.map(r => ({ + filename: r.filename, page: r.page, viewport: r.viewport, status: r.status, + httpStatus: r.httpStatus, title: r.title, + elementCount: r.elements?.length || 0, + consoleErrorCount: r.consoleErrors?.length || 0, + networkErrorCount: r.networkErrors?.length || 0, + })), + elements: allElements, + consoleErrors: allConsoleErrors, + networkErrors: allNetworkErrors, + comparison: compareResult.results, + }; + + const reportPath = path.join(REPORTS_DIR, 'visual-test-report.json'); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + + console.log('\n═══════════════════════════════════════════════════'); + console.log(` 📊 RESULTS SUMMARY`); + console.log(` ─────────────────────────────────────────────────`); + console.log(` Screenshots: ${report.summary.screenshotsCaptured} captured, ${report.summary.screenshotsFailed} failed`); + console.log(` Elements: ${report.summary.totalElements}`); + console.log(` Comparison: ${compareResult.passed} passed, ${compareResult.failed} failed`); + console.log(` Console Errs: ${allConsoleErrors.length}`); + console.log(` Network Errs: ${allNetworkErrors.length}`); + + if (allConsoleErrors.length > 0) { + console.log(`\n ❌ Console Errors:`); + for (const e of allConsoleErrors.slice(0, 10)) { + console.log(` [${e.page}/${e.viewport}] ${e.error.slice(0, 120)}`); + } + } + + if (allNetworkErrors.length > 0) { + console.log(`\n ❌ Network Errors:`); + for (const e of allNetworkErrors.slice(0, 10)) { + console.log(` [${e.page}/${e.viewport}] ${e.status || e.failure} ${e.url?.slice(0, 80)}`); + } + } + + console.log(`\n 📄 Report: ${reportPath}`); + console.log('═══════════════════════════════════════════════════\n'); + + process.exit(report.summary.overallPassed ? 0 : 1); +} + +main().catch(err => { console.error('Fatal:', err); process.exit(1); }); \ No newline at end of file diff --git a/tests/visual/baseline/homepage_desktop.png b/tests/visual/baseline/homepage_desktop.png new file mode 100644 index 0000000..e8b9131 Binary files /dev/null and b/tests/visual/baseline/homepage_desktop.png differ diff --git a/tests/visual/baseline/homepage_mobile.png b/tests/visual/baseline/homepage_mobile.png new file mode 100644 index 0000000..2690d44 Binary files /dev/null and b/tests/visual/baseline/homepage_mobile.png differ diff --git a/tests/visual/baseline/homepage_tablet.png b/tests/visual/baseline/homepage_tablet.png new file mode 100644 index 0000000..0e064f1 Binary files /dev/null and b/tests/visual/baseline/homepage_tablet.png differ