154 lines
8.7 KiB
JavaScript
154 lines
8.7 KiB
JavaScript
/**
|
||
* Проверка автономного HTML-пакета для ручной разметки (файл для врача).
|
||
*
|
||
* Пакет открывается напрямую с диска (`file://`), без сервера и без сети —
|
||
* именно так его откроет врач. Сценарий повторяет его работу: выбрать снимок,
|
||
* посмотреть, отметить вердикт, убедиться, что он пережил перезагрузку, выгрузить
|
||
* CSV, а затем загрузить его обратно (восстановление прогресса на другой машине).
|
||
*
|
||
* Пакет нужно собрать заранее:
|
||
* python -m src.dxa.review_pack --out /tmp/review_small.html --limit 8
|
||
* PACK=/tmp/review_small.html node tests/browser/review_pack.js
|
||
*
|
||
* Переменные окружения:
|
||
* PACK путь к HTML-пакету (обязателен)
|
||
* 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 PACK = process.env.PACK;
|
||
if (!PACK || !fs.existsSync(PACK)) {
|
||
console.error('FAILED: укажите PACK=путь/к/пакету.html (соберите его через src.dxa.review_pack)');
|
||
process.exit(1);
|
||
}
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) throw new Error(message);
|
||
}
|
||
|
||
(async () => {
|
||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dxa-pack-'));
|
||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||
channel: 'chrome',
|
||
headless: true,
|
||
acceptDownloads: true,
|
||
});
|
||
const page = context.pages()[0] || await context.newPage();
|
||
await page.setViewportSize({ width: 1500, height: 1000 });
|
||
// beforeunload предупреждает о невыгруженной разметке — для проверки это ожидаемо.
|
||
page.on('dialog', (dialog) => dialog.accept());
|
||
|
||
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('file:') && !url.startsWith('data:') && !url.startsWith('blob:')) {
|
||
external.push(url);
|
||
}
|
||
});
|
||
|
||
await page.goto('file://' + path.resolve(PACK), { waitUntil: 'domcontentloaded' });
|
||
console.log('title:', await page.title());
|
||
|
||
await page.waitForFunction(() => document.querySelectorAll('.item').length > 0, { timeout: 30000 });
|
||
const total = await page.evaluate(() => document.querySelectorAll('.item').length);
|
||
console.log('снимков в списке:', total);
|
||
console.log('прогресс:', (await page.locator('#progressText').innerText()).trim());
|
||
console.log('отпечаток пакета:', (await page.locator('#packId').innerText()).trim());
|
||
assert(total > 1, 'в пакете должно быть несколько снимков');
|
||
|
||
// Разбор первого снимка: изображение встроено как data-URI и рисуется.
|
||
await page.locator('.item').first().click();
|
||
await page.waitForFunction(() => {
|
||
const img = document.getElementById('image');
|
||
return img && img.naturalWidth > 0;
|
||
}, { timeout: 20000 });
|
||
const image = await page.evaluate(() => {
|
||
const img = document.getElementById('image');
|
||
return { w: img.naturalWidth, h: img.naturalHeight, data: img.getAttribute('src').startsWith('data:image/png;base64,') };
|
||
});
|
||
console.log('снимок:', image);
|
||
assert(image.w > 50 && image.h > 50, 'снимок не отрисован');
|
||
assert(image.data, 'снимок должен быть встроен как data-URI');
|
||
|
||
// Построенная разметка и её источник видны сразу (режим подтверждения).
|
||
const detail = await page.locator('#detail').innerText();
|
||
assert(/Построенная разметка/.test(detail), 'не показана построенная разметка');
|
||
assert(/источник:/.test(detail), 'не показан источник метки');
|
||
console.log('подсказка о расхождении:', /не совпадает с пометкой/.test(detail) ? 'есть' : 'нет в этом снимке');
|
||
|
||
// Нарушение: тип подставляется по области; годное — снимает тип.
|
||
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('#reviewer').fill('Тестова А. А.');
|
||
await page.locator('#save').click();
|
||
await page.waitForFunction(() => /Разобрано 1 /.test(document.getElementById('progressText').innerText), { timeout: 15000 });
|
||
console.log('после сохранения:', (await page.locator('#progressText').innerText()).trim());
|
||
|
||
// Вердикт должен пережить перезагрузку страницы (localStorage).
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await page.waitForFunction(() => /Разобрано 1 /.test(document.getElementById('progressText').innerText), { timeout: 20000 });
|
||
console.log('после перезагрузки:', (await page.locator('#progressText').innerText()).trim());
|
||
|
||
// Выгрузка CSV.
|
||
const [download] = await Promise.all([
|
||
page.waitForEvent('download', { timeout: 20000 }),
|
||
page.locator('#export').click(),
|
||
]);
|
||
const csvPath = path.join(userDataDir, 'export.csv');
|
||
await download.saveAs(csvPath);
|
||
const csvText = fs.readFileSync(csvPath, 'utf-8');
|
||
const lines = csvText.replace(/^\ufeff/, '').trim().split(/\r?\n/);
|
||
const header = lines[0].split(',');
|
||
console.log('CSV: строк', lines.length - 1, '| колонки:', header.join(','));
|
||
assert(/path_to_image/.test(header[0]), 'в CSV нет колонки path_to_image');
|
||
assert(header.includes('quality_class') && header.includes('pack_id'), 'в CSV нет обязательных колонок');
|
||
assert(lines.length === 2, `в CSV должна быть одна строка с вердиктом, получено ${lines.length - 1}`);
|
||
assert(/,0,/.test(lines[1]), 'вердикт «годное» должен записаться как quality_class=0');
|
||
|
||
// Восстановление прогресса из файла: чистим хранилище и загружаем CSV обратно.
|
||
await page.evaluate(() => window.localStorage.clear());
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await page.waitForFunction(() => /Разобрано 0 /.test(document.getElementById('progressText').innerText), { timeout: 20000 });
|
||
await page.locator('#import').setInputFiles(csvPath);
|
||
await page.waitForFunction(() => /Разобрано 1 /.test(document.getElementById('progressText').innerText), { timeout: 20000 });
|
||
console.log('после загрузки CSV:', (await page.locator('#progressText').innerText()).trim());
|
||
|
||
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);
|
||
});
|