OmniRoute/scripts/i18n/run-visual-qa.mjs
diegosouzapw 952b0b22c7 docs(i18n): add Swedish locale translations and i18n QA tooling
Add complete Swedish (sv) translation for all documentation files
including API Reference, README, and guides. Introduce automated
i18n QA infrastructure with visual regression testing across
multiple viewports and locales to validate translations.
2026-02-26 16:28:29 -03:00

477 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);
});