chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user