Files
wiki/tasks/ui-testing/tests/enduro-phase3.js
2026-05-04 12:20:17 +03:00

452 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
})();