OmniRoute/scripts/i18n/generate-qa-checklist.mjs
zenobit b6d4442800 Apply suggestions from code review
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-01 01:14:17 +02:00

346 lines
11 KiB
JavaScript

#!/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 (`<logs>`, `<analytics>`).",
"",
"## 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);
});