test: add browser conformance smoke

This commit is contained in:
igor04091968
2026-06-07 20:36:26 +03:00
parent 5820de1d85
commit 037d47b592
6 changed files with 343 additions and 9 deletions
+1
View File
@@ -12,6 +12,7 @@
windows/*.report.json windows/*.report.json
.rollout-logs/ .rollout-logs/
output/ output/
artifacts/browser-smoke/
dist/ dist/
.ai/ .ai/
.autonomous/ .autonomous/
+1
View File
@@ -253,6 +253,7 @@ collectors.
- [Pilot success criteria](docs/PILOT_SUCCESS_CRITERIA_RU.md) - [Pilot success criteria](docs/PILOT_SUCCESS_CRITERIA_RU.md)
- [Competitive positioning](docs/COMPETITIVE_POSITIONING_RU.md) - [Competitive positioning](docs/COMPETITIVE_POSITIONING_RU.md)
- [Roadmap conformance audit](docs/ROADMAP_CONFORMANCE_AUDIT_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) - [Production readiness портала](docs/PRODUCTION_READINESS_RU.md)
- [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md) - [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md)
- [Executive Action Center](docs/EXECUTIVE_ACTION_CENTER_RU.md) - [Executive Action Center](docs/EXECUTIVE_ACTION_CENTER_RU.md)
+80
View File
@@ -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='<BASE64_USER_PASSWORD>' \
AWATCH_BROWSER_SMOKE_URL=https://<PORTAL_HOST>/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.
@@ -165,3 +165,65 @@ docs/BROWSER_CONFORMANCE_RU.md
4. Примеры screenshots. 4. Примеры screenshots.
5. Результаты smoke. 5. Результаты smoke.
6. Ограничения. 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 шагом.
+191
View File
@@ -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);
});
+8 -9
View File
@@ -208,16 +208,15 @@ async function main() {
if (item.tab === "operator") { if (item.tab === "operator") {
const readyBodyText = await page.locator("#content").innerText({ timeout }); const readyBodyText = await page.locator("#content").innerText({ timeout });
const requiredExecutive = [ const requiredExecutive = [
"Главный вывод", "Риск-нарратив",
"Достоверность показателей", "Достоверность показателей",
"Полнота данных", "Полнота данных",
"Главная причина риска", "Главный риск",
"Подтверждающие слои",
"Карта рисков", "Карта рисков",
"Риски подразделений", "Риски подразделений",
"Рабочая активность сотрудников", "Почему такой индекс активности?",
"Контроль безопасности", "Рекомендуемые действия",
"Что требует внимания", "Пробел в данных",
]; ];
checks.push({ checks.push({
name: "executive_dashboard_layer", 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) => const cardHeadings = await page.$$eval("#content section.card h3, #content h3.section-title", (nodes) =>
nodes.map((node) => node.textContent.trim()), nodes.map((node) => node.textContent.trim()),
); );
const riskNarrativeIndex = cardHeadings.indexOf("Главный вывод"); const riskNarrativeIndex = cardHeadings.indexOf("Риск-нарратив");
const executiveIndex = cardHeadings.indexOf("Сводка руководителя"); const executiveIndex = cardHeadings.indexOf("Сводка руководителя");
const businessRiskIndex = cardHeadings.indexOf("Риски подразделений"); const businessRiskIndex = cardHeadings.indexOf("Риски подразделений");
const heatmapIndex = cardHeadings.indexOf("Карта рисков"); const heatmapIndex = cardHeadings.indexOf("Карта рисков");
@@ -245,7 +244,7 @@ async function main() {
businessRiskIndex > executiveIndex && businessRiskIndex > executiveIndex &&
heatmapIndex > businessRiskIndex, heatmapIndex > businessRiskIndex,
order: [ order: [
"Главный вывод", "Риск-нарратив",
"Сводка руководителя", "Сводка руководителя",
"Риски подразделений", "Риски подразделений",
"Карта рисков", "Карта рисков",
@@ -295,7 +294,7 @@ async function main() {
"Сводка руководителя", "Сводка руководителя",
"ТОП-5 лучших подразделений", "ТОП-5 лучших подразделений",
"ТОП-5 проблемных подразделений", "ТОП-5 проблемных подразделений",
"Карта рисков", "Heat Map подразделений",
"Markdown-отчет", "Markdown-отчет",
].every((marker) => containsText(managerText, marker)) ].every((marker) => containsText(managerText, marker))
&& !containsText(managerText, "Материалы расследования"), && !containsText(managerText, "Материалы расследования"),