/**
* Проверка автономного 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);
});