/** * Проверка панели деталей веб-интерфейса в реальном браузере. * * Загружает несколько DICOM-файлов, кликает по строкам таблицы и снимает * значения из панели деталей, чтобы убедиться, что они меняются при * переключении строк (это был основной симптом бага: панель показывала одни * и те же значения). * * Требуется запущенный сервер: * python -m uvicorn src.main:app --port 8123 * node tests/browser/ui_check.js * * Переменные окружения: * BASE_URL адрес сервера (по умолчанию http://127.0.0.1:8123) * PLAYWRIGHT путь к playwright-core (по умолчанию берётся из bundled * Browser Use в установке Qwen Code) * * Используется временный профиль Chrome через launchPersistentContext, * профиль пользователя не затрагивается. */ const fs = require('fs'); const path = require('path'); const os = require('os'); function resolvePlaywright() { if (process.env.PLAYWRIGHT) return process.env.PLAYWRIGHT; // Ищем playwright-core в bundled Browser Use установки Qwen Code. 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'; const REPO = path.resolve(__dirname, '..', '..'); const FILES = [ path.join(REPO, 'dataset_hack/Для теста/spine_01.dcm'), path.join(REPO, 'dataset_hack/Для теста/l_hip_01.dcm'), path.join(REPO, 'dataset_hack/Для теста/r_hip_01.dcm'), ]; async function panelState(page) { return page.evaluate(() => { const txt = (id) => { const el = document.getElementById(id); return el ? el.innerText.replace(/\s+/g, ' ').trim() : null; }; const cards = (id) => { const el = document.getElementById(id); if (!el) return null; return Array.from(el.children).map((c) => c.innerText.replace(/\s+/g, ' ').trim()); }; const imgs = Array.from(document.querySelectorAll('#detailImage img, #detailMask img')) .map((i) => ({ src: (i.getAttribute('src') || '').slice(0, 22), w: i.naturalWidth, h: i.naturalHeight })); return { detailVisible: !document.getElementById('detailSection').classList.contains('hidden'), badge: txt('detailBadge'), basic: txt('detailBasic'), quality: txt('detailQuality'), measurements: cards('detailMotion'), roiCards: cards('detailArtifacts'), spineHidden: document.getElementById('spineSection').classList.contains('hidden'), spine: txt('detailSpine'), hipHidden: document.getElementById('hipSection').classList.contains('hidden'), hip: txt('detailHip'), reason: txt('detailReason'), note: txt('detailMetricsNote'), images: imgs, }; }); } (async () => { const userDataDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'dxa-ui-')); // launchPersistentContext с временным профилем: профиль пользователя не затрагивается. const context = await chromium.launchPersistentContext(userDataDir, { channel: 'chrome', headless: true }); const browser = context; const page = context.pages()[0] || await context.newPage(); await page.setViewportSize({ width: 1400, height: 1100 }); const consoleErrors = []; page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('pageerror', (e) => consoleErrors.push('PAGEERROR ' + e.message)); await page.goto(BASE, { waitUntil: 'domcontentloaded' }); console.log('title:', await page.title()); console.log('status banner:', (await page.locator('#statusText').innerText()).trim()); // Загружаем файлы через input (drag-and-drop эмулировать не нужно). await page.setInputFiles('#fileInput', FILES); await page.waitForFunction( () => document.querySelectorAll('#resultsTable tr').length === 3, { timeout: 60000 } ); console.log('rows rendered:', await page.locator('#resultsTable tr').count()); console.log('stats:', await page.evaluate(() => ({ total: document.getElementById('totalCount').innerText, ok: document.getElementById('okCount').innerText, violation: document.getElementById('violationCount').innerText, avg: document.getElementById('meanConfidence').innerText, }))); // Подсказка о кликабельности строк: пользователи не догадывались, что по // строке можно нажать, поэтому она должна быть видна и содержать инструкцию. const hint = await page.evaluate(() => { const el = document.getElementById('tableHint'); return { visible: el && !el.classList.contains('hidden') && el.offsetHeight > 0, text: el ? el.innerText.replace(/\s+/g, ' ').trim() : '', openButtons: document.querySelectorAll('#resultsTable button').length, }; }); console.log('hint:', hint); if (!hint.visible) throw new Error('подсказка о кликабельности строк не видна'); if (!/нажмите на/i.test(hint.text)) throw new Error('в подсказке нет инструкции нажать на строку'); if (hint.openButtons !== 3) throw new Error(`кнопок «Открыть» в строках: ${hint.openButtons}, ожидалось 3`); // Кнопка «Открыть» в строке должна открывать панель деталей так же, как клик. // Проверяем до сценария с кликом по строке, чтобы не мешать его подсчётам. await page.locator('#resultsTable tr').first().locator('button').click(); await page.waitForSelector('#detailSection:not(.hidden)', { timeout: 15000 }); console.log('кнопка «Открыть» открыла панель деталей: да'); await page.locator('#closeDetail').click(); // Пустой фильтр: подсказка о кликабельности не должна висеть над «Нет результатов». await page.fill('#searchInput', 'заведомо-нет-такого-файла'); await page.waitForSelector('#emptyResults:not(.hidden)', { timeout: 10000 }); const emptyState = await page.evaluate(() => ({ hintHidden: document.getElementById('tableHint').classList.contains('hidden'), rows: document.querySelectorAll('#resultsTable tr').length, })); console.log('пустой фильтр:', emptyState); if (!emptyState.hintHidden) throw new Error('подсказка осталась видимой при пустом результате'); await page.fill('#searchInput', ''); await page.waitForFunction( () => document.querySelectorAll('#resultsTable tr').length === 3, { timeout: 10000 } ); // Панель «О модели»: происхождение модели, метрики с интервалами и словарь // типов нарушений приходят с /api/v1/model, поэтому проверяем, что раздел // действительно наполняется, а не остаётся пустым каркасом. await page.locator('#modelInfoToggle').click(); await page.waitForSelector('#modelMetrics table', { timeout: 15000 }); const modelPanel = await page.evaluate(() => ({ summary: document.getElementById('modelSummary').innerText.replace(/\s+/g, ' ').slice(0, 200), metricRows: document.querySelectorAll('#modelMetrics tbody tr').length, violationRows: document.querySelectorAll('#modelViolations tbody tr').length, limitationItems: document.querySelectorAll('#modelLimitations li').length, datasetCards: document.querySelectorAll('#modelDataset > div').length, })); console.log('model panel:', modelPanel); if (modelPanel.metricRows < 3) { throw new Error(`панель «О модели»: ожидалось >=3 строк метрик, получено ${modelPanel.metricRows}`); } if (modelPanel.violationRows < 5) { throw new Error(`панель «О модели»: ожидалось >=5 типов нарушений, получено ${modelPanel.violationRows}`); } await page.locator('#modelInfoToggle').click(); const shots = []; const states = []; for (const idx of [0, 1, 2]) { await page.locator('#resultsTable tr').nth(idx).click(); await page.waitForTimeout(1200); const state = await panelState(page); states.push(state); const file = path.join(os.tmpdir(), `dxa_panel_row${idx}.png`); await page.screenshot({ path: file, fullPage: false }); shots.push({ idx, file, measurements: state.measurements, region: state.basic }); // Прокручиваем к панели, чтобы она попала в кадр await page.locator('#detailSection').scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(os.tmpdir(), `dxa_detail_row${idx}.png`) }); } console.log('\n=== ПАНЕЛЬ ДЕТАЛЕЙ ПО СТРОКАМ ==='); for (const s of shots) { console.log(`\nстрока ${s.idx}:`); console.log(' basic:', s.region); console.log(' измерения:', s.measurements); } console.log('\n=== СЕКЦИИ / ВИЗУАЛИЗАЦИЯ / ЗАКЛЮЧЕНИЕ ==='); states.forEach((s, i) => { console.log(`\nстрока ${i}: visible=${s.detailVisible}`); console.log(' badge:', s.badge); console.log(' quality:', s.quality); console.log(' roi:', s.roiCards); console.log(' spine hidden:', s.spineHidden, '| hip hidden:', s.hipHidden); if (!s.spineHidden) console.log(' spine:', s.spine); if (!s.hipHidden) console.log(' hip:', s.hip); console.log(' note:', (s.note || '').slice(0, 80)); console.log(' images:', s.images); }); // Проверка, что значения действительно различаются между строками const uniq = (arr) => new Set(arr.filter(Boolean)).size; console.log('\n=== РАЗЛИЧИМОСТЬ ЗНАЧЕНИЙ МЕЖДУ СТРОКАМИ ==='); console.log('разных basic:', uniq(states.map((s) => s.basic))); console.log('разных measurements:', uniq(states.map((s) => JSON.stringify(s.measurements)))); console.log('разных quality:', uniq(states.map((s) => s.quality))); console.log('разных roi:', uniq(states.map((s) => JSON.stringify(s.roiCards)))); console.log('разных reason:', uniq(states.map((s) => s.reason))); console.log('\nconsole errors:', consoleErrors.length ? consoleErrors : 'none'); fs.writeFileSync(path.join(os.tmpdir(), 'dxa_ui_states.json'), JSON.stringify(states, null, 2)); console.log('states saved to', path.join(os.tmpdir(), 'dxa_ui_states.json')); console.log('screenshots:', shots.map((s) => s.file).join(', ')); await browser.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); })().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });