mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-03 00:30:26 +00:00
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
346 lines
11 KiB
JavaScript
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);
|
|
});
|