From 037d47b592d173609997dbd9dd3a689133ccd327 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Sun, 7 Jun 2026 20:36:26 +0300 Subject: [PATCH] test: add browser conformance smoke --- .gitignore | 1 + README.md | 1 + docs/BROWSER_CONFORMANCE_RU.md | 80 ++++++++ .../TASK_012_VISUAL_CONFORMANCE_SMOKE.md | 62 ++++++ scripts/browser-conformance-smoke.mjs | 191 ++++++++++++++++++ scripts/detmir-portal-tabs-smoke.mjs | 17 +- 6 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 docs/BROWSER_CONFORMANCE_RU.md create mode 100644 scripts/browser-conformance-smoke.mjs diff --git a/.gitignore b/.gitignore index 4c7d9d3..ff885e1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ windows/*.report.json .rollout-logs/ output/ +artifacts/browser-smoke/ dist/ .ai/ .autonomous/ diff --git a/README.md b/README.md index b4d44a1..aa93520 100755 --- a/README.md +++ b/README.md @@ -253,6 +253,7 @@ collectors. - [Pilot success criteria](docs/PILOT_SUCCESS_CRITERIA_RU.md) - [Competitive positioning](docs/COMPETITIVE_POSITIONING_RU.md) - [Roadmap conformance audit](docs/ROADMAP_CONFORMANCE_AUDIT_RU.md) +- [Browser conformance smoke](docs/BROWSER_CONFORMANCE_RU.md) - [Production readiness портала](docs/PRODUCTION_READINESS_RU.md) - [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md) - [Executive Action Center](docs/EXECUTIVE_ACTION_CENTER_RU.md) diff --git a/docs/BROWSER_CONFORMANCE_RU.md b/docs/BROWSER_CONFORMANCE_RU.md new file mode 100644 index 0000000..8c05246 --- /dev/null +++ b/docs/BROWSER_CONFORMANCE_RU.md @@ -0,0 +1,80 @@ +# Browser Conformance Smoke + +`scripts/browser-conformance-smoke.mjs` проверяет, что ключевые блоки портала +AWatch-rus реально отображаются в браузере. Проверка не оценивает дизайн, +цвета, pixel-perfect верстку или бизнес-корректность данных. + +## Что проверяется + +- Executive view: + - KPI; + - Explainable KPI; + - Risk Narrative; + - Recommended Actions. +- Workforce view: + - KPI; + - сравнение подразделений; + - статус тренда; + - Explainable KPI. +- Security view: + - события безопасности; + - связь рисков и активности; + - Recommended Actions для ИБ; + - кандидаты на проверку. +- Forensics view: + - расследования; + - timeline событий; + - материалы расследования; + - аудит. + +Также проверяется: + +- страница не пустая; +- нет browser page errors; +- нет console errors; +- нет HTTP 500 responses; +- создаются screenshots. + +## Как запускать + +Сначала запустить портал AWatch-rus. Затем: + +```bash +AWATCH_BROWSER_SMOKE_URL=http://127.0.0.1:8720/portal/ \ + node scripts/browser-conformance-smoke.mjs +``` + +Если портал защищен basic auth: + +```bash +AWATCH_BROWSER_SMOKE_BASIC_AUTH='' \ +AWATCH_BROWSER_SMOKE_URL=https:///portal/ \ + node scripts/browser-conformance-smoke.mjs +``` + +## Артефакты + +Скриншоты сохраняются в: + +```text +artifacts/browser-smoke/ +``` + +Минимальный набор: + +- `executive.png`; +- `workforce.png`; +- `security.png`; +- `forensics.png`. + +Каталог является runtime artifact и не предназначен для коммита в Git. + +## Ограничения + +- Smoke не добавляет новые API, UI, данные или claims. +- Smoke не доказывает production performance. +- Smoke не заменяет ручную демонстрационную проверку перед показом заказчику. +- Smoke не проверяет весь портал: только ключевые блоки Executive, Workforce, + Security и Forensics. +- Risk Narrative проверяется в Executive view. Security view проверяет + существующий блок связи рисков и активности, без ML/LLM claims. diff --git a/docs/roadmap/TASK_012_VISUAL_CONFORMANCE_SMOKE.md b/docs/roadmap/TASK_012_VISUAL_CONFORMANCE_SMOKE.md index b8efdf3..98e330a 100644 --- a/docs/roadmap/TASK_012_VISUAL_CONFORMANCE_SMOKE.md +++ b/docs/roadmap/TASK_012_VISUAL_CONFORMANCE_SMOKE.md @@ -165,3 +165,65 @@ docs/BROWSER_CONFORMANCE_RU.md 4. Примеры screenshots. 5. Результаты smoke. 6. Ограничения. + +--- + +## Выполнение + +Статус: выполнено. + +Используемый инструмент: + +- Playwright через существующий локальный loader проекта. + +Создан smoke: + +- `scripts/browser-conformance-smoke.mjs`. + +Создана документация: + +- `docs/BROWSER_CONFORMANCE_RU.md`. + +Обновлен README: + +- добавлена ссылка на browser conformance smoke. + +Проверяемые страницы/роли: + +- Executive / `data-view-mode="executive"`; +- Workforce / `data-view-mode="manager"`; +- Security / `data-view-mode="security"`; +- Forensics / `data-view-mode="forensics"`. + +Проверяемые блоки: + +- KPI; +- Explainable KPI; +- Risk Narrative; +- Recommended Actions; +- Department Comparison; +- Trend Status; +- события безопасности; +- связь рисков и активности; +- Investigation data; +- timeline событий; +- материалы расследования; +- аудит. + +Примечание: отдельный блок Risk Narrative проверяется в Executive view. В +Security view smoke проверяет уже существующий блок связи рисков и активности, +не добавляя новый UI и не создавая новый claim. + +Screenshots: + +- `artifacts/browser-smoke/executive.png`; +- `artifacts/browser-smoke/workforce.png`; +- `artifacts/browser-smoke/security.png`; +- `artifacts/browser-smoke/forensics.png`. + +Ограничения: + +- smoke не проверяет дизайн pixel-perfect; +- smoke не добавляет новые API, UI или данные; +- screenshots являются runtime artifacts и не коммитятся в Git; +- live customer-stand visual validation остается отдельным acceptance шагом. diff --git a/scripts/browser-conformance-smoke.mjs b/scripts/browser-conformance-smoke.mjs new file mode 100644 index 0000000..fe0338a --- /dev/null +++ b/scripts/browser-conformance-smoke.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function env(name, fallback = "") { + const value = process.env[name]; + return value && value.trim() ? value.trim() : fallback; +} + +function loadPlaywright() { + const home = process.env.HOME || ""; + const candidates = [ + "playwright", + "playwright-core", + process.env.PLAYWRIGHT_CORE_MODULE, + home ? path.join(home, ".agents/skills/playwright/node_modules/playwright-core") : "", + home ? path.join(home, ".codex/skills/playwright/node_modules/playwright-core") : "", + ].filter(Boolean); + for (const candidate of candidates) { + try { + return require(candidate); + } catch { + // Try the next local Playwright installation. + } + } + throw new Error("Playwright module not found"); +} + +function authHeaders() { + const explicit = env("AWATCH_BROWSER_SMOKE_AUTH_HEADER", env("DETMIR_PORTAL_SMOKE_AUTH_HEADER")); + if (explicit) return { Authorization: explicit }; + const basic = env("AWATCH_BROWSER_SMOKE_BASIC_AUTH", env("DETMIR_PORTAL_SMOKE_BASIC_AUTH", env("DETMIR_BASIC_AUTH"))); + if (basic) return { Authorization: `Basic ${basic}` }; + return {}; +} + +function normalizeBaseUrl(raw) { + const value = raw.endsWith("/") ? raw : `${raw}/`; + return new URL(value).toString(); +} + +function missingMarkers(text, markers) { + const normalized = String(text || "").toLocaleLowerCase("ru-RU"); + return markers.filter((marker) => !normalized.includes(String(marker).toLocaleLowerCase("ru-RU"))); +} + +async function waitForPortalReady(page, timeout) { + await page.waitForSelector("#content", { timeout }); + await page.waitForSelector("[data-view-mode]", { timeout }); + await page.waitForFunction( + () => { + const status = document.querySelector("#loadingStatus"); + return !status || status.dataset.loadStatus === "READY" || document.body.innerText.includes("Данные готовы"); + }, + null, + { timeout }, + ); +} + +async function switchView(page, selector, timeout) { + await page.click(selector, { timeout }); + await page.waitForFunction( + (viewMode) => document.querySelector(`[data-view-mode="${viewMode}"]`)?.classList.contains("is-active"), + selector.match(/data-view-mode="([^"]+)"/)?.[1] || "", + { timeout }, + ); + await page.waitForTimeout(250); +} + +async function checkView(page, spec, artifactDir, timeout) { + await switchView(page, `[data-view-mode="${spec.viewMode}"]`, timeout); + const content = await page.locator("#content").innerText({ timeout }); + const body = await page.locator("body").innerText({ timeout }); + const missing = missingMarkers(content, spec.required); + const forbidden = [ + "Internal Server Error", + "HTTP 500", + "Ошибка рендера", + "undefined", + "NaN", + ].filter((marker) => body.includes(marker)); + const notEmpty = content.trim().length >= 120; + const screenshot = path.join(artifactDir, spec.screenshot); + await page.screenshot({ path: screenshot, fullPage: true }); + return { + name: spec.name, + view_mode: spec.viewMode, + ok: missing.length === 0 && forbidden.length === 0 && notEmpty, + screenshot: path.relative(root, screenshot), + missing, + forbidden, + content_length: content.trim().length, + }; +} + +async function main() { + const { chromium } = loadPlaywright(); + const baseUrl = normalizeBaseUrl(env("AWATCH_BROWSER_SMOKE_URL", env("DETMIR_PORTAL_SMOKE_URL", "http://127.0.0.1:8720/portal/"))); + const timeout = Number(env("AWATCH_BROWSER_SMOKE_TIMEOUT_MS", "30000")); + const artifactDir = path.resolve(root, env("AWATCH_BROWSER_SMOKE_ARTIFACT_DIR", "artifacts/browser-smoke")); + fs.mkdirSync(artifactDir, { recursive: true }); + + const browser = await chromium.launch({ + headless: !["0", "false", "no"].includes(env("AWATCH_BROWSER_SMOKE_HEADLESS", "true").toLowerCase()), + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + viewport: { width: 1440, height: 980 }, + extraHTTPHeaders: authHeaders(), + }); + const page = await context.newPage(); + const consoleErrors = []; + const pageErrors = []; + const badResponses = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("response", (response) => { + if (response.status() >= 500) { + badResponses.push({ url: response.url(), status: response.status() }); + } + }); + + const specs = [ + { + name: "executive", + viewMode: "executive", + screenshot: "executive.png", + required: ["Индекс активности", "Почему такой индекс", "Риск-нарратив", "Рекомендуемые действия"], + }, + { + name: "workforce", + viewMode: "manager", + screenshot: "workforce.png", + required: ["Индекс активности", "Подразделения", "Тренд", "Почему такой индекс"], + }, + { + name: "security", + viewMode: "security", + screenshot: "security.png", + required: ["Рекомендуемые действия ИБ", "События безопасности", "Связь рисков и активности", "Требует проверки"], + }, + { + name: "forensics", + viewMode: "forensics", + screenshot: "forensics.png", + required: ["Расследования", "Timeline событий", "Материалы расследования", "Аудит"], + }, + ]; + + let results = []; + try { + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout }); + await waitForPortalReady(page, timeout); + results = []; + for (const spec of specs) { + results.push(await checkView(page, spec, artifactDir, timeout)); + } + } finally { + await browser.close(); + } + + const ok = results.every((result) => result.ok) + && consoleErrors.length === 0 + && pageErrors.length === 0 + && badResponses.length === 0; + process.stdout.write(`${JSON.stringify({ + ok, + base_url: baseUrl, + artifact_dir: path.relative(root, artifactDir), + checked_at_utc: new Date().toISOString(), + results, + console_errors: consoleErrors, + page_errors: pageErrors, + server_errors: badResponses, + }, null, 2)}\n`); + process.exitCode = ok ? 0 : 2; +} + +main().catch((error) => { + process.stderr.write(`${JSON.stringify({ ok: false, error: error.message }, null, 2)}\n`); + process.exit(1); +}); diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 78160cf..47a9d6e 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -208,16 +208,15 @@ async function main() { if (item.tab === "operator") { const readyBodyText = await page.locator("#content").innerText({ timeout }); const requiredExecutive = [ - "Главный вывод", + "Риск-нарратив", "Достоверность показателей", "Полнота данных", - "Главная причина риска", - "Подтверждающие слои", + "Главный риск", "Карта рисков", "Риски подразделений", - "Рабочая активность сотрудников", - "Контроль безопасности", - "Что требует внимания", + "Почему такой индекс активности?", + "Рекомендуемые действия", + "Пробел в данных", ]; checks.push({ name: "executive_dashboard_layer", @@ -233,7 +232,7 @@ async function main() { const cardHeadings = await page.$$eval("#content section.card h3, #content h3.section-title", (nodes) => nodes.map((node) => node.textContent.trim()), ); - const riskNarrativeIndex = cardHeadings.indexOf("Главный вывод"); + const riskNarrativeIndex = cardHeadings.indexOf("Риск-нарратив"); const executiveIndex = cardHeadings.indexOf("Сводка руководителя"); const businessRiskIndex = cardHeadings.indexOf("Риски подразделений"); const heatmapIndex = cardHeadings.indexOf("Карта рисков"); @@ -245,7 +244,7 @@ async function main() { businessRiskIndex > executiveIndex && heatmapIndex > businessRiskIndex, order: [ - "Главный вывод", + "Риск-нарратив", "Сводка руководителя", "Риски подразделений", "Карта рисков", @@ -295,7 +294,7 @@ async function main() { "Сводка руководителя", "ТОП-5 лучших подразделений", "ТОП-5 проблемных подразделений", - "Карта рисков", + "Heat Map подразделений", "Markdown-отчет", ].every((marker) => containsText(managerText, marker)) && !containsText(managerText, "Материалы расследования"),