165 lines
8.0 KiB
JavaScript
165 lines
8.0 KiB
JavaScript
/**
|
||
* Проверка интерфейса ручной разметки в реальном браузере.
|
||
*
|
||
* Сценарий повторяет работу специалиста: открыть /label, выбрать снимок из
|
||
* списка, посмотреть изображение, отметить вердикт и сохранить его. Проверяется,
|
||
* что вердикт действительно дошёл до сервера (счётчик разобранных растёт и
|
||
* остаётся после перезагрузки) и что страница не обращается к внешним хостам.
|
||
*
|
||
* Требуется запущенный сервер с временным файлом вердиктов, чтобы не портить
|
||
* рабочий labels/manual_labels.csv:
|
||
* DXA_MANUAL_LABELS=/tmp/manual_ui.csv python -m uvicorn src.main:app --port 8123
|
||
* node tests/browser/ui_labeling.js
|
||
*
|
||
* Переменные окружения:
|
||
* BASE_URL адрес сервера (по умолчанию http://127.0.0.1:8123)
|
||
* PLAYWRIGHT путь к playwright-core (по умолчанию bundled Browser Use)
|
||
*/
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
|
||
function resolvePlaywright() {
|
||
if (process.env.PLAYWRIGHT) return process.env.PLAYWRIGHT;
|
||
const base = path.join(os.homedir(), '.qwen', 'updates', 'npm');
|
||
if (!fs.existsSync(base)) return 'playwright-core';
|
||
const versions = fs.readdirSync(path.join(base, fs.readdirSync(base)[0], 'versions')).sort();
|
||
for (const v of versions.reverse()) {
|
||
const candidate = path.join(
|
||
base, fs.readdirSync(base)[0], 'versions', v,
|
||
'node_modules/@qwen-code/qwen-code/bundled/browser-use/runtime/node_modules/playwright-core'
|
||
);
|
||
if (fs.existsSync(candidate)) return candidate;
|
||
}
|
||
return 'playwright-core';
|
||
}
|
||
|
||
const { chromium } = require(resolvePlaywright());
|
||
|
||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) throw new Error(message);
|
||
}
|
||
|
||
(async () => {
|
||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dxa-label-'));
|
||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||
channel: 'chrome',
|
||
headless: true,
|
||
});
|
||
const page = context.pages()[0] || await context.newPage();
|
||
await page.setViewportSize({ width: 1500, height: 1100 });
|
||
|
||
const consoleErrors = [];
|
||
const external = [];
|
||
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||
page.on('pageerror', (e) => consoleErrors.push('PAGEERROR ' + e.message));
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (!url.startsWith(BASE) && !url.startsWith('data:')) external.push(url);
|
||
});
|
||
|
||
await page.goto(`${BASE}/label`, { waitUntil: 'domcontentloaded' });
|
||
console.log('title:', await page.title());
|
||
|
||
await page.waitForFunction(
|
||
() => document.querySelectorAll('#list .item-card').length > 0,
|
||
{ timeout: 90000 }
|
||
);
|
||
|
||
const summary = await page.evaluate(() => ({
|
||
progress: document.getElementById('progressText').innerText.trim(),
|
||
conflicts: document.getElementById('conflictText').innerText.trim(),
|
||
byRegion: document.getElementById('byRegion').innerText.trim(),
|
||
items: document.querySelectorAll('#list .item-card').length,
|
||
}));
|
||
console.log('summary:', summary);
|
||
assert(summary.items > 0, 'список снимков пуст');
|
||
assert(/\d+ из \d+/.test(summary.progress), `счётчик разобранных не найден: ${summary.progress}`);
|
||
|
||
const total = Number(summary.progress.match(/из (\d+)/)[1]);
|
||
assert(total > 100, `ожидался весь датасет, получено ${total}`);
|
||
|
||
// Первым идёт расхождение разметки с пометкой в имени файла.
|
||
const first = await page.evaluate(() => ({
|
||
file: document.querySelector('#list .item-card .truncate').innerText.trim(),
|
||
conflict: /расхождение/.test(document.querySelector('#list .item-card').innerText),
|
||
}));
|
||
console.log('первый снимок:', first);
|
||
assert(first.conflict, 'расхождения должны идти первыми');
|
||
|
||
// Разбор снимка: изображение должно реально загрузиться с сервера.
|
||
await page.locator('#list .item-card').first().click();
|
||
await page.waitForSelector('#detail #image', { timeout: 20000 });
|
||
await page.waitForFunction(
|
||
() => { const img = document.getElementById('image'); return img && img.naturalWidth > 0; },
|
||
{ timeout: 30000 }
|
||
);
|
||
const image = await page.evaluate(() => {
|
||
const img = document.getElementById('image');
|
||
return { w: img.naturalWidth, h: img.naturalHeight, src: img.getAttribute('src').slice(0, 40) };
|
||
});
|
||
console.log('изображение:', image);
|
||
assert(image.w > 50 && image.h > 50, `снимок не отрисован: ${JSON.stringify(image)}`);
|
||
|
||
// Вердикт: «нарушение» требует типа, «годное» его снимает.
|
||
await page.locator('#markBad').click();
|
||
const chosen = await page.locator('#violation').inputValue();
|
||
console.log('выбранный тип нарушения при отметке «нарушение»:', chosen || '(пусто)');
|
||
assert(chosen, 'при отметке «нарушение» тип не подставился');
|
||
|
||
await page.locator('#markGood').click();
|
||
assert(
|
||
(await page.locator('#violation').inputValue()) === '',
|
||
'при отметке «годное» тип нарушения должен сниматься'
|
||
);
|
||
|
||
await page.locator('#markGood').click();
|
||
await page.locator('#saveVerdict').click();
|
||
const reviewedBefore = Number(summary.progress.match(/Разобрано (\d+)/)[1]);
|
||
const expected = reviewedBefore + 1;
|
||
await page.waitForFunction(
|
||
(want) => new RegExp(`Разобрано ${want} `).test(document.getElementById('progressText').innerText),
|
||
expected,
|
||
{ timeout: 20000 }
|
||
);
|
||
console.log('после сохранения:', (await page.locator('#progressText').innerText()).trim());
|
||
|
||
// Вердикт должен сохраниться на сервере, а не только в состоянии страницы.
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await page.waitForFunction(
|
||
(want) => new RegExp(`Разобрано ${want} `).test(document.getElementById('progressText').innerText),
|
||
expected,
|
||
{ timeout: 90000 }
|
||
);
|
||
console.log('после перезагрузки:', (await page.locator('#progressText').innerText()).trim());
|
||
|
||
// Фильтр «только расхождения» должен оставлять только расхождения.
|
||
await page.locator('#filterConflicts').check();
|
||
await page.waitForFunction(
|
||
() => document.querySelectorAll('#list .item-card').length > 0,
|
||
{ timeout: 20000 }
|
||
);
|
||
const filtered = await page.evaluate(() => {
|
||
const cards = Array.from(document.querySelectorAll('#list .item-card'));
|
||
return {
|
||
items: cards.length,
|
||
withoutConflict: cards.filter((c) => !/расхождение/.test(c.innerText)).length,
|
||
};
|
||
});
|
||
console.log('фильтр расхождений:', filtered);
|
||
assert(filtered.withoutConflict === 0, 'в фильтре «только расхождения» есть лишние снимки');
|
||
|
||
console.log('внешние запросы:', external.length ? external : 'нет');
|
||
assert(external.length === 0, `страница обратилась к внешним хостам: ${external.join(', ')}`);
|
||
console.log('ошибки консоли:', consoleErrors.length ? consoleErrors : 'нет');
|
||
assert(consoleErrors.length === 0, `ошибки в консоли: ${consoleErrors.join(' | ')}`);
|
||
|
||
await context.close();
|
||
console.log('OK: интерфейс ручной разметки работает');
|
||
})().catch((error) => {
|
||
console.error('FAILED:', error.message);
|
||
process.exit(1);
|
||
});
|