mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:44:47 +00:00
page.route() перехватывает /api/clients и возвращает тестового клиента. Карточка клиента теперь открывается и проверяется без реальных данных. 17 чеков, 0 пропущено. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
372 lines
17 KiB
JavaScript
372 lines
17 KiB
JavaScript
/**
|
||
* UI smoke-тест MiniApp через Playwright.
|
||
* Запуск: node tests/ui_smoke.js — локальный сервер (по умолчанию)
|
||
* SMOKE_URL=https://... node tests/ui_smoke.js — произвольный URL
|
||
*
|
||
* Что проверяет:
|
||
* - Нет JS-ошибок (ReferenceError, TypeError и т.п.) на каждом экране
|
||
* - Список клиентов загружается
|
||
* - Карточка клиента открывается без ошибок
|
||
* - Форма нового клиента открывается без ошибок
|
||
* - Экраны замеров, сборок, #/inbox, #/me открываются без ошибок
|
||
*/
|
||
|
||
const { chromium } = require("playwright");
|
||
const crypto = require("crypto");
|
||
const { spawn } = require("child_process");
|
||
const path = require("path");
|
||
|
||
// ─── Конфигурация ────────────────────────────────────────────────────────────
|
||
const BOT_TOKEN = "8281503057:AAEXmOepY8quH8E3RqOjFbgn7owV1ngnbGA";
|
||
const ADMIN_TG_ID = 5937498515;
|
||
const LOCAL_PORT = 8787;
|
||
const PROJECT_ROOT = path.resolve(__dirname, ".."); // без пробелов в аргументах
|
||
const REMOTE_URL = "https://wasrusgen.github.io/zov-tech/";
|
||
// Если задана переменная окружения — используем её; иначе поднимаем локальный сервер
|
||
const USE_REMOTE = !!process.env.SMOKE_URL;
|
||
const MINIAPP_URL = process.env.SMOKE_URL || `http://localhost:${LOCAL_PORT}/`;
|
||
const TIMEOUT_MS = 15000;
|
||
|
||
// ─── Локальный сервер ────────────────────────────────────────────────────────
|
||
const http = require("http");
|
||
let _serveProc = null;
|
||
|
||
function pingServer(port) {
|
||
return new Promise((resolve) => {
|
||
const req = http.get(`http://localhost:${port}/`, (res) => { res.resume(); resolve(true); });
|
||
req.on("error", () => resolve(false));
|
||
req.setTimeout(500, () => { req.destroy(); resolve(false); });
|
||
});
|
||
}
|
||
|
||
function startLocalServer() {
|
||
return new Promise((resolve, reject) => {
|
||
if (USE_REMOTE) { resolve(); return; }
|
||
_serveProc = spawn("npx", ["serve", "miniapp", "-p", String(LOCAL_PORT), "--no-clipboard", "-s"], {
|
||
stdio: "ignore",
|
||
shell: true,
|
||
cwd: PROJECT_ROOT,
|
||
});
|
||
_serveProc.on("error", reject);
|
||
|
||
// Polling: ждём HTTP-ответа, максимум 15 сек
|
||
const deadline = Date.now() + 15000;
|
||
const poll = setInterval(async () => {
|
||
if (await pingServer(LOCAL_PORT)) { clearInterval(poll); resolve(); return; }
|
||
if (Date.now() > deadline) { clearInterval(poll); reject(new Error("Локальный сервер не запустился за 15 сек")); }
|
||
}, 400);
|
||
});
|
||
}
|
||
|
||
function stopLocalServer() {
|
||
if (_serveProc) { _serveProc.kill(); _serveProc = null; }
|
||
}
|
||
|
||
// ─── Генерация валидного initData ─────────────────────────────────────────────
|
||
function makeInitData() {
|
||
const user = JSON.stringify({
|
||
id: ADMIN_TG_ID,
|
||
first_name: "Руслан",
|
||
username: "wasrusgen",
|
||
language_code: "ru",
|
||
allows_write_to_pm: true,
|
||
});
|
||
const fields = {
|
||
auth_date: String(Math.floor(Date.now() / 1000)),
|
||
user,
|
||
};
|
||
const dataCheckString = Object.keys(fields).sort()
|
||
.map(k => `${k}=${fields[k]}`).join("\n");
|
||
const secretKey = crypto.createHmac("sha256", "WebAppData")
|
||
.update(BOT_TOKEN).digest();
|
||
const hash = crypto.createHmac("sha256", secretKey)
|
||
.update(dataCheckString).digest("hex");
|
||
return new URLSearchParams({ ...fields, hash }).toString();
|
||
}
|
||
|
||
// ─── Отчёт ───────────────────────────────────────────────────────────────────
|
||
const RESULTS = [];
|
||
function pass(name, detail = "") {
|
||
RESULTS.push({ ok: true, name, detail });
|
||
console.log(` ✅ ${name}${detail ? " — " + detail : ""}`);
|
||
}
|
||
function fail(name, detail = "") {
|
||
RESULTS.push({ ok: false, name, detail });
|
||
console.log(` ❌ ${name}${detail ? " — " + detail : ""}`);
|
||
}
|
||
function section(title) {
|
||
console.log(`\n${"─".repeat(55)}\n ${title}\n${"─".repeat(55)}`);
|
||
}
|
||
|
||
// ─── Вспомогательные функции ─────────────────────────────────────────────────
|
||
|
||
/** Ждёт появления элемента на странице (или возвращает null по таймауту) */
|
||
async function waitForSelector(page, selector, timeout = TIMEOUT_MS) {
|
||
try {
|
||
await page.waitForSelector(selector, { timeout });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Возвращает JS-ошибки накопленные с момента последнего вызова reset */
|
||
function makeErrorCollector(page) {
|
||
const errors = [];
|
||
page.on("pageerror", e => errors.push(e.message));
|
||
page.on("console", msg => {
|
||
if (msg.type() === "error") errors.push(msg.text());
|
||
});
|
||
return {
|
||
flush() { const copy = [...errors]; errors.length = 0; return copy; },
|
||
any() { return errors.length > 0; },
|
||
};
|
||
}
|
||
|
||
// ─── Основные тесты ──────────────────────────────────────────────────────────
|
||
|
||
async function run() {
|
||
await startLocalServer();
|
||
|
||
console.log(`\n${"=".repeat(55)}`);
|
||
console.log(" UI SMOKE-ТЕСТ MiniApp (Playwright)");
|
||
console.log(` ${MINIAPP_URL}${USE_REMOTE ? "" : " (локальный сервер)"}`);
|
||
console.log(`${"=".repeat(55)}`);
|
||
|
||
const browser = await chromium.launch({ headless: true });
|
||
const context = await browser.newContext({
|
||
viewport: { width: 390, height: 844 },
|
||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) TelegramBot",
|
||
});
|
||
const page = await context.newPage();
|
||
const errors = makeErrorCollector(page);
|
||
|
||
const initData = makeInitData();
|
||
const initDataUnsafe = {
|
||
user: { id: ADMIN_TG_ID, first_name: "Руслан", username: "wasrusgen", language_code: "ru" },
|
||
auth_date: Math.floor(Date.now() / 1000),
|
||
hash: "",
|
||
};
|
||
|
||
// ── Инжектируем mock Telegram.WebApp ──────────────────────────────────────
|
||
await page.addInitScript(({ initData, initDataUnsafe }) => {
|
||
window.Telegram = {
|
||
WebApp: {
|
||
initData,
|
||
initDataUnsafe,
|
||
colorScheme: "dark",
|
||
themeParams: {},
|
||
isExpanded: true,
|
||
viewportHeight: 844,
|
||
viewportStableHeight: 844,
|
||
MainButton: { show() {}, hide() {}, setText() {}, onClick() {} },
|
||
BackButton: { show() {}, hide() {}, onClick() {} },
|
||
HapticFeedback: { impactOccurred() {}, notificationOccurred() {} },
|
||
ready() {},
|
||
expand() {},
|
||
close() {},
|
||
showAlert(msg) { console.log("[tg.showAlert]", msg); },
|
||
showConfirm(msg, cb) { cb(true); },
|
||
},
|
||
};
|
||
}, { initData, initDataUnsafe });
|
||
|
||
// ── 1. Загрузка страницы ──────────────────────────────────────────────────
|
||
section("📄 Загрузка приложения");
|
||
try {
|
||
await page.goto(MINIAPP_URL, { waitUntil: "domcontentloaded", timeout: 20000 });
|
||
const appEl = await waitForSelector(page, "#app", 5000);
|
||
appEl ? pass("MiniApp загрузился (#app найден)") : fail("MiniApp не загрузился (#app не найден)");
|
||
} catch (e) {
|
||
fail("Ошибка загрузки страницы", e.message);
|
||
await browser.close();
|
||
stopLocalServer();
|
||
printSummary();
|
||
return;
|
||
}
|
||
const jsErrOnLoad = errors.flush();
|
||
jsErrOnLoad.length === 0
|
||
? pass("Нет JS-ошибок при загрузке")
|
||
: fail("JS-ошибки при загрузке", jsErrOnLoad.slice(0, 2).join(" | "));
|
||
|
||
// Авторизуемся как менеджер
|
||
try {
|
||
await page.goto(MINIAPP_URL + "?role=manager", { waitUntil: "domcontentloaded", timeout: 15000 });
|
||
await page.waitForTimeout(1500);
|
||
} catch { /* ок */ }
|
||
|
||
// ── Mock API: подставляем тестового клиента ──────────────────────────────
|
||
// Mock: список клиентов с тестовой записью
|
||
await page.route("**/api/clients", route => {
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
ok: true,
|
||
clients: [{
|
||
client_name: "Тест Смоук",
|
||
client_phone: "+70000000000",
|
||
client_tg_id: "",
|
||
address: "ул. Тестовая, 1",
|
||
leads_count: 0,
|
||
last_lead_at: "",
|
||
}],
|
||
}),
|
||
});
|
||
});
|
||
|
||
// ── 2. Экран клиентов ─────────────────────────────────────────────────────
|
||
section("👥 Список клиентов");
|
||
await page.evaluate(() => { location.hash = "#/clients"; });
|
||
await page.waitForTimeout(3000);
|
||
const jsErrClients = errors.flush();
|
||
jsErrClients.length === 0
|
||
? pass("Нет JS-ошибок на экране клиентов")
|
||
: fail("JS-ошибки на экране клиентов", jsErrClients.slice(0, 2).join(" | "));
|
||
|
||
// Проверяем что что-то отрендерилось
|
||
const hasClientList = await page.evaluate(() =>
|
||
document.querySelector(".client-card, .empty, .error") !== null
|
||
);
|
||
hasClientList
|
||
? pass("Список клиентов отрендерился")
|
||
: fail("Список клиентов пустой (нет .client-card, .empty, .error)");
|
||
|
||
// ── 3. Карточка первого клиента (mock-фикстура) ───────────────────────────
|
||
section("🪪 Карточка клиента");
|
||
const firstCard = await page.$(".client-card");
|
||
if (firstCard) {
|
||
await firstCard.click();
|
||
await page.waitForTimeout(3000);
|
||
const jsErrCard = errors.flush();
|
||
jsErrCard.length === 0
|
||
? pass("Нет JS-ошибок при открытии карточки")
|
||
: fail("JS-ошибки в карточке клиента", jsErrCard.slice(0, 2).join(" | "));
|
||
|
||
const headerText = await page.evaluate(() =>
|
||
document.querySelector(".podbor-title")?.textContent?.trim() || ""
|
||
);
|
||
(headerText || "").toLowerCase().includes("карточка")
|
||
? pass("Заголовок карточки", headerText)
|
||
: fail("Ожидался заголовок 'Карточка клиента'", `получили: "${headerText}"`);
|
||
|
||
const hasDetails = await page.evaluate(() =>
|
||
document.querySelector(".client-detail-head, .client-quick-actions") !== null
|
||
);
|
||
hasDetails
|
||
? pass("Содержимое карточки отрендерилось")
|
||
: fail("Карточка пустая (нет .client-detail-head)");
|
||
} else {
|
||
fail("Карточка клиента — .client-card не найден после mock-фикстуры");
|
||
}
|
||
|
||
// ── 4. Форма нового клиента ───────────────────────────────────────────────
|
||
section("➕ Форма нового клиента");
|
||
await page.evaluate(() => { location.hash = "#/clients/new"; });
|
||
await page.waitForTimeout(2000);
|
||
const jsErrNew = errors.flush();
|
||
jsErrNew.length === 0
|
||
? pass("Нет JS-ошибок в форме нового клиента")
|
||
: fail("JS-ошибки в форме нового клиента", jsErrNew.slice(0, 2).join(" | "));
|
||
|
||
const hasForm = await page.evaluate(() =>
|
||
document.querySelector("#fn, #ph") !== null
|
||
);
|
||
hasForm
|
||
? pass("Форма отрендерилась (поля ФИО и телефон)")
|
||
: fail("Форма не отрендерилась");
|
||
|
||
// ── 5. Экран замеров ──────────────────────────────────────────────────────
|
||
section("📐 Экран замеров");
|
||
await page.evaluate(() => { location.hash = "#/measurements"; });
|
||
await page.waitForTimeout(3000);
|
||
const jsErrMeasure = errors.flush();
|
||
jsErrMeasure.length === 0
|
||
? pass("Нет JS-ошибок на экране замеров")
|
||
: fail("JS-ошибки на экране замеров", jsErrMeasure.slice(0, 2).join(" | "));
|
||
|
||
// ── 6. Экран сборок ───────────────────────────────────────────────────────
|
||
section("🔧 Экран сборок");
|
||
await page.evaluate(() => { location.hash = "#/assembly"; });
|
||
await page.waitForTimeout(3000);
|
||
const jsErrAssembly = errors.flush();
|
||
jsErrAssembly.length === 0
|
||
? pass("Нет JS-ошибок на экране сборок")
|
||
: fail("JS-ошибки на экране сборок", jsErrAssembly.slice(0, 2).join(" | "));
|
||
|
||
// ── 7. Входящие задачи менеджера (#/inbox) ────────────────────────────────
|
||
section("📥 Входящие менеджера (#/inbox)");
|
||
await page.evaluate(() => { location.hash = "#/inbox"; });
|
||
await page.waitForTimeout(3000);
|
||
const jsErrInbox = errors.flush();
|
||
jsErrInbox.length === 0
|
||
? pass("Нет JS-ошибок на экране #/inbox")
|
||
: fail("JS-ошибки на экране #/inbox", jsErrInbox.slice(0, 2).join(" | "));
|
||
|
||
const inboxRendered = await page.evaluate(() =>
|
||
document.querySelector(".podbor-header, .empty, .error, .assembly-card") !== null
|
||
);
|
||
inboxRendered
|
||
? pass("#/inbox отрендерился (заголовок или список)")
|
||
: fail("#/inbox не отрендерился — нет .podbor-header / .empty / .error");
|
||
|
||
const inboxTitle = await page.evaluate(() =>
|
||
document.querySelector(".podbor-title")?.textContent?.trim() || ""
|
||
);
|
||
inboxTitle === "Входящие"
|
||
? pass("Заголовок «Входящие» корректен")
|
||
: fail("Неверный заголовок #/inbox", `получили: "${inboxTitle}"`);
|
||
|
||
// ── 8. Профиль (#/me) ─────────────────────────────────────────────────────
|
||
section("👤 Профиль (#/me)");
|
||
await page.evaluate(() => { location.hash = "#/me"; });
|
||
await page.waitForTimeout(3000);
|
||
const jsErrMe = errors.flush();
|
||
jsErrMe.length === 0
|
||
? pass("Нет JS-ошибок на экране #/me")
|
||
: fail("JS-ошибки на экране #/me", jsErrMe.slice(0, 2).join(" | "));
|
||
|
||
const meRendered = await page.evaluate(() =>
|
||
document.querySelector(".podbor-header, .error") !== null
|
||
);
|
||
meRendered
|
||
? pass("#/me отрендерился")
|
||
: fail("#/me не отрендерился — нет .podbor-header");
|
||
|
||
// ── 9. Снимок экрана для отчёта ───────────────────────────────────────────
|
||
await page.evaluate(() => { location.hash = "#/clients"; });
|
||
await page.waitForTimeout(2000);
|
||
await page.screenshot({ path: "tests/ui_last_run.png", fullPage: false });
|
||
pass("Скриншот сохранён", "tests/ui_last_run.png");
|
||
|
||
await browser.close();
|
||
stopLocalServer();
|
||
printSummary();
|
||
}
|
||
|
||
function printSummary() {
|
||
const passed = RESULTS.filter(r => r.ok).length;
|
||
const failed = RESULTS.filter(r => !r.ok).length;
|
||
console.log(`\n${"=".repeat(55)}`);
|
||
console.log(` ИТОГО: ${passed} ✅ / ${failed} ❌`);
|
||
console.log(`${"=".repeat(55)}\n`);
|
||
if (failed > 0) {
|
||
console.log("📋 ЗАМЕЧАНИЯ К УСТРАНЕНИЮ:\n");
|
||
RESULTS.filter(r => !r.ok).forEach(r => {
|
||
console.log(` ❌ ${r.name}`);
|
||
if (r.detail) console.log(` → ${r.detail}`);
|
||
});
|
||
console.log();
|
||
process.exit(1);
|
||
} else {
|
||
console.log("✅ Все UI-тесты прошли.\n");
|
||
process.exit(0);
|
||
}
|
||
}
|
||
|
||
run().catch(e => {
|
||
stopLocalServer();
|
||
console.error("Критическая ошибка:", e.message);
|
||
process.exit(1);
|
||
});
|