Spaces:
Runtime error
Runtime error
| 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); | |
| }); | |