diff --git a/skills/ui-test/SKILL.md b/skills/ui-test/SKILL.md new file mode 100644 index 0000000..aff7731 --- /dev/null +++ b/skills/ui-test/SKILL.md @@ -0,0 +1,204 @@ +--- +name: ui-test +description: "Автономное UI/UX тестирование веб-приложений через Playwright + vision analysis. Запуск тестов, скриншоты, визуальный анализ, отчёт." +--- + +# UI/UX Auto-Testing Skill + +## Когда использовать + +- После деплоя новой фичи — проверить что ничего не сломалось +- По запросу Славы — "проверь UI" +- Перед релизом фазы — регрессионный прогон всех тестов + +## Порядок тестирования + +UI/UX тесты запускаются **последними**, после: +1. Unit/integration тесты (curl/grep, API) +2. Функциональные проверки (DOM, HTTP коды) +3. **UI/UX тесты (этот скилл)** + +## Быстрый старт + +### 1. Проверить готовность + +```bash +cd /home/node/.openclaw/workspace/skills/ui-test/scripts && node health_check.js +``` + +Все проверки должны быть ✅. Если нет — см. раздел "Установка". + +### 2. Запустить тесты + +```bash +cd /home/node/.openclaw/workspace/skills/ui-test/scripts +node run_tests.js +``` + +Пример для enduro-trails: +```bash +node run_tests.js \ + /home/node/.openclaw/workspace/tasks/enduro-trails/TEST_CASES_UI.md \ + /home/node/.openclaw/workspace/tasks/enduro-trails/reports +``` + +Результат: +- `/screenshots/` — PNG скриншоты +- `/results.json` — JSON с результатами + +### 3. Проанализировать скриншоты + +После запуска раннера — проанализировать скриншоты через `image` tool. + +Для каждого `check-visual` из results.json: +1. Скопировать скриншот в workspace (image tool требует путь под `~/.openclaw/workspace/`) +2. Вызвать `image` tool с промптом: + +``` +Ты — QA-инженер. Проанализируй скриншот веб-приложения. +Контекст: {check.description} +Визуальные критерии: {test.criteria} + +Проверь: +1. Есть ли визуальные артефакты (пустые области, битые изображения, наложения)? +2. Текст читаем? Достаточный контраст? +3. Элементы UI расположены логично? Ничего не обрезано? +4. Для мобильного: нет горизонтального скролла? Кнопки достаточного размера? + +Ответь: pass или fail + описание проблем. +``` + +3. Записать результат + +### 4. Сформировать отчёт + +Создать `reports/ui-test-YYYY-MM-DD.md`: + +```markdown +# UI Test Report: {project} +**Дата:** {date} +**Тесты:** {total} | ✅ {passed} | ❌ {failed} + +| # | Тест | Desktop | Mobile | Проблемы | +|---|------|---------|--------|----------| +| TC-XX | Название | ✅/❌ | ✅/❌ | описание | + +## Скриншоты проблемных тестов +### TC-XX ❌ +![screenshot](screenshots/filename.png) +**Проблема:** описание +``` + +### 5. Отправить результат Славе + +**Всегда** отправлять результат — даже если всё зелёное. + +--- + +## Формат тест-кейсов + +Файл: `TEST_CASES_UI.md` (или секция в общем TEST_CASES файле с `**Тип:** ui`) + +```markdown +### TC-XX — Название теста +**Тип:** ui +**Viewport:** desktop | mobile | both +**URL:** https://example.com/ + +**Шаги:** +1. navigate: {url} +2. wait: {ms} +3. click: "{css-selector}" +4. scroll: {pixels} +5. resize: {width}x{height} +6. screenshot: "{name}" +7. check-visual: "{описание что проверяем}" + +**Визуальные критерии:** +- Критерий 1 +- Критерий 2 +``` + +### Доступные действия + +| Действие | Формат | Описание | +|----------|--------|----------| +| navigate | `navigate: {url}` | Перейти на URL, ждать networkidle | +| wait | `wait: {ms}` | Подождать N миллисекунд | +| click | `click: "{selector}"` | Кликнуть по CSS-селектору (timeout 5s) | +| scroll | `scroll: {pixels}` | Прокрутить вниз на N пикселей | +| resize | `resize: {w}x{h}` | Изменить viewport | +| screenshot | `screenshot: "{name}"` | Сделать скриншот (сохраняется как `{TC-ID}-{viewport}-{name}.png`) | +| check-visual | `check-visual: "{desc}"` | Скриншот + пометка для vision-анализа | + +### Viewport + +- `desktop` — 1280×720, deviceScaleFactor 1 +- `mobile` — 375×812, deviceScaleFactor 2, isMobile true, iPhone UA +- `both` — тест прогоняется дважды (desktop + mobile) + +--- + +## Установка (если health_check падает) + +### Chromium headless shell +```bash +npx --yes playwright install chromium +``` +Устанавливается в `~/.cache/ms-playwright/` + +### Системные библиотеки (без root) +Библиотеки уже извлечены в `~/chromium-libs/libs/`. Если нет: +```bash +mkdir -p ~/chromium-libs && cd ~/chromium-libs +# Скачать deb-пакеты с ftp.us.debian.org (bookworm amd64): +# libglib2.0-0, libnss3, libnspr4, libatk1.0-0, libatk-bridge2.0-0, +# libdbus-1-3, libx11-6, libxcomposite1, libxdamage1, libxext6, +# libxfixes3, libxrandr2, libgbm1, libxcb1, libxkbcommon0, libasound2, +# libatspi2.0-0, libxrender1, libdrm2, libwayland-server0, libxau6, +# libxdmcp6, libxi6, libffi8, libpcre2-8-0, libbsd0 +for deb in *.deb; do dpkg-deb -x "$deb" libs/; done +``` + +### Node.js зависимости +```bash +cd /home/node/.openclaw/workspace/skills/ui-test/scripts && npm install +``` + +--- + +## Ограничения + +- **Headless** — нет GPU рендеринга. WebGL карты (MapLibre) рендерятся через software, могут выглядеть чуть иначе чем в реальном браузере +- **Тайлы по сети** — MapLibre грузит тайлы с tile.openstreetmap.org. Нужен `wait: 3000-5000` после navigate +- **Vision-анализ субъективен** — false positives возможны. Severity critical/major — блокируют, minor — в отчёт +- **Мобильный viewport** — эмулирует размер экрана, но не реальные touch-события +- **image tool path** — скриншоты должны быть под `~/.openclaw/workspace/` для анализа через image tool. Копировать из /tmp если output-dir был там +- **Селекторы** — CSS-селекторы должны точно совпадать с реальными ID/классами на странице. Проверять через `grep` в исходниках перед написанием тестов + +--- + +## Проекты + +| Проект | URL | Тест-кейсы | +|--------|-----|------------| +| enduro-trails | openclaw.mva154.duckdns.org/enduro/ | `tasks/enduro-trails/TEST_CASES_UI.md` | +| noisemap | localhost:5555 | `tasks/flightradar24/TEST_CASES_UI.md` | +| snowbike-rag | localhost:5557 | `tasks/snowbike-rag/TEST_CASES_UI.md` | + +--- + +## Файлы скилла + +``` +skills/ui-test/ +├── SKILL.md # Этот файл +├── examples/ +│ └── TEST_CASES_EXAMPLE.md # Пример тест-кейсов +└── scripts/ + ├── health_check.js # Проверка готовности окружения + ├── parse_testcases.js # Парсер markdown → JSON + ├── run_tests.js # Раннер (Playwright + Chromium) + ├── package.json # Зависимости + └── node_modules/ # playwright-core +``` diff --git a/skills/ui-test/examples/TEST_CASES_EXAMPLE.md b/skills/ui-test/examples/TEST_CASES_EXAMPLE.md index e7cc29d..ef8a280 100644 --- a/skills/ui-test/examples/TEST_CASES_EXAMPLE.md +++ b/skills/ui-test/examples/TEST_CASES_EXAMPLE.md @@ -27,7 +27,7 @@ 1. navigate: https://openclaw.mva154.duckdns.org/enduro/ 2. wait: 3000 3. screenshot: "before-theme-switch" -4. click: "#theme-toggle" +4. click: "#btn-theme" 5. wait: 2000 6. screenshot: "after-theme-switch" 7. check-visual: "Тема переключилась: фон карты изменился (светлый↔тёмный), кнопки toolbar сменили цвет" @@ -47,7 +47,7 @@ **Шаги:** 1. navigate: https://openclaw.mva154.duckdns.org/enduro/ 2. wait: 3000 -3. click: "#terrain-btn" +3. click: "#btn-terrain" 4. wait: 1000 5. screenshot: "terrain-popup-mobile" 6. check-visual: "Попап terrain виден полностью, не обрезан снизу. Чекбоксы кликабельного размера (>44px)." diff --git a/skills/ui-test/scripts/parse_testcases.js b/skills/ui-test/scripts/parse_testcases.js index b516b42..f2e529e 100644 --- a/skills/ui-test/scripts/parse_testcases.js +++ b/skills/ui-test/scripts/parse_testcases.js @@ -4,14 +4,6 @@ const fs = require('fs'); const path = require('path'); -const filePath = process.argv[2]; -if (!filePath) { - console.error('Usage: node parse_testcases.js '); - process.exit(1); -} - -const content = fs.readFileSync(path.resolve(filePath), 'utf8'); - function parseTestCases(md) { const tests = []; // Split by ### headers @@ -82,5 +74,17 @@ function parseTestCases(md) { return tests; } -const result = parseTestCases(content); -console.log(JSON.stringify(result, null, 2)); +// Export for use as module +module.exports = { parseTestCases }; + +// CLI mode +if (require.main === module) { + const filePath = process.argv[2]; + if (!filePath) { + console.error('Usage: node parse_testcases.js '); + process.exit(1); + } + const content = fs.readFileSync(path.resolve(filePath), 'utf8'); + const result = parseTestCases(content); + console.log(JSON.stringify(result, null, 2)); +} diff --git a/skills/ui-test/scripts/run_tests.js b/skills/ui-test/scripts/run_tests.js new file mode 100644 index 0000000..e490fad --- /dev/null +++ b/skills/ui-test/scripts/run_tests.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// run_tests.js — UI test runner using Playwright + Chromium headless shell + +const { chromium } = require('playwright-core'); +const fs = require('fs'); +const path = require('path'); + +const CHROMIUM_PATH = path.join( + process.env.HOME, + '.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell' +); + +const LD_LIBRARY_PATH = [ + path.join(process.env.HOME, 'chromium-libs/libs/usr/lib/x86_64-linux-gnu'), + path.join(process.env.HOME, 'chromium-libs/libs/lib/x86_64-linux-gnu') +].join(':'); + +const { parseTestCases } = require('./parse_testcases.js'); + +// Parse args +const testCasesFile = process.argv[2]; +const outputDir = process.argv[3]; + +if (!testCasesFile || !outputDir) { + console.error('Usage: node run_tests.js '); + process.exit(1); +} + +function loadTestCases(filePath) { + const content = fs.readFileSync(path.resolve(filePath), 'utf8'); + return parseTestCases(content); +} + +async function launchBrowser(viewport) { + const browser = await chromium.launch({ + executablePath: CHROMIUM_PATH, + headless: true, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + env: { ...process.env, LD_LIBRARY_PATH } + }); + const context = await browser.newContext({ + viewport: viewport === 'mobile' + ? { width: 375, height: 812 } + : { width: 1280, height: 720 }, + deviceScaleFactor: viewport === 'mobile' ? 2 : 1, + isMobile: viewport === 'mobile', + userAgent: viewport === 'mobile' + ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1' + : undefined + }); + return { browser, context }; +} + +async function executeStep(page, step, screenshotDir, testId, viewportLabel) { + const prefix = `${testId}-${viewportLabel}`; + switch (step.action) { + case 'navigate': + await page.goto(step.value, { waitUntil: 'networkidle', timeout: 30000 }); + break; + case 'wait': + await page.waitForTimeout(typeof step.value === 'number' ? step.value : parseInt(step.value)); + break; + case 'click': + try { + await page.click(step.value, { timeout: 5000 }); + } catch (e) { + return { error: `Click failed on "${step.value}": ${e.message.slice(0, 100)}` }; + } + break; + case 'scroll': + await page.evaluate((px) => window.scrollBy(0, parseInt(px)), String(step.value)); + break; + case 'screenshot': { + const filename = `${prefix}-${step.value}.png`; + await page.screenshot({ path: path.join(screenshotDir, filename), fullPage: false }); + return { screenshot: filename }; + } + case 'check-visual': { + const filename = `${prefix}-check-${Date.now()}.png`; + await page.screenshot({ path: path.join(screenshotDir, filename), fullPage: false }); + return { screenshot: filename, checkDescription: step.value }; + } + case 'resize': { + const [w, h] = step.value.split('x').map(Number); + await page.setViewportSize({ width: w, height: h }); + break; + } + default: + return { warning: `Unknown action: ${step.action}` }; + } + return null; +} + +async function runTest(test, viewportLabel, screenshotDir) { + const result = { + id: test.id, + name: test.name, + viewport: viewportLabel, + status: 'completed', + screenshots: [], + checks: [], + errors: [] + }; + + let browser, context; + try { + ({ browser, context } = await launchBrowser(viewportLabel)); + const page = await context.newPage(); + + for (const step of test.steps) { + const stepResult = await executeStep(page, step, screenshotDir, test.id, viewportLabel); + if (stepResult) { + if (stepResult.error) { + result.errors.push(stepResult.error); + // Continue with remaining steps + } + if (stepResult.screenshot) { + result.screenshots.push(stepResult.screenshot); + if (stepResult.checkDescription) { + result.checks.push({ + description: stepResult.checkDescription, + screenshot: stepResult.screenshot + }); + } + } + } + } + + if (result.errors.length > 0) { + result.status = 'completed_with_errors'; + } + } catch (e) { + result.status = 'failed'; + result.errors.push(e.message.slice(0, 200)); + } finally { + if (browser) await browser.close(); + } + + return result; +} + +async function main() { + // Load test cases + const tests = loadTestCases(testCasesFile); + if (tests.length === 0) { + console.error('No UI test cases found in', testCasesFile); + process.exit(1); + } + + console.log(`Found ${tests.length} UI test(s) in ${path.basename(testCasesFile)}`); + + // Create output dirs + const screenshotDir = path.join(outputDir, 'screenshots'); + fs.mkdirSync(screenshotDir, { recursive: true }); + + const allResults = []; + + for (const test of tests) { + const viewports = test.viewport === 'both' + ? ['desktop', 'mobile'] + : [test.viewport]; + + for (const vp of viewports) { + console.log(` Running: ${test.id} — ${test.name} [${vp}]`); + const result = await runTest(test, vp, screenshotDir); + allResults.push(result); + const icon = result.status === 'completed' ? '✓' : result.status === 'completed_with_errors' ? '⚠' : '✗'; + console.log(` ${icon} ${result.status} — ${result.screenshots.length} screenshot(s)`); + } + } + + // Write results.json + const report = { + timestamp: new Date().toISOString(), + testFile: path.basename(testCasesFile), + results: allResults + }; + + const resultsPath = path.join(outputDir, 'results.json'); + fs.writeFileSync(resultsPath, JSON.stringify(report, null, 2)); + console.log(`\nResults saved to: ${resultsPath}`); + console.log(`Screenshots: ${screenshotDir}/`); + + // Summary + const completed = allResults.filter(r => r.status === 'completed').length; + const withErrors = allResults.filter(r => r.status === 'completed_with_errors').length; + const failed = allResults.filter(r => r.status === 'failed').length; + console.log(`\nSummary: ${allResults.length} runs | ✓ ${completed} | ⚠ ${withErrors} | ✗ ${failed}`); + + if (failed > 0) process.exit(1); +} + +main().catch(e => { + console.error('Fatal error:', e.message); + process.exit(1); +}); diff --git a/tasks/ui-test-skill/reports/screenshots/TC-UI-01-desktop-initial-load.png b/tasks/ui-test-skill/reports/screenshots/TC-UI-01-desktop-initial-load.png new file mode 100644 index 0000000..60ec76a Binary files /dev/null and b/tasks/ui-test-skill/reports/screenshots/TC-UI-01-desktop-initial-load.png differ diff --git a/tasks/ui-test-skill/reports/screenshots/TC-UI-02-desktop-after-theme-switch.png b/tasks/ui-test-skill/reports/screenshots/TC-UI-02-desktop-after-theme-switch.png new file mode 100644 index 0000000..09bb230 Binary files /dev/null and b/tasks/ui-test-skill/reports/screenshots/TC-UI-02-desktop-after-theme-switch.png differ diff --git a/tasks/ui-test-skill/reports/screenshots/TC-UI-02-desktop-before-theme-switch.png b/tasks/ui-test-skill/reports/screenshots/TC-UI-02-desktop-before-theme-switch.png new file mode 100644 index 0000000..60ec76a Binary files /dev/null and b/tasks/ui-test-skill/reports/screenshots/TC-UI-02-desktop-before-theme-switch.png differ