/** * Проверка интерфейса ручной разметки в реальном браузере. * * Сценарий повторяет работу специалиста: открыть /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); });