#!/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); })();