#!/usr/bin/env node import { promises as fs } from "node:fs"; import path from "node:path"; const ROOT = process.cwd(); const APP_DIR = path.join(ROOT, "src", "app"); const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages"); const REPORTS_DIR = path.join(ROOT, "docs", "reports"); const I18N_README_DIR = path.join(ROOT, "docs", "i18n"); const PRIORITY_LOCALES = ["es", "fr", "de", "ja", "ar"]; async function walk(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); const out = []; for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { out.push(...(await walk(full))); continue; } out.push(full); } return out; } function routeFromPage(pageFile) { const rel = path.relative(APP_DIR, pageFile).replace(/\\/g, "/"); const noPage = rel.replace(/(^|\/)page\.tsx$/, ""); if (!noPage) { return "/"; } const segments = noPage .split("/") .filter(Boolean) .filter((segment) => !/^\(.+\)$/.test(segment)); return `/${segments.join("/")}`; } function classifyPriority(route) { const highPatterns = [ "/dashboard/usage", "/dashboard/providers", "/dashboard/settings", "/dashboard/endpoint", "/dashboard/logs", "/dashboard/audit-log", "/dashboard/health", "/dashboard/api-manager", "/dashboard/cli-tools", "/dashboard/combos", "/dashboard/translator", "/dashboard/analytics", "/dashboard/costs", "/dashboard/limits", ]; if (highPatterns.some((pattern) => route.startsWith(pattern))) { return "Alta"; } if (route === "/") { return "Baixa"; } return "Media"; } function countRegex(content, regex) { const matches = content.match(regex); return matches ? matches.length : 0; } async function collectRouteRiskMetrics(pageFile) { const routeDir = path.dirname(pageFile); if (routeDir === APP_DIR) { const raw = await fs.readFile(pageFile, "utf8"); return { files: 1, fixedWidth: countRegex(raw, /\bw-(?:\d+|\[[^\]]+\]|\d+\/\d+)\b/g), directional: countRegex( raw, /\b(?:left|right|ml|mr|pl|pr)-[\w\[\]-]+\b|\btext-(?:left|right)\b/g ), clipping: countRegex(raw, /\btruncate\b|\bline-clamp-\d+\b|\boverflow-hidden\b/g), }; } const files = await (async function walkRouteFiles(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); const acc = []; for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (full !== routeDir) { const nestedEntries = await fs.readdir(full); if (nestedEntries.includes("page.tsx")) { continue; } } acc.push(...(await walkRouteFiles(full))); continue; } if (/\.(tsx|ts)$/.test(entry.name)) { acc.push(full); } } return acc; })(routeDir); let fixedWidth = 0; let directional = 0; let clipping = 0; for (const file of files) { const raw = await fs.readFile(file, "utf8"); fixedWidth += countRegex(raw, /\bw-(?:\d+|\[[^\]]+\]|\d+\/\d+)\b/g); directional += countRegex( raw, /\b(?:left|right|ml|mr|pl|pr)-[\w\[\]-]+\b|\btext-(?:left|right)\b/g ); clipping += countRegex(raw, /\btruncate\b|\bline-clamp-\d+\b|\boverflow-hidden\b/g); } return { files: files.length, fixedWidth, directional, clipping, }; } function getByPath(target, keyPath) { return keyPath.split(".").reduce((acc, token) => (acc ? acc[token] : undefined), target); } function collectStringKeys(node, prefix = "", out = []) { if (typeof node === "string") { out.push(prefix); return out; } if (Array.isArray(node)) { node.forEach((item, index) => collectStringKeys(item, `${prefix}[${index}]`, out)); return out; } if (node && typeof node === "object") { for (const key of Object.keys(node)) { collectStringKeys(node[key], prefix ? `${prefix}.${key}` : key, out); } } return out; } async function runAutomatedChecks() { const en = JSON.parse(await fs.readFile(path.join(MESSAGES_DIR, "en.json"), "utf8")); const enKeys = collectStringKeys(en).sort(); const localeFiles = (await fs.readdir(MESSAGES_DIR)).filter((file) => file.endsWith(".json")); const localeCodes = localeFiles.map((file) => file.replace(/\.json$/, "")).sort(); const parityIssues = []; for (const code of localeCodes) { if (code === "en") { continue; } const raw = await fs.readFile(path.join(MESSAGES_DIR, `${code}.json`), "utf8"); const data = JSON.parse(raw); const keys = collectStringKeys(data).sort(); const missing = enKeys.filter((key) => !keys.includes(key)); const extra = keys.filter((key) => !enKeys.includes(key)); if (missing.length || extra.length) { parityIssues.push({ code, missing: missing.length, extra: extra.length }); } } const readmeLabelChecks = []; // Check that README has language selector line with emoji flag const expectedPattern = /^🌐 \*\*Languages:\*\*/; for (const code of PRIORITY_LOCALES) { const readmePath = path.join(I18N_README_DIR, code, "README.md"); let content = ""; try { content = await fs.readFile(readmePath, "utf8"); } catch { // Skip if README doesn't exist continue; } const line = content.split("\n").find((entry) => entry.startsWith("🌐 **Languages:**")) || ""; const ok = expectedPattern.test(line); readmeLabelChecks.push({ file: `docs/i18n/${code}/README.md`, ok, line }); } let anchorLineRemoved = true; let brAppendixRemoved = true; // Check specific languages (ar, ja) for legacy content const legacyCheckLocales = ["ar", "ja"]; for (const code of legacyCheckLocales) { const readmePath = path.join(I18N_README_DIR, code, "README.md"); try { const content = await fs.readFile(readmePath, "utf8"); if (content.includes("**[English](#-omniroute--the-free-ai-gateway)**")) { anchorLineRemoved = false; } if (content.includes("## πŸ‡§πŸ‡· OmniRoute")) { brAppendixRemoved = false; } } catch { // Skip if README doesn't exist } } return { localeCodes, parityIssues, readmeLabelChecks, anchorLineRemoved, brAppendixRemoved, }; } async function main() { const allFiles = await walk(APP_DIR); const pageFiles = allFiles.filter((file) => file.endsWith("/page.tsx")).sort(); const automated = await runAutomatedChecks(); const routeRows = []; for (const pageFile of pageFiles) { const route = routeFromPage(pageFile); const metrics = await collectRouteRiskMetrics(pageFile); routeRows.push({ route, priority: classifyPriority(route), ...metrics, status: "Pendente validacao visual", }); } const date = new Date().toISOString().slice(0, 10); const reportPath = path.join(REPORTS_DIR, `i18n-qa-checklist-${date}.md`); const automatedChecksLines = [ `- Locale files detectados: **${automated.localeCodes.length}**`, `- Locales priorizados nesta rodada: **${PRIORITY_LOCALES.join(", ")}**`, `- Paridade EN x demais idiomas: **${automated.parityIssues.length === 0 ? "OK" : "COM GAPS"}**`, ]; if (automated.parityIssues.length > 0) { automatedChecksLines.push( ...automated.parityIssues.map( (issue) => ` - ${issue.code}: missing=${issue.missing} extra=${issue.extra}` ) ); } automatedChecksLines.push( `- Language selector (🌐 **Languages:**) in README (es/fr/de/ja/ar): **${automated.readmeLabelChecks.every((item) => item.ok) ? "OK" : "FALHAS"}**`, `- Linha legacy EN/PT removida em ja/ar: **${automated.anchorLineRemoved ? "OK" : "PENDENTE"}**`, `- ApΓͺndice "## πŸ‡§πŸ‡· OmniRoute" removido em ja/ar: **${automated.brAppendixRemoved ? "OK" : "PENDENTE"}**`, "- RTL habilitado globalmente para `ar` e `he` via `dir=rtl` no layout." ); const routeTableHeader = "| Rota | Prioridade | Arquivos da rota | Risco truncamento (w-*) | Risco RTL direcional (left/right etc.) | Clipping (`truncate/line-clamp/overflow-hidden`) | Status |"; const routeTableDivider = "|---|---|---:|---:|---:|---:|---|"; const routeTableRows = routeRows.map( (row) => `| \`${row.route}\` | ${row.priority} | ${row.files} | ${row.fixedWidth} | ${row.directional} | ${row.clipping} | ${row.status} |` ); const content = [ "# Checklist QA i18n - Revisao Priorizada", "", `Data: ${date}`, "Escopo: validacao de UI e traducao para `es`, `fr`, `de`, `ja`, `ar`.", "", "## Resultado da revisao manual priorizada (item 1)", "", "- Ajustes manuais aplicados em `messages` para `es/fr/de/ja/ar` nos namespaces de alta visibilidade:", " - `common.*`", " - `sidebar.*`", " - `header.*`", " - `home.*`", " - `auth.*`", "- Ajustes de README aplicados em `README.es.md`, `README.fr.md`, `README.de.md`, `README.ja.md`, `README.ar.md`:", " - Prefixo local da linha de idiomas (bandeiras mantidas)", " - Navegacao superior localizada em `ja/ar`", " - Remocao da linha legada de anchors EN/PT em `ja/ar`", " - Remocao de apendice duplicado em `ja/ar`", "", "## Checagens automaticas", "", ...automatedChecksLines, "", "## Checklist por pagina (item 2)", "", "Legenda:", "- Prioridade Alta: validar obrigatoriamente em desktop + mobile para `es/fr/de/ja/ar`, e em `ar` com fluxo RTL completo.", "- Risco truncamento: contagem heuristica de classes `w-*` em arquivos da rota.", "- Risco RTL direcional: contagem heuristica de classes com direcao fisica (`left/right/ml/mr/pl/pr/text-left/text-right`).", "", routeTableHeader, routeTableDivider, ...routeTableRows, "", "## Passos de validacao visual recomendados", "", "1. Trocar idioma no seletor e recarregar rota atual (cookie + refresh).", "2. Validar titulos, subtitulos, CTAs e textos de estado vazios/erro/sucesso.", "3. Em `ar`: validar alinhamento RTL, ordem visual dos blocos e leitura dos componentes em tabela.", "4. Validar cards/tabelas com textos longos para evitar corte indevido e overflow horizontal.", "5. Validar labels com placeholders (`{count}`, `{provider}`, `{url}`) e tags (``, ``).", "", "## Observacoes", "", "- Este checklist combina validacao automatica + roteiro de validacao manual por pagina.", "- Nao foram executados testes E2E visuais nesta rodada (Playwright/screenshot).", ].join("\n"); await fs.mkdir(REPORTS_DIR, { recursive: true }); await fs.writeFile(reportPath, `${content}\n`, "utf8"); console.log(reportPath); } main().catch((error) => { console.error(error); process.exit(1); });