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