test: add browser conformance smoke
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 шагом.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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, "Материалы расследования"),
|
||||||
|
|||||||
Reference in New Issue
Block a user