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
+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") {
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, "Материалы расследования"),