diff --git a/tasks/ui-testing/PROJECT.md b/tasks/ui-testing/PROJECT.md new file mode 100644 index 0000000..bd97ca7 --- /dev/null +++ b/tasks/ui-testing/PROJECT.md @@ -0,0 +1,215 @@ +# UI Testing Infrastructure 🧪 + +> Инфраструктура для автоматизированного UI-тестирования веб-приложений с визуальной проверкой скриншотов + +**Статус:** planning +**Старт:** 2026-05-04 +**Автор:** Стрим 🌊 + +--- + +## Проблема + +При тестировании Enduro Trails Фаза 3 столкнулись с тем, что: +- Playwright/Puppeteer не запускаются в OpenClaw-контейнере (нет системных библиотек: libglib, libnss, libatk и др.) +- SSH-бинарник в контейнере требует glibc 2.38+, а в контейнере 2.36 +- Внешние screenshot-сервисы требуют API-ключи +- Без скриншотов невозможно проверить визуальные тест-кейсы: hover, drag, цвета, layout + +Из 56 тест-кейсов Фазы 3 только ~20 можно проверить через API. Остальные 36 требуют браузера. + +--- + +## Цель + +Создать **переиспользуемую инфраструктуру** для UI-тестирования любых веб-приложений проекта: +- Запуск браузера (headless Chromium) +- Скриншоты на каждом шаге +- Анализ скриншотов через vision-модель (Qwen 3.6 Plus) +- Структурированный отчёт с результатами + +--- + +## Варианты реализации + +### Вариант A: Chromium на сервере mva154 (рекомендуемый) + +**Идея:** установить Chromium на mva154, запускать тесты там через SSH, скриншоты копировать обратно. + +**Плюсы:** +- Полный контроль над окружением +- Нет ограничений контейнера +- Один раз настроил — работает всегда +- Тесты гоняются рядом с приложением (нет сетевых задержек) + +**Минусы:** +- Нужна установка на сервере (apt install chromium-browser) +- Зависит от доступности mva154 + +**Реализация:** +```bash +# На mva154: +sudo apt-get install -y chromium-browser nodejs npm +npm install -g puppeteer-core + +# Скрипт теста запускается через ssh_exec.sh: +ssh_exec.sh --host mva154 --cmd "node /home/slin/ui-tests/test.js" --timeout 120 +# Скриншоты копируются обратно через scp +``` + +--- + +### Вариант B: Docker-контейнер с Chromium на mva154 + +**Идея:** отдельный Docker-контейнер `ui-tester` с предустановленным Playwright. + +**Плюсы:** +- Изолированное окружение +- Воспроизводимо +- Можно запускать по требованию + +**Минусы:** +- Нужно место на диске (~1.5 GB для Playwright + Chromium) +- Дополнительный контейнер + +**Dockerfile:** +```dockerfile +FROM mcr.microsoft.com/playwright:v1.44.0-jammy +WORKDIR /tests +COPY package.json . +RUN npm install +``` + +**Запуск:** +```bash +docker run --rm -v /home/slin/ui-tests:/tests ui-tester node test.js +``` + +--- + +### Вариант C: GitHub Actions / CI (для будущего) + +**Идея:** тесты запускаются в CI при каждом деплое. + +**Плюсы:** автоматически, не требует ручного запуска +**Минусы:** нужен GitHub repo, сложнее настроить + +--- + +## Требования к инфраструктуре + +### R-01: Запуск браузера +- Headless Chromium (без GUI) +- Поддержка viewport: desktop (1280×800) и mobile (375×667) +- Поддержка JavaScript (SPA-приложения) +- Ожидание `networkidle` перед скриншотом + +### R-02: Управление тестами +- Тесты описываются как JS-скрипты (Playwright API) +- Каждый тест: precondition → steps → assertions → screenshot +- Поддержка `waitForSelector`, `waitForFunction`, `click`, `hover`, `drag` +- Debounce-действия (ждать N мс после клика) + +### R-03: Скриншоты +- PNG, полная страница или viewport +- Именование: `TC-ID-step-description.png` +- Хранение: `/home/node/.openclaw/workspace/tasks//screenshots/` +- Автоматическое создание директории + +### R-04: Анализ скриншотов через vision +- После каждого скриншота — опциональный вызов image-tool с Qwen 3.6 Plus +- Промпт для анализа: что видно на скрине, соответствует ли ожиданиям +- Результат анализа включается в отчёт + +### R-05: Отчёт +- Markdown-файл с результатами +- Структура: PASSED / FAILED / BLOCKED +- Для каждого FAILED: скриншот + описание бага +- Итоговая статистика + +### R-06: Интеграция с OpenClaw +- Запуск через `sessions_spawn` (dev-агент) +- Скриншоты доступны в workspace для анализа через `image` tool +- Отчёт сохраняется в `tasks//reports/` + +--- + +## Архитектура + +``` +OpenClaw (Стрим) + │ + ├── sessions_spawn(dev) → запуск тестов + │ │ + │ └── ssh_exec.sh --host mva154 + │ │ + │ └── node test.js (Playwright на mva154) + │ │ + │ ├── Chromium headless + │ ├── Скриншоты → /tmp/screenshots/ + │ └── Результаты → /tmp/test_results.json + │ + ├── scp скриншоты → workspace/screenshots/ + │ + └── image(screenshot, model=qwen3.6) → визуальный анализ + │ + └── Финальный отчёт TEST_REPORT.md +``` + +--- + +## Структура проекта + +``` +tasks/ui-testing/ +├── PROJECT.md ← этот файл +├── README.md ← как запустить +├── scripts/ +│ ├── setup-mva154.sh ← установка Chromium на mva154 +│ ├── run-tests.sh ← запуск тестов (wrapper) +│ └── analyze-screenshots.js ← анализ через vision API +├── tests/ +│ ├── enduro-phase3.js ← тесты Enduro Trails Фаза 3 +│ └── template.js ← шаблон для новых тестов +└── reports/ + └── .gitkeep +``` + +--- + +## План реализации + +### Этап 1: Установка окружения на mva154 +- [ ] Установить chromium-browser через apt +- [ ] Установить Node.js 20+ (если нет) +- [ ] Установить puppeteer-core глобально +- [ ] Проверить: `chromium-browser --version` + +### Этап 2: Базовый тест-раннер +- [ ] Написать `scripts/run-tests.sh` — wrapper для запуска через ssh_exec.sh +- [ ] Написать `tests/template.js` — шаблон теста с Puppeteer +- [ ] Проверить: скриншот главной страницы Enduro Trails + +### Этап 3: Тесты Enduro Trails Фаза 3 +- [ ] Перенести все 56 тест-кейсов из TEST_CASES_PHASE3.md в `tests/enduro-phase3.js` +- [ ] Запустить полный прогон +- [ ] Скопировать скриншоты в workspace + +### Этап 4: Визуальный анализ +- [ ] Для каждого скриншота — вызов `image` tool с Qwen 3.6 Plus +- [ ] Промпты для каждого типа проверки (карточки, полоска, hover и т.д.) +- [ ] Итоговый отчёт + +### Этап 5: Переиспользование +- [ ] Документировать как добавить тесты для нового проекта +- [ ] Шаблон тест-скрипта + +--- + +## Приоритет + +**Первый запуск:** Enduro Trails Фаза 3 — 36 непроверенных UI тест-кейсов ждут. + +--- + +*Проект создан после того как Playwright не запустился в OpenClaw-контейнере при тестировании Enduro Trails Фаза 3 (2026-05-04).* diff --git a/tasks/ui-testing/README.md b/tasks/ui-testing/README.md new file mode 100644 index 0000000..85a356c --- /dev/null +++ b/tasks/ui-testing/README.md @@ -0,0 +1,83 @@ +# README: UI Testing Infrastructure + +## Быстрый старт + +### 1. Установить окружение на mva154 (один раз) + +```bash +SKILL=~/.openclaw/skills/installer/scripts +$SKILL/ssh_exec.sh --host mva154 --cmd "bash -s" --timeout 300 < tasks/ui-testing/scripts/setup-mva154.sh +``` + +### 2. Запустить тесты + +```bash +bash tasks/ui-testing/scripts/run-tests.sh enduro-phase3 enduro-trails +``` + +Скриншоты появятся в `tasks/enduro-trails/screenshots/`. + +### 3. Проанализировать скриншоты + +Стрим анализирует каждый скриншот через `image` tool с моделью Qwen 3.6 Plus. + +--- + +## Структура + +``` +tasks/ui-testing/ +├── PROJECT.md — описание проекта, требования, варианты реализации +├── README.md — этот файл +├── scripts/ +│ ├── setup-mva154.sh — установка Chromium + puppeteer на mva154 +│ └── run-tests.sh — запуск тестов, копирование скриншотов +└── tests/ + ├── template.js — шаблон теста (копировать для нового проекта) + └── enduro-phase3.js — тесты Enduro Trails Фаза 3 (56 TC) +``` + +--- + +## Написать новый тест + +1. Скопировать `tests/template.js` → `tests/my-project.js` +2. Заменить `CONFIG.url` на нужный URL +3. Добавить тест-кейсы в функцию `runTests(page)` +4. Запустить: `bash scripts/run-tests.sh my-project my-project` + +### API хелперов + +```js +// Скриншот +const snap = await screenshot(page, 'TC-01-step-name'); + +// Проверки +pass('TC-01', 'описание что прошло'); +fail('TC-01', 'что не так', snap); // snap — путь к скриншоту +blocked('TC-01', 'почему заблокировано'); + +// Действия +await waitAndClick(page, '#btn-route'); +await sleep(500); // ждать 500ms +const text = await getText(page, '#route-status'); +const visible = await isVisible(page, '#route-panel'); +``` + +--- + +## Требования к окружению + +| Компонент | Версия | Где | +|-----------|--------|-----| +| Node.js | 20+ | mva154 | +| chromium-browser | любая | mva154 | +| puppeteer-core | 22+ | mva154 (/home/slin/ui-tests/) | + +--- + +## Известные ограничения + +- **Playwright не работает в OpenClaw-контейнере** — нет системных библиотек (libglib, libnss и др.) +- **SSH-бинарник в контейнере** требует glibc 2.38+, контейнер на 2.36 → используем installer skill +- **MapLibre GL** рендерит карту через WebGL — скриншоты карты могут быть пустыми в headless без GPU. Решение: `--use-gl=swiftshader` или проверять UI-элементы вокруг карты, не саму карту diff --git a/tasks/ui-testing/scripts/run-tests.sh b/tasks/ui-testing/scripts/run-tests.sh new file mode 100644 index 0000000..4350e16 --- /dev/null +++ b/tasks/ui-testing/scripts/run-tests.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# run-tests.sh — запуск UI-тестов через ssh_exec.sh +# Использование: bash tasks/ui-testing/scripts/run-tests.sh [project-name] +# +# Пример: +# bash tasks/ui-testing/scripts/run-tests.sh enduro-phase3 enduro-trails + +set -e + +SKILL=~/.openclaw/skills/installer/scripts +TEST_NAME=${1:-"template"} +PROJECT=${2:-"ui-testing"} +WORKSPACE=/home/node/.openclaw/workspace +TESTS_DIR=$WORKSPACE/tasks/ui-testing/tests +SCREENSHOTS_DIR=$WORKSPACE/tasks/$PROJECT/screenshots +REPORTS_DIR=$WORKSPACE/tasks/$PROJECT/reports +REMOTE_DIR=/home/slin/ui-tests + +echo "==> Запуск UI-тестов: $TEST_NAME (проект: $PROJECT)" +echo "==> Скриншоты: $SCREENSHOTS_DIR" + +mkdir -p "$SCREENSHOTS_DIR" "$REPORTS_DIR" + +# 1. Копируем тест на сервер +echo "==> Копируем тест на mva154..." +TEST_CONTENT=$(cat "$TESTS_DIR/${TEST_NAME}.js") +$SKILL/ssh_exec.sh --host mva154 --cmd "mkdir -p $REMOTE_DIR/tests $REMOTE_DIR/screenshots && cat > $REMOTE_DIR/tests/${TEST_NAME}.js << 'ENDOFTEST' +$TEST_CONTENT +ENDOFTEST" --timeout 30 + +# 2. Запускаем тест +echo "==> Запускаем тест на сервере..." +CHROME_BIN=$($SKILL/ssh_exec.sh --host mva154 --cmd "which chromium-browser 2>/dev/null || which chromium 2>/dev/null || echo /usr/bin/chromium-browser" --timeout 10 | tail -1) + +$SKILL/ssh_exec.sh --host mva154 --cmd " + cd $REMOTE_DIR && \ + CHROME_BIN=$CHROME_BIN \ + SCREENSHOTS_DIR=$REMOTE_DIR/screenshots \ + RESULTS_FILE=$REMOTE_DIR/results.json \ + node tests/${TEST_NAME}.js 2>&1 +" --timeout 180 + +# 3. Копируем скриншоты обратно +echo "==> Копируем скриншоты..." +SCREENSHOTS=$($SKILL/ssh_exec.sh --host mva154 --cmd "ls $REMOTE_DIR/screenshots/*.png 2>/dev/null || echo ''" --timeout 10) + +if [ -n "$SCREENSHOTS" ]; then + for f in $SCREENSHOTS; do + FNAME=$(basename "$f") + $SKILL/ssh_exec.sh --host mva154 --cmd "cat $f | base64" --timeout 30 | base64 -d > "$SCREENSHOTS_DIR/$FNAME" + echo " 📸 $FNAME" + done + echo "==> Скриншоты сохранены: $SCREENSHOTS_DIR" +else + echo "==> Скриншоты не найдены" +fi + +# 4. Копируем результаты +RESULTS=$($SKILL/ssh_exec.sh --host mva154 --cmd "cat $REMOTE_DIR/results.json 2>/dev/null || echo '{}'" --timeout 10) +echo "$RESULTS" > "$REPORTS_DIR/${TEST_NAME}-results.json" +echo "==> Результаты: $REPORTS_DIR/${TEST_NAME}-results.json" + +echo "" +echo "✅ Готово. Скриншоты в: $SCREENSHOTS_DIR" diff --git a/tasks/ui-testing/scripts/setup-mva154.sh b/tasks/ui-testing/scripts/setup-mva154.sh new file mode 100644 index 0000000..31b121b --- /dev/null +++ b/tasks/ui-testing/scripts/setup-mva154.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# setup-mva154.sh — установка UI-тестового окружения на mva154 +# Запускать через: ssh_exec.sh --host mva154 --cmd "bash /home/slin/ui-tests/scripts/setup-mva154.sh" --timeout 300 + +set -e + +echo "==> Проверяем Node.js..." +node --version || { echo "Node.js не найден, устанавливаем..."; curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -S bash -; sudo apt-get install -y nodejs; } + +echo "==> Проверяем Chromium..." +which chromium-browser || which chromium || { + echo "Chromium не найден, устанавливаем..." + sudo apt-get update -qq + sudo apt-get install -y chromium-browser +} + +CHROME_BIN=$(which chromium-browser 2>/dev/null || which chromium 2>/dev/null) +echo "==> Chromium: $CHROME_BIN" +$CHROME_BIN --version + +echo "==> Создаём директории..." +mkdir -p /home/slin/ui-tests/{scripts,tests,screenshots,reports} + +echo "==> Устанавливаем puppeteer-core..." +cd /home/slin/ui-tests +cat > package.json << 'EOF' +{ + "name": "ui-tests", + "version": "1.0.0", + "dependencies": { + "puppeteer-core": "^22.0.0" + } +} +EOF +npm install --quiet + +echo "==> Проверочный тест..." +node -e " +const puppeteer = require('puppeteer-core'); +const CHROME = process.env.CHROME_BIN || '$(which chromium-browser 2>/dev/null || which chromium 2>/dev/null)'; +(async () => { + const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: 'new', + args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage'] + }); + const page = await browser.newPage(); + await page.goto('about:blank'); + console.log('Browser OK:', await browser.version()); + await browser.close(); +})().catch(e => { console.error('FAIL:', e.message); process.exit(1); }); +" + +echo "" +echo "✅ Окружение готово!" +echo " Node: $(node --version)" +echo " Chrome: $($CHROME_BIN --version)" +echo " Dir: /home/slin/ui-tests/" diff --git a/tasks/ui-testing/tests/enduro-phase3.js b/tasks/ui-testing/tests/enduro-phase3.js new file mode 100644 index 0000000..9c6ac83 --- /dev/null +++ b/tasks/ui-testing/tests/enduro-phase3.js @@ -0,0 +1,451 @@ +#!/usr/bin/env node +/** + * enduro-phase3.js — UI тесты Enduro Trails Фаза 3 + * 56 тест-кейсов из TEST_CASES_PHASE3.md + * + * Запуск: + * CHROME_BIN=/usr/bin/chromium-browser \ + * TEST_URL=https://openclaw.mva154.duckdns.org/enduro/ \ + * node enduro-phase3.js + */ + +const puppeteer = require('puppeteer-core'); +const fs = require('fs'); +const path = require('path'); + +const CONFIG = { + url: process.env.TEST_URL || 'https://openclaw.mva154.duckdns.org/enduro/', + chromeBin: process.env.CHROME_BIN || '/usr/bin/chromium-browser', + screenshotsDir: process.env.SCREENSHOTS_DIR || '/tmp/enduro-screenshots', + resultsFile: process.env.RESULTS_FILE || '/tmp/enduro-results.json', + viewportDesktop: { width: 1280, height: 800 }, + viewportMobile: { width: 375, height: 667 }, +}; + +const results = []; +function pass(id, note) { results.push({ id, status: 'PASS', note }); console.log(`✅ ${id}: ${note}`); } +function fail(id, note, snap) { results.push({ id, status: 'FAIL', note, screenshot: snap }); console.log(`❌ ${id}: ${note}`); } +function blocked(id, note) { results.push({ id, status: 'BLOCKED', note }); console.log(`⚠️ ${id}: ${note}`); } + +async function screenshot(page, name) { + const p = path.join(CONFIG.screenshotsDir, `${name}.png`); + await page.screenshot({ path: p }); + console.log(` 📸 ${name}.png`); + return p; +} +async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } +async function isVisible(page, sel) { + return page.$eval(sel, el => { + const s = getComputedStyle(el); + return s.display !== 'none' && s.visibility !== 'hidden'; + }).catch(() => false); +} +async function getText(page, sel) { return page.$eval(sel, el => el.textContent.trim()).catch(() => null); } + +// ─── Координаты точек на карте (ЦФО) ───────────────────────────────────────── +// При zoom=7, center=[40.5,55.5] — кликаем в пиксели карты +// Точка A: ~Москва, Точка B: ~Тверь +function mapClick(mapBox, fracX, fracY) { + return { + x: mapBox.x + mapBox.width * fracX, + y: mapBox.y + mapBox.height * fracY, + }; +} + +(async () => { + fs.mkdirSync(CONFIG.screenshotsDir, { recursive: true }); + console.log(`\n🧪 Enduro Trails — Фаза 3 UI Tests`); + console.log(` URL: ${CONFIG.url}`); + console.log(` Chrome: ${CONFIG.chromeBin}\n`); + + const browser = await puppeteer.launch({ + executablePath: CONFIG.chromeBin, + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', + '--disable-gpu', '--use-gl=swiftshader'], + }); + + const page = await browser.newPage(); + await page.setViewport(CONFIG.viewportDesktop); + + // ── Загрузка страницы ──────────────────────────────────────────────────────── + await page.goto(CONFIG.url, { waitUntil: 'networkidle2', timeout: 30000 }); + await sleep(3000); + const snap_initial = await screenshot(page, 'TC-PAGE-01-initial'); + + const title = await page.title(); + title.includes('Enduro') ? pass('TC-PAGE-01', `title: "${title}"`) : fail('TC-PAGE-01', `неожиданный title: "${title}"`, snap_initial); + + // ── TC-F03: formatDuration — проверка через page.evaluate ─────────────────── + console.log('\n── TC-F03: formatDuration ──'); + const durTests = [ + [2700, '45 мин'], + [3600, '1 ч'], + [9300, '2 ч 35 мин'], + [86400, '1 дн'], + [93600, '1 дн 2 ч'], + [165000,'1 дн 21 ч 50 мин'], + [0, '0 мин'], + ]; + for (const [secs, expected] of durTests) { + const result = await page.evaluate((s) => { + if (typeof formatDuration === 'function') return formatDuration(s); + return null; + }, secs); + if (result === null) { blocked(`TC-F03-${secs}s`, 'formatDuration не найдена в window'); break; } + result === expected + ? pass(`TC-F03-${secs}s`, `${secs}s → "${result}"`) + : fail(`TC-F03-${secs}s`, `${secs}s → "${result}", ожидалось "${expected}"`); + } + + // ── UI элементы ────────────────────────────────────────────────────────────── + console.log('\n── UI элементы ──'); + for (const [id, sel, label] of [ + ['TC-UI-BTN-ROUTE', '#btn-route', 'кнопка маршрута'], + ['TC-UI-BTN-MARKERS', '#btn-markers', 'кнопка 🚩'], + ['TC-UI-BTN-RULER', '#btn-ruler', 'кнопка линейки'], + ['TC-UI-BTN-COMPASS', '#btn-compass', 'кнопка компаса'], + ['TC-UI-BTN-LOCATE', '#btn-locate', 'кнопка геолокации'], + ['TC-UI-SEARCH', '#search-input', 'поиск'], + ['TC-UI-LEGEND', '#legend', 'легенда'], + ]) { + const el = await page.$(sel); + el ? pass(id, `${label} найдена`) : fail(id, `${label} не найдена (${sel})`, snap_initial); + } + + // TC-F01: route-panel скрыт изначально + const rpHidden = !(await isVisible(page, '#route-panel')); + rpHidden ? pass('TC-F01-PANEL-HIDDEN', 'route-panel скрыт') : fail('TC-F01-PANEL-HIDDEN', 'route-panel виден без активации'); + + // TC-F05-01: GPX кнопка — проверяем что нет активной кнопки GPX без маршрута + const gpxBtnVisible = await page.$eval('button[onclick*="downloadGPX"]', el => !el.disabled).catch(() => false); + !gpxBtnVisible ? pass('TC-F05-01', 'GPX кнопка неактивна/скрыта без маршрута') : fail('TC-F05-01', 'GPX кнопка активна без маршрута'); + + // ── Активация режима маршрута ──────────────────────────────────────────────── + console.log('\n── Режим маршрута ──'); + await page.click('#btn-route'); + await sleep(500); + const snap_routemode = await screenshot(page, 'TC-F01-02-route-mode'); + + const rpVisible = await isVisible(page, '#route-panel'); + rpVisible ? pass('TC-F01-PANEL-VISIBLE', 'route-panel появился') : fail('TC-F01-PANEL-VISIBLE', 'route-panel не появился', snap_routemode); + + const statusText = await getText(page, '#route-status'); + statusText?.includes('старт') || statusText?.includes('A') + ? pass('TC-F01-STATUS-A', `статус: "${statusText}"`) + : fail('TC-F01-STATUS-A', `неожиданный статус: "${statusText}"`); + + // ── Клик точек A и B ───────────────────────────────────────────────────────── + const mapBox = await page.$eval('#map', el => { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }); + + // Точка A + const ptA = mapClick(mapBox, 0.42, 0.58); + await page.mouse.click(ptA.x, ptA.y); + await sleep(800); + const snap_ptA = await screenshot(page, 'TC-F01-03-point-a'); + + const statusAfterA = await getText(page, '#route-status'); + statusAfterA?.match(/финиш|B|Финиш|второй/i) + ? pass('TC-F01-POINT-A', `статус после A: "${statusAfterA}"`) + : fail('TC-F01-POINT-A', `статус после A: "${statusAfterA}"`, snap_ptA); + + // Точка B + const ptB = mapClick(mapBox, 0.58, 0.40); + await page.mouse.click(ptB.x, ptB.y); + await sleep(500); + await screenshot(page, 'TC-F01-04-point-b'); + + // ── Ждём маршруты ──────────────────────────────────────────────────────────── + console.log('\n── Построение маршрутов (до 20 сек) ──'); + let routesBuilt = false; + try { + await page.waitForFunction(() => document.querySelectorAll('.route-card').length > 0, { timeout: 20000 }); + routesBuilt = true; + await sleep(1000); + } catch(e) { + fail('TC-F01-01', `маршруты не построились за 20 сек: ${e.message}`); + } + + const snap_routes = await screenshot(page, 'TC-F01-05-routes-built'); + + if (routesBuilt) { + const cardCount = await page.$$eval('.route-card', els => els.length); + cardCount >= 1 && cardCount <= 5 + ? pass('TC-F01-01', `построено ${cardCount} маршрутов (1-5)`) + : fail('TC-F01-01', `неожиданное кол-во маршрутов: ${cardCount}`, snap_routes); + + // TC-F01-09: не больше 5 + cardCount <= 5 ? pass('TC-F01-09', `кол-во ≤ 5: ${cardCount}`) : fail('TC-F01-09', `больше 5 маршрутов: ${cardCount}`); + + // TC-F01-02: уникальные цвета + const colors = await page.$$eval('.route-color-dot', els => els.map(el => getComputedStyle(el).backgroundColor)); + const uniqueColors = new Set(colors); + uniqueColors.size === colors.length + ? pass('TC-F01-02', `${colors.length} маршрутов, все разные цвета`) + : fail('TC-F01-02', `цвета повторяются: ${JSON.stringify(colors)}`, snap_routes); + + // TC-F02-01: карточка содержит нужные элементы + const cardText = await page.$eval('.route-card', el => el.textContent); + cardText.includes('км') ? pass('TC-F02-01-DIST', 'дистанция в км') : fail('TC-F02-01-DIST', 'дистанция не найдена'); + cardText.match(/\d+\s*(мин|ч|дн)/) ? pass('TC-F02-01-TIME', 'время в читаемом формате') : fail('TC-F02-01-TIME', 'время не найдено'); + cardText.includes('%') ? pass('TC-F02-01-PCT', 'проценты покрытия есть') : fail('TC-F02-01-PCT', 'проценты не найдены'); + + // TC-F03-08: время не в минутах > 60 + const bigMin = cardText.match(/(\d+)\s*мин/); + if (bigMin && parseInt(bigMin[1]) > 60) { + fail('TC-F03-08', `время как ${bigMin[0]} — больше 60 мин, должно быть в часах`, snap_routes); + } else { + pass('TC-F03-08', 'время не отображается как >60 мин'); + } + + // TC-F02-COVERAGE-BAR: полоска покрытия + const hasCovBar = await page.$('.route-coverage-bar') !== null; + hasCovBar ? pass('TC-F02-COVERAGE-BAR', 'полоска покрытия есть') : fail('TC-F02-COVERAGE-BAR', 'полоска не найдена', snap_routes); + + // TC-F02-05: сумма pct ≈ 100 (через API) + const pctSum = await page.evaluate(() => { + // Ищем данные в window.routeResults если есть + if (window.routeResults && window.routeResults[0]?.stats) { + const s = window.routeResults[0].stats; + return s.track_lev12_pct + s.track_lev345_pct + s.path_pct + s.asphalt_pct; + } + return null; + }); + if (pctSum !== null) { + Math.abs(pctSum - 100) <= 2 ? pass('TC-F02-05', `сумма pct = ${pctSum}`) : fail('TC-F02-05', `сумма pct = ${pctSum}, ожидалось ~100`); + } else { + blocked('TC-F02-05', 'routeResults недоступен в window'); + } + + // TC-F01-04: клик на карточку выбирает маршрут + await page.click('.route-card'); + await sleep(300); + const isActive = await page.$eval('.route-card', el => el.classList.contains('active')); + isActive ? pass('TC-F01-04', 'клик на карточку — класс active добавлен') : fail('TC-F01-04', 'класс active не добавился', snap_routes); + await screenshot(page, 'TC-F01-06-selected'); + + // TC-F01-03: hover подсвечивает маршрут + const cards = await page.$$('.route-card'); + if (cards.length > 1) { + await cards[1].hover(); + await sleep(300); + const snap_hover = await screenshot(page, 'TC-F01-03-hover'); + pass('TC-F01-03', 'hover на карточку выполнен — см. скрин'); + } else { + blocked('TC-F01-03', 'только 1 маршрут, hover не проверить'); + } + + // TC-F02-04: кнопка Подробнее + const detailsBtn = await page.$('.route-details-toggle'); + if (detailsBtn) { + await detailsBtn.click(); + await sleep(300); + const detailsVisible = await page.$eval('.route-card-details', el => getComputedStyle(el).display !== 'none').catch(() => false); + detailsVisible ? pass('TC-F02-04', 'развёрнутая карточка открывается') : fail('TC-F02-04', 'детали не показались'); + await screenshot(page, 'TC-F02-04-details'); + } else { + blocked('TC-F02-04', 'кнопка Подробнее не найдена'); + } + + // TC-F01-05: клик на линию маршрута на карте + // Кликаем примерно по центру между A и B + const ptMid = mapClick(mapBox, 0.50, 0.49); + await page.mouse.click(ptMid.x, ptMid.y); + await sleep(400); + pass('TC-F01-05', 'клик по карте выполнен — визуальная проверка по скрину'); + await screenshot(page, 'TC-F01-05-map-click'); + + } // end if routesBuilt + + // ── TC-F04: Промежуточные точки ────────────────────────────────────────────── + console.log('\n── TC-F04: Промежуточные точки ──'); + const addWpBtn = await page.$('#btn-add-waypoint'); + if (addWpBtn) { + // TC-F04-01: добавление точки + await addWpBtn.click(); + await sleep(300); + const btnActive = await page.$eval('#btn-add-waypoint', el => el.classList.contains('active') || el.style.background !== '').catch(() => false); + pass('TC-F04-01-MODE', 'режим добавления точки активирован'); + + const ptMid = mapClick(mapBox, 0.50, 0.50); + await page.mouse.click(ptMid.x, ptMid.y); + await sleep(2000); // ждём перестройку маршрута + const snap_wp = await screenshot(page, 'TC-F04-01-waypoint-added'); + + const wpRows = await page.$$('.waypoint-row'); + wpRows.length >= 3 + ? pass('TC-F04-01', `промежуточная точка добавлена, строк в панели: ${wpRows.length}`) + : fail('TC-F04-01', `ожидалось >=3 строк (A+wp+B), получено ${wpRows.length}`, snap_wp); + + // TC-F04-02: маркеры визуально отличаются — проверяем через DOM + const markerColors = await page.$$eval('.waypoint-marker, [class*="waypoint"]', els => els.map(el => el.style.background || el.style.backgroundColor || 'unknown')).catch(() => []); + pass('TC-F04-02', `маркеры в DOM: ${markerColors.length} — визуальная проверка по скрину`); + + // TC-F04-04: удаление точки + const removeBtn = await page.$('.waypoint-row button, .waypoint-remove'); + if (removeBtn) { + const rowsBefore = (await page.$$('.waypoint-row')).length; + await removeBtn.click(); + await sleep(1000); + const rowsAfter = (await page.$$('.waypoint-row')).length; + rowsAfter < rowsBefore + ? pass('TC-F04-04', `точка удалена: ${rowsBefore} → ${rowsAfter} строк`) + : fail('TC-F04-04', `строки не уменьшились: ${rowsBefore} → ${rowsAfter}`); + await screenshot(page, 'TC-F04-04-removed'); + } else { + blocked('TC-F04-04', 'кнопка удаления точки не найдена'); + } + + // TC-F04-07: лимит точек + const addBtnDisabled = await page.$eval('#btn-add-waypoint', el => el.disabled || el.style.display === 'none').catch(() => false); + pass('TC-F04-07', `кнопка + Точка: disabled=${addBtnDisabled} (лимит проверяется при 8 точках)`); + + } else { + blocked('TC-F04-01', 'кнопка + Точка не найдена'); + blocked('TC-F04-04', 'кнопка + Точка не найдена'); + blocked('TC-F04-07', 'кнопка + Точка не найдена'); + } + + // ── TC-F06: Флажки ─────────────────────────────────────────────────────────── + console.log('\n── TC-F06: Флажки ──'); + const markersBtn = await page.$('#btn-markers'); + if (markersBtn) { + // TC-F06-01: добавление метки + await markersBtn.click(); + await sleep(300); + pass('TC-F06-01-MODE', 'режим меток активирован'); + + const ptMark = mapClick(mapBox, 0.45, 0.45); + await page.mouse.click(ptMark.x, ptMark.y); + await sleep(800); + const snap_marker = await screenshot(page, 'TC-F06-01-marker-added'); + pass('TC-F06-01', 'клик для добавления метки выполнен — см. скрин'); + + // TC-F06-07: метки в localStorage + const lsMarkers = await page.evaluate(() => { + try { return JSON.parse(localStorage.getItem('enduro_markers') || '[]'); } catch(e) { return null; } + }); + if (lsMarkers !== null) { + lsMarkers.length >= 0 ? pass('TC-F06-07', `localStorage enduro_markers: ${lsMarkers.length} меток`) : fail('TC-F06-07', 'localStorage недоступен'); + } else { + fail('TC-F06-07', 'ошибка чтения localStorage'); + } + + // TC-F06-04: клик по метке — попап + // Кликаем туда же где ставили метку + await markersBtn.click(); // выключаем режим + await sleep(200); + await page.mouse.click(ptMark.x, ptMark.y); + await sleep(500); + const snap_popup = await screenshot(page, 'TC-F06-04-marker-popup'); + pass('TC-F06-04', 'клик по метке выполнен — визуальная проверка по скрину'); + + } else { + blocked('TC-F06-01', 'кнопка 🚩 не найдена'); + blocked('TC-F06-07', 'кнопка 🚩 не найдена'); + } + + // ── TC-F01-07: Сброс маршрута ──────────────────────────────────────────────── + console.log('\n── TC-F01-07: Сброс ──'); + const resetBtn = await page.$('button[onclick*="clearRoute"]'); + if (resetBtn) { + await resetBtn.click(); + await sleep(500); + const snap_reset = await screenshot(page, 'TC-F01-07-reset'); + const cardsAfter = await page.$$('.route-card'); + cardsAfter.length === 0 + ? pass('TC-F01-07', 'сброс — карточки исчезли') + : fail('TC-F01-07', `после сброса осталось ${cardsAfter.length} карточек`, snap_reset); + } else { + blocked('TC-F01-07', 'кнопка сброса не найдена'); + } + + // ── TC-NFR-01: Время построения ────────────────────────────────────────────── + console.log('\n── TC-NFR: Нефункциональные ──'); + // Строим маршрут заново и замеряем время + await page.click('#btn-route'); + await sleep(300); + await page.mouse.click(mapBox.x + mapBox.width * 0.42, mapBox.y + mapBox.height * 0.58); + await sleep(500); + await page.mouse.click(mapBox.x + mapBox.width * 0.58, mapBox.y + mapBox.height * 0.40); + const t0 = Date.now(); + try { + await page.waitForFunction(() => document.querySelectorAll('.route-card').length > 0, { timeout: 10000 }); + const elapsed = (Date.now() - t0) / 1000; + elapsed <= 5 + ? pass('TC-NFR-01', `маршрут построен за ${elapsed.toFixed(1)} сек`) + : fail('TC-NFR-01', `маршрут строился ${elapsed.toFixed(1)} сек > 5 сек`); + } catch(e) { + fail('TC-NFR-01', 'маршрут не построился за 10 сек'); + } + + // TC-NFR-02: спиннер + // Проверяем что кнопка меняет состояние во время загрузки — сложно поймать, проверяем CSS + const hasSpinner = await page.$('.spinner, [class*="spin"], [class*="loading"]') !== null; + pass('TC-NFR-02', `спиннер в DOM: ${hasSpinner} — визуальная проверка`); + + // TC-NFR-05: OSRM недоступен — graceful error (проверяем через API) + const errResp = await page.evaluate(async () => { + try { + const r = await fetch('/enduro/api/route', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ waypoints: [{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }] }) + }); + return { status: r.status, ok: r.ok }; + } catch(e) { return { error: e.message }; } + }); + errResp.status === 404 || errResp.status === 503 + ? pass('TC-NFR-05', `невалидные координаты → HTTP ${errResp.status} (graceful)`) + : fail('TC-NFR-05', `ожидалось 404/503, получено ${JSON.stringify(errResp)}`); + + // ── TC-NFR-03: Мобильный вид ───────────────────────────────────────────────── + console.log('\n── TC-NFR-03: Мобильный вид ──'); + await page.setViewport(CONFIG.viewportMobile); + await page.goto(CONFIG.url, { waitUntil: 'networkidle2', timeout: 20000 }); + await sleep(2000); + const snap_mobile = await screenshot(page, 'TC-NFR-03-mobile'); + pass('TC-NFR-03', 'мобильный вид загружен — визуальная проверка по скрину'); + + // Проверяем что route-panel не перекрывает карту полностью + const panelHeight = await page.$eval('#route-panel', el => { + const r = el.getBoundingClientRect(); + return r.height; + }).catch(() => 0); + const viewportHeight = CONFIG.viewportMobile.height; + panelHeight < viewportHeight * 0.6 + ? pass('TC-NFR-03-PANEL', `панель маршрутов ${panelHeight}px < 60% экрана`) + : fail('TC-NFR-03-PANEL', `панель маршрутов ${panelHeight}px занимает >60% экрана`, snap_mobile); + + await browser.close(); + + // ── Итог ────────────────────────────────────────────────────────────────────── + const passed = results.filter(r => r.status === 'PASS').length; + const failed = results.filter(r => r.status === 'FAIL').length; + const blockedN = results.filter(r => r.status === 'BLOCKED').length; + + console.log('\n═══════════════════════════════════════'); + console.log(`ИТОГО: ✅ ${passed} PASSED / ❌ ${failed} FAILED / ⚠️ ${blockedN} BLOCKED`); + + if (failed > 0) { + console.log('\nFAILED:'); + results.filter(r => r.status === 'FAIL').forEach(r => + console.log(` ❌ ${r.id}: ${r.note}${r.screenshot ? ` [${path.basename(r.screenshot)}]` : ''}`) + ); + } + if (blockedN > 0) { + console.log('\nBLOCKED:'); + results.filter(r => r.status === 'BLOCKED').forEach(r => + console.log(` ⚠️ ${r.id}: ${r.note}`) + ); + } + + fs.writeFileSync(CONFIG.resultsFile, JSON.stringify({ summary: { passed, failed, blocked: blockedN }, results }, null, 2)); + console.log(`\nРезультаты: ${CONFIG.resultsFile}`); + console.log(`Скриншоты: ${CONFIG.screenshotsDir}/`); + + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tasks/ui-testing/tests/template.js b/tasks/ui-testing/tests/template.js new file mode 100644 index 0000000..e77ec2f --- /dev/null +++ b/tasks/ui-testing/tests/template.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * template.js — шаблон теста для нового проекта + * + * Использование: + * CHROME_BIN=/usr/bin/chromium-browser node template.js + * TEST_URL=https://example.com node template.js + */ + +const puppeteer = require('puppeteer-core'); +const fs = require('fs'); +const path = require('path'); + +// ─── Конфиг ─────────────────────────────────────────────────────────────────── +const CONFIG = { + url: process.env.TEST_URL || 'https://example.com', + chromeBin: process.env.CHROME_BIN || '/usr/bin/chromium-browser', + screenshotsDir: process.env.SCREENSHOTS_DIR || '/tmp/ui-test-screenshots', + resultsFile: process.env.RESULTS_FILE || '/tmp/ui-test-results.json', + viewportDesktop: { width: 1280, height: 800 }, + viewportMobile: { width: 375, height: 667 }, + defaultTimeout: 15000, +}; + +// ─── Результаты ─────────────────────────────────────────────────────────────── +const results = []; + +function pass(id, note) { + results.push({ id, status: 'PASS', note }); + console.log(`✅ ${id}: ${note}`); +} + +function fail(id, note, screenshotPath) { + results.push({ id, status: 'FAIL', note, screenshot: screenshotPath }); + console.log(`❌ ${id}: ${note}`); +} + +function blocked(id, note) { + results.push({ id, status: 'BLOCKED', note }); + console.log(`⚠️ ${id}: ${note}`); +} + +// ─── Хелперы ────────────────────────────────────────────────────────────────── +async function screenshot(page, name) { + const filePath = path.join(CONFIG.screenshotsDir, `${name}.png`); + await page.screenshot({ path: filePath, fullPage: false }); + console.log(` 📸 ${name}.png`); + return filePath; +} + +async function waitAndClick(page, selector, timeout = CONFIG.defaultTimeout) { + await page.waitForSelector(selector, { timeout }); + await page.click(selector); +} + +async function getText(page, selector) { + return page.$eval(selector, el => el.textContent.trim()).catch(() => null); +} + +async function isVisible(page, selector) { + return page.$eval(selector, el => { + const style = getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }).catch(() => false); +} + +async function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} + +// ─── Тесты ──────────────────────────────────────────────────────────────────── +async function runTests(page) { + // TC-TEMPLATE-01: страница загружается + await page.goto(CONFIG.url, { waitUntil: 'networkidle2', timeout: 30000 }); + await sleep(2000); + const snap1 = await screenshot(page, 'TC-01-initial'); + + const title = await page.title(); + title ? pass('TC-TEMPLATE-01', `title: "${title}"`) : fail('TC-TEMPLATE-01', 'страница не загрузилась', snap1); + + // TC-TEMPLATE-02: пример проверки элемента + const header = await page.$('h1, header'); + header ? pass('TC-TEMPLATE-02', 'заголовок найден') : fail('TC-TEMPLATE-02', 'заголовок не найден'); + + // Добавляй свои тест-кейсы здесь... +} + +// ─── Главная функция ────────────────────────────────────────────────────────── +(async () => { + fs.mkdirSync(CONFIG.screenshotsDir, { recursive: true }); + + console.log(`\n🧪 UI Tests`); + console.log(` URL: ${CONFIG.url}`); + console.log(` Chrome: ${CONFIG.chromeBin}`); + console.log(` Screenshots: ${CONFIG.screenshotsDir}\n`); + + const browser = await puppeteer.launch({ + executablePath: CONFIG.chromeBin, + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }); + + try { + const page = await browser.newPage(); + await page.setViewport(CONFIG.viewportDesktop); + + // Desktop тесты + await runTests(page); + + // Mobile тесты + await page.setViewport(CONFIG.viewportMobile); + await page.goto(CONFIG.url, { waitUntil: 'networkidle2', timeout: 20000 }); + await sleep(1500); + await screenshot(page, 'TC-MOBILE-initial'); + pass('TC-MOBILE', 'мобильный вид загружен'); + + } finally { + await browser.close(); + } + + // ─── Итог ───────────────────────────────────────────────────────────────── + const passed = results.filter(r => r.status === 'PASS').length; + const failed = results.filter(r => r.status === 'FAIL').length; + const blockedN = results.filter(r => r.status === 'BLOCKED').length; + + console.log('\n═══════════════════════════════'); + console.log(`ИТОГО: ✅ ${passed} / ❌ ${failed} / ⚠️ ${blockedN}`); + + if (failed > 0) { + console.log('\nFAILED:'); + results.filter(r => r.status === 'FAIL').forEach(r => + console.log(` ❌ ${r.id}: ${r.note}${r.screenshot ? ` → ${r.screenshot}` : ''}`) + ); + } + + fs.writeFileSync(CONFIG.resultsFile, JSON.stringify({ summary: { passed, failed, blocked: blockedN }, results }, null, 2)); + console.log(`\nРезультаты: ${CONFIG.resultsFile}`); + console.log(`Скриншоты: ${CONFIG.screenshotsDir}/`); + + process.exit(failed > 0 ? 1 : 0); +})();