auto-sync: 2026-05-13 00:30:01

This commit is contained in:
Stream
2026-05-13 00:30:02 +03:00
parent 11cadd3c0b
commit 375da6a21c
7 changed files with 416 additions and 12 deletions

204
skills/ui-test/SKILL.md Normal file
View 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 ❌
![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
```

View File

@@ -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)."

View File

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

View 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