auto-sync: 2026-05-04 12:20:01

This commit is contained in:
Stream
2026-05-04 12:20:17 +03:00
parent 73f213b7a4
commit 4836421910
6 changed files with 1012 additions and 0 deletions

215
tasks/ui-testing/PROJECT.md Normal file
View File

@@ -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/<project>/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/<project>/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).*

View File

@@ -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-элементы вокруг карты, не саму карту

View File

@@ -0,0 +1,64 @@
#!/bin/bash
# run-tests.sh — запуск UI-тестов через ssh_exec.sh
# Использование: bash tasks/ui-testing/scripts/run-tests.sh <test-file> [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"

View File

@@ -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/"

View File

@@ -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);
})();

View File

@@ -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);
})();