Files
2026-07-13 13:39:12 +08:00

478 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { chromium, devices } from "@playwright/test";
import { promises as fs } from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const REPORTS_DIR = path.join(ROOT, "docs", "reports");
const DATE = new Date().toISOString().slice(0, 10);
const BASE_URL = process.env.QA_BASE_URL || "http://localhost:20128";
const REPORT_SUFFIX = process.env.QA_REPORT_SUFFIX ? `-${process.env.QA_REPORT_SUFFIX}` : "";
const DEFAULT_LOCALES = ["es", "fr", "de", "ja", "ar"];
const RTL_LOCALES = new Set(["ar", "he"]);
const ROUTES = [
"/dashboard/analytics",
"/dashboard/api-manager",
"/dashboard/audit-log",
"/dashboard/cli-tools",
"/dashboard/combos",
"/dashboard/costs",
"/dashboard/endpoint",
"/dashboard/health",
"/dashboard/limits",
"/dashboard/logs",
"/dashboard/providers",
"/dashboard/settings",
"/dashboard/settings/pricing",
"/dashboard/translator",
"/dashboard/usage",
];
function parseRouteList(raw) {
if (!raw) {
return null;
}
const list = raw
.split(",")
.map((value) => value.trim())
.filter(Boolean)
.map((value) => (value.startsWith("/") ? value : `/${value}`));
return list.length > 0 ? list : null;
}
const customRoutes = parseRouteList(process.env.QA_ROUTES);
const ACTIVE_ROUTES = customRoutes || ROUTES;
function parseLocaleList(raw) {
if (!raw) {
return null;
}
const list = raw
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return list.length > 0 ? list : null;
}
const customLocales = parseLocaleList(process.env.QA_LOCALES);
const ACTIVE_LOCALES = customLocales || DEFAULT_LOCALES;
const VIEWPORTS = [
{
name: "desktop",
viewport: { width: 1440, height: 900 },
userAgent: devices["Desktop Chrome"].userAgent,
},
{
name: "mobile",
viewport: devices["iPhone 13"].viewport,
userAgent: devices["iPhone 13"].userAgent,
isMobile: true,
hasTouch: true,
deviceScaleFactor: devices["iPhone 13"].deviceScaleFactor,
},
];
function safeRoute(route) {
return route === "/"
? "root"
: route.replace(/^\//, "").replace(/\//g, "__").replace(/\[|\]/g, "");
}
function classifyResult(item) {
if (item.error && !item.error.startsWith("screenshot-error:")) {
return "Ajuste necessario";
}
if (item.redirectedToLogin && item.route !== "/login") {
return "Ajuste necessario";
}
if (item.rtlMismatch) {
return "Ajuste necessario";
}
if (item.overflowCount > 8 || item.clippedCount > 6) {
return "Revisar";
}
if (item.error && item.error.startsWith("screenshot-error:")) {
return "Revisar";
}
return "OK";
}
async function ensureLoggedIn(page) {
await page.goto(`${BASE_URL}/dashboard`, { waitUntil: "domcontentloaded", timeout: 120000 });
if (!page.url().includes("/login")) {
return true;
}
const password = process.env.INITIAL_PASSWORD || "123456";
const input = page.locator('input[type="password"]');
if ((await input.count()) === 0) {
return false;
}
await input.first().fill(password);
const submit = page.locator('button[type="submit"]');
if ((await submit.count()) === 0) {
return false;
}
await submit.first().click();
await page.waitForTimeout(700);
try {
await page.waitForURL(/\/dashboard(\/.*)?/, { timeout: 30000 });
} catch {
// Keep going, final URL check below.
}
return !page.url().includes("/login");
}
async function evaluatePageHealth(page, locale) {
return page.evaluate(
({ locale, expectRtl }) => {
const hasHorizontalScrollContext = (el) => {
let current = el;
while (current) {
if (!(current instanceof HTMLElement)) {
break;
}
const cls = typeof current.className === "string" ? current.className : "";
if (
cls.includes("overflow-x-auto") ||
cls.includes("overflow-auto") ||
cls.includes("overflow-scroll")
) {
return true;
}
const style = window.getComputedStyle(current);
if (style.overflowX === "auto" || style.overflowX === "scroll") {
return true;
}
current = current.parentElement;
}
return false;
};
const isVisible = (el) => {
const style = window.getComputedStyle(el);
if (
style.display === "none" ||
style.visibility === "hidden" ||
Number(style.opacity) === 0
) {
return false;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const nodes = Array.from(document.querySelectorAll("*"));
let overflowCount = 0;
let clippedCount = 0;
const samples = [];
for (const el of nodes) {
if (!(el instanceof HTMLElement)) {
continue;
}
if (!isVisible(el)) {
continue;
}
const text = (el.innerText || "").trim().replace(/\s+/g, " ");
if (!text || text.length < 12) {
continue;
}
if (text === "Skip to content") {
continue;
}
const cls = el.className || "";
const classString = typeof cls === "string" ? cls : "";
if (
classString.includes("monaco-") ||
el.closest(".monaco-editor") ||
el.closest(".monaco-scrollable-element")
) {
continue;
}
if (el.tagName === "HTML" || el.tagName === "BODY") {
continue;
}
const overW = el.scrollWidth > el.clientWidth + 1;
if (!overW) {
continue;
}
if (hasHorizontalScrollContext(el)) {
continue;
}
// Decorative absolute layers often exceed bounds by design and should not
// be treated as localization regressions.
const style = window.getComputedStyle(el);
if (style.position === "absolute" && el.getAttribute("aria-hidden") === "true") {
continue;
}
overflowCount += 1;
const looksClipped =
classString.includes("truncate") ||
classString.includes("line-clamp-") ||
style.overflowX === "hidden" ||
style.overflowY === "hidden" ||
style.textOverflow === "ellipsis";
if (looksClipped) {
clippedCount += 1;
}
if (samples.length < 10 && looksClipped) {
samples.push({
tag: el.tagName.toLowerCase(),
className: classString.slice(0, 120),
text: text.slice(0, 140),
});
}
}
const dir = document.documentElement.getAttribute("dir") || "";
const lang = document.documentElement.getAttribute("lang") || "";
const rtlMismatch = expectRtl ? dir !== "rtl" : dir === "rtl";
return {
locale,
dir,
lang,
rtlMismatch,
overflowCount,
clippedCount,
clippedSamples: samples,
};
},
{ locale, expectRtl: RTL_LOCALES.has(locale) }
);
}
async function run() {
await fs.mkdir(REPORTS_DIR, { recursive: true });
const screenshotRoot = path.join(REPORTS_DIR, `i18n-qa-screenshots-${DATE}`);
await fs.mkdir(screenshotRoot, { recursive: true });
const browser = await chromium.launch({ headless: true });
const allResults = [];
for (const viewportSpec of VIEWPORTS) {
const context = await browser.newContext({
viewport: viewportSpec.viewport,
userAgent: viewportSpec.userAgent,
isMobile: viewportSpec.isMobile,
hasTouch: viewportSpec.hasTouch,
deviceScaleFactor: viewportSpec.deviceScaleFactor,
});
const page = await context.newPage();
const logged = await ensureLoggedIn(page);
console.log(`[qa] ${viewportSpec.name} login state: ${logged ? "ok" : "not-authenticated"}`);
for (const locale of ACTIVE_LOCALES) {
await context.addCookies([
{
name: "NEXT_LOCALE",
value: locale,
domain: "localhost",
path: "/",
},
]);
for (const route of ACTIVE_ROUTES) {
const started = Date.now();
const result = {
route,
locale,
viewport: viewportSpec.name,
finalUrl: "",
durationMs: 0,
status: "OK",
redirectedToLogin: false,
rtlMismatch: false,
overflowCount: 0,
clippedCount: 0,
clippedSamples: [],
dir: "",
lang: "",
error: "",
screenshot: "",
};
try {
await page.goto(`${BASE_URL}${route}`, {
waitUntil: "domcontentloaded",
timeout: 120000,
});
await page.waitForTimeout(500);
result.finalUrl = page.url();
result.redirectedToLogin = result.finalUrl.includes("/login");
const metrics = await evaluatePageHealth(page, locale);
result.rtlMismatch = metrics.rtlMismatch;
result.overflowCount = metrics.overflowCount;
result.clippedCount = metrics.clippedCount;
result.clippedSamples = metrics.clippedSamples;
result.dir = metrics.dir;
result.lang = metrics.lang;
} catch (error) {
result.error = String(error?.message || error);
}
result.durationMs = Date.now() - started;
const localeDir = path.join(screenshotRoot, viewportSpec.name, locale);
await fs.mkdir(localeDir, { recursive: true });
const screenshotPath = path.join(localeDir, `${safeRoute(route)}.png`);
try {
await page.screenshot({ path: screenshotPath, fullPage: true, timeout: 120000 });
result.screenshot = path.relative(ROOT, screenshotPath).replaceAll("\\", "/");
} catch (error) {
if (!result.error) {
result.error = `screenshot-error: ${String(error?.message || error)}`;
}
}
result.status = classifyResult(result);
allResults.push(result);
console.log(
`[qa] ${viewportSpec.name} ${locale} ${route} -> ${result.status}` +
`${result.redirectedToLogin ? " (redirected-login)" : ""}` +
`${result.rtlMismatch ? " (rtl-mismatch)" : ""}` +
`${result.clippedCount ? ` (clipped=${result.clippedCount})` : ""}`
);
}
}
await context.close();
}
await browser.close();
const jsonPath = path.join(REPORTS_DIR, `i18n-visual-qa-${DATE}${REPORT_SUFFIX}.json`);
await fs.writeFile(jsonPath, `${JSON.stringify(allResults, null, 2)}\n`, "utf8");
const aggregate = new Map();
const aggregateByLocale = new Map();
for (const item of allResults) {
const key = item.route;
if (!aggregate.has(key)) {
aggregate.set(key, {
route: key,
ok: 0,
review: 0,
adjust: 0,
clipped: 0,
loginRedirects: 0,
rtlMismatch: 0,
});
}
const slot = aggregate.get(key);
if (item.status === "OK") slot.ok += 1;
if (item.status === "Revisar") slot.review += 1;
if (item.status === "Ajuste necessario") slot.adjust += 1;
slot.clipped += item.clippedCount;
if (item.redirectedToLogin) slot.loginRedirects += 1;
if (item.rtlMismatch) slot.rtlMismatch += 1;
const localeKey = item.locale;
if (!aggregateByLocale.has(localeKey)) {
aggregateByLocale.set(localeKey, {
locale: localeKey,
ok: 0,
review: 0,
adjust: 0,
clipped: 0,
loginRedirects: 0,
rtlMismatch: 0,
});
}
const localeSlot = aggregateByLocale.get(localeKey);
if (item.status === "OK") localeSlot.ok += 1;
if (item.status === "Revisar") localeSlot.review += 1;
if (item.status === "Ajuste necessario") localeSlot.adjust += 1;
localeSlot.clipped += item.clippedCount;
if (item.redirectedToLogin) localeSlot.loginRedirects += 1;
if (item.rtlMismatch) localeSlot.rtlMismatch += 1;
}
const lines = [
"# Relatorio QA Visual i18n",
"",
`Data: ${DATE}`,
`Base URL: ${BASE_URL}`,
`Locales: ${ACTIVE_LOCALES.join(", ")}`,
`Viewports: ${VIEWPORTS.map((v) => v.name).join(", ")}`,
"",
"## Resumo por rota",
"",
"| Rota | OK | Revisar | Ajuste necessario | Clipped total | Redirect login | RTL mismatch |",
"|---|---:|---:|---:|---:|---:|---:|",
...Array.from(aggregate.values()).map(
(row) =>
`| \`${row.route}\` | ${row.ok} | ${row.review} | ${row.adjust} | ${row.clipped} | ${row.loginRedirects} | ${row.rtlMismatch} |`
),
"",
"## Resumo por locale",
"",
"| Locale | OK | Revisar | Ajuste necessario | Clipped total | Redirect login | RTL mismatch |",
"|---|---:|---:|---:|---:|---:|---:|",
...Array.from(aggregateByLocale.values())
.sort((a, b) => a.locale.localeCompare(b.locale))
.map(
(row) =>
`| \`${row.locale}\` | ${row.ok} | ${row.review} | ${row.adjust} | ${row.clipped} | ${row.loginRedirects} | ${row.rtlMismatch} |`
),
"",
"## Artefatos",
"",
`- JSON detalhado: \`${path.relative(ROOT, jsonPath)}\``,
`- Screenshots: \`${path.relative(ROOT, screenshotRoot)}\``,
"",
"## Observacoes",
"",
"- Status `Revisar` e `Ajuste necessario` sao heuristicas automaticas (overflow/clipping/RTL/redirect).",
"- A validacao final de UX deve ser confirmada manualmente nas rotas sinalizadas.",
];
const mdPath = path.join(REPORTS_DIR, `i18n-visual-qa-${DATE}${REPORT_SUFFIX}.md`);
await fs.writeFile(mdPath, `${lines.join("\n")}\n`, "utf8");
console.log(mdPath);
console.log(jsonPath);
}
run().catch((error) => {
console.error(error);
process.exit(1);
});