auto-sync: 2026-05-13 00:30:01
This commit is contained in:
204
skills/ui-test/SKILL.md
Normal file
204
skills/ui-test/SKILL.md
Normal file
@@ -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 <path/to/TEST_CASES.md> <output-dir>
|
||||
```
|
||||
|
||||
Пример для 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
|
||||
```
|
||||
|
||||
Результат:
|
||||
- `<output-dir>/screenshots/` — PNG скриншоты
|
||||
- `<output-dir>/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 ❌
|
||||

|
||||
**Проблема:** описание
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
@@ -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)."
|
||||
|
||||
@@ -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 <path-to-TEST_CASES.md>');
|
||||
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 <path-to-TEST_CASES.md>');
|
||||
process.exit(1);
|
||||
}
|
||||
const content = fs.readFileSync(path.resolve(filePath), 'utf8');
|
||||
const result = parseTestCases(content);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
196
skills/ui-test/scripts/run_tests.js
Normal file
196
skills/ui-test/scripts/run_tests.js
Normal file
@@ -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 <test-cases.md> <output-dir>');
|
||||
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);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Reference in New Issue
Block a user