From fff2c41c44a44c02893f67dc3233f92fa389b8f9 Mon Sep 17 00:00:00 2001 From: lijian19 Date: Wed, 9 Sep 2026 14:21:37 +0800 Subject: [PATCH] test(i18n): add QA tooling for key consistency and hardcoded scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Wave 1 QA tools per I18N_TEST_PLAN/TEST_TOOLS: - scripts/i18n/check-keys.mjs : en/zh-CN dictionary key-set consistency checker (missing/extra keys, shape, interpolation variables). Exit 1 on mismatch; feeds the G3 'key sets identical' gate. - scripts/i18n/scan-hardcoded.mjs : read-only hardcoded-UI-string scanner (report only, never rewrites — §3.4) for JSX text, a11y attrs and toast/ notify message entry points. - tests/i18n/: unit tests for both libs. Verified: check-keys passes on A4's 8-namespace en/zh-CN skeletons; scanner reports 2823 candidates across src/ with no noise. --- .../scripts/i18n/check-keys-lib.mjs | 149 ++++++++++++++++++ .../scripts/i18n/check-keys.mjs | 123 +++++++++++++++ .../scripts/i18n/scan-hardcoded-lib.mjs | 139 ++++++++++++++++ .../scripts/i18n/scan-hardcoded.mjs | 80 ++++++++++ .../tests/i18n/check-keys-lib.test.ts | 112 +++++++++++++ .../tests/i18n/scan-hardcoded-lib.test.ts | 100 ++++++++++++ 6 files changed, 703 insertions(+) create mode 100644 ui/litellm-dashboard/scripts/i18n/check-keys-lib.mjs create mode 100644 ui/litellm-dashboard/scripts/i18n/check-keys.mjs create mode 100644 ui/litellm-dashboard/scripts/i18n/scan-hardcoded-lib.mjs create mode 100644 ui/litellm-dashboard/scripts/i18n/scan-hardcoded.mjs create mode 100644 ui/litellm-dashboard/tests/i18n/check-keys-lib.test.ts create mode 100644 ui/litellm-dashboard/tests/i18n/scan-hardcoded-lib.test.ts diff --git a/ui/litellm-dashboard/scripts/i18n/check-keys-lib.mjs b/ui/litellm-dashboard/scripts/i18n/check-keys-lib.mjs new file mode 100644 index 00000000000..2067d36bd4e --- /dev/null +++ b/ui/litellm-dashboard/scripts/i18n/check-keys-lib.mjs @@ -0,0 +1,149 @@ +// T-02 key 集合一致性校验库(纯 Node,无第三方依赖)。 +// 对比 en / zh-CN 同名 namespace 的: +// 1. 递归扁平化 key 集合(含嵌套路径,点分隔) +// 2. 嵌套结构形状(叶子 vs 对象) +// 3. 每叶子上的 {{var}} 插值变量集合 +// 输出对称差清单,供 CLI / CI 门禁(G3)消费。 +// 只读、不改写任何文件。 + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const DEFAULT_LOCALES = ["en", "zh-CN"]; + +// 从 JSON 值中提取插值变量 `{{name}}`(i18next 标准插值)。 +export function extractInterpolationVars(value) { + const vars = new Set(); + if (typeof value !== "string") return vars; + const re = /\{\{\s*([A-Za-z0-9_][A-Za-z0-9_.]*)\s*\}\}/g; + let m; + while ((m = re.exec(value)) !== null) { + vars.add(m[1]); + } + return vars; +} + +// 递归扁平化一个字典:返回 Map。 +// 对象值递归,叶子(字符串/数字/布尔/null/数组)记录 value。 +export function flattenDict(obj, prefix = "") { + const out = new Map(); + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === "object" && !Array.isArray(v)) { + const sub = flattenDict(v, path); + for (const [p, info] of sub) out.set(p, info); + out.set(path, { value: undefined, isLeaf: false }); // 记录分支节点形状 + } else { + out.set(path, { value: v, isLeaf: true }); + } + } + return out; +} + +function parseJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +// 列出目录下顶层文件(不含子目录),去扩展名,返回 { name, absPath, ext }。 +export function listLocaleFiles(dir) { + if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return []; + return readdirSync(dir) + .filter((f) => statSync(join(dir, f)).isFile()) + .map((f) => { + const dot = f.lastIndexOf("."); + const name = dot > 0 ? f.slice(0, dot) : f; + return { name, absPath: join(dir, f), ext: dot > 0 ? f.slice(dot + 1) : "" }; + }); +} + +// 命名空间之间没有共同文件也能校验;只对有同名校的文件做比对。 +export function collectNamespaces(localeDir) { + const files = listLocaleFiles(localeDir).filter((f) => f.ext === "json"); + return new Map(files.map((f) => [f.name, parseJson(f.absPath)])); +} + +// 对比一对字典(en vs zh),返回差异详情。 +export function diffDicts(a, b) { + const fa = flattenDict(a); + const fb = flattenDict(b); + const aPaths = new Set(fa.keys()); + const bPaths = new Set(fb.keys()); + + const missing = []; // 在 b 中有、a 中没有(即 zh 缺 en 的 key) + const extra = []; // 在 a 中有、b 没有 + const shapeMismatch = []; + const interpolationMismatch = []; + + // 以 a (en) 为基准:遍历所有叶子路径 + for (const p of aPaths) { + if (!bPaths.has(p)) { + extra.push(p); // en 有、zh 无 -> zh 缺 + } + } + for (const p of bPaths) { + if (!aPaths.has(p)) { + missing.push(p); // zh 有、en 无 + } + } + + // 结构/插值比对仅在双方都存在叶子时进行 + for (const p of aPaths) { + if (!bPaths.has(p)) continue; + const infoA = fa.get(p); + const infoB = fb.get(p); + if (infoA.isLeaf !== infoB.isLeaf) { + shapeMismatch.push(p); + continue; + } + if (!infoA.isLeaf) continue; + const va = extractInterpolationVars(infoA.value); + const vb = extractInterpolationVars(infoB.value); + const vaArr = [...va].sort(); + const vbArr = [...vb].sort(); + if (vaArr.join("|") !== vbArr.join("|")) { + interpolationMismatch.push({ key: p, en: vaArr, zh: vbArr }); + } + } + + return { missing, extra, shapeMismatch, interpolationMismatch }; +} + +// 顶层入口:给定 en 目录与 zh 目录,返回每个共同 namespace 的差异。 +// 同时报告只有一方存在的 namespace(enOnly / zhOnly)。 +export function diffLocaleDirs(enDir, zhDir, { locales = DEFAULT_LOCALES } = {}) { + const enNs = collectNamespaces(enDir); + const zhNs = collectNamespaces(zhDir); + const enNames = new Set(enNs.keys()); + const zhNames = new Set(zhNs.keys()); + + const results = []; + for (const name of enNames) { + if (zhNames.has(name)) { + const d = diffDicts(enNs.get(name), zhNs.get(name)); + results.push({ namespace: name, ...d }); + } else { + results.push({ + namespace: name, + missing: [], + extra: [], + shapeMismatch: [], + interpolationMismatch: [], + onlyInEn: true, + }); + } + } + const onlyInZh = [...zhNames].filter((n) => !enNames.has(n)); + return { namespaces: results, enOnly: [...enNames].filter((n) => !zhNames.has(n)), zhOnly: onlyInZh }; +} + +export function hasDiff(diff) { + if (diff.enOnly.length > 0 || diff.zhOnly.length > 0) return true; + return diff.namespaces.some( + (n) => + n.missing.length > 0 || + n.extra.length > 0 || + n.shapeMismatch.length > 0 || + n.interpolationMismatch.length > 0 || + n.onlyInEn, + ); +} diff --git a/ui/litellm-dashboard/scripts/i18n/check-keys.mjs b/ui/litellm-dashboard/scripts/i18n/check-keys.mjs new file mode 100644 index 00000000000..0d455493ab5 --- /dev/null +++ b/ui/litellm-dashboard/scripts/i18n/check-keys.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// T-02 key 集合一致性校验 CLI(下方红色:只读、不写入字典)。 +// 校验 en 与 zh-CN 同名 namespace 的 key 集合、嵌套结构、插值变量集合完全一致。 +// +// 用法: +// node scripts/i18n/check-keys.mjs +// node scripts/i18n/check-keys.mjs --en --zh +// +// 退出码:一致 -> 0;不一致 -> 1。适合 CI 门禁(G3)。 + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { diffLocaleDirs, hasDiff } from "./check-keys-lib.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const DEFAULT_EN = resolve(root, "src/locales/en"); +const DEFAULT_ZH = resolve(root, "src/locales/zh-CN"); + +function parseArgs(argv) { + const args = { en: DEFAULT_EN, zh: DEFAULT_ZH }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--en" && argv[i + 1]) args.en = resolve(argv[++i]); + else if (argv[i] === "--zh" && argv[i + 1]) args.zh = resolve(argv[++i]); + else if (argv[i] === "--help" || argv[i] === "-h") args.help = true; + } + return args; +} + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + console.log(`T-02 key consistency checker + +Usage: + node scripts/i18n/check-keys.mjs [--en ] [--zh ] + +Default dirs (relative to dashboard root): + --en src/locales/en + --zh src/locales/zh-CN + +Exit code 0 when key sets match, 1 when any difference is found.`); + process.exit(0); +} + +const { en, zh } = parseArgs(process.argv.slice(2)); + +let diff; +try { + diff = diffLocaleDirs(en, zh); +} catch (e) { + console.error(`check-keys: failed to read locale dirs (${e.message})`); + console.error(` en: ${en}`); + console.error(` zh: ${zh}`); + process.exit(2); +} + +const failed = hasDiff(diff); +let anyLine = false; + +const print = (line) => { + console.log(line); + anyLine = true; +}; + +if (diff.enOnly.length > 0) { + print(`[EN-ONLY] namespace 仅存在于 ${en}:`); + for (const n of diff.enOnly) print(` - ${n}`); +} +if (diff.zhOnly.length > 0) { + print(`[ZH-ONLY] namespace 仅存在于 ${zh}:`); + for (const n of diff.zhOnly) print(` - ${n}`); +} + +for (const ns of diff.namespaces) { + const nsProblems = + ns.onlyInEn || + ns.missing.length > 0 || + ns.extra.length > 0 || + ns.shapeMismatch.length > 0 || + ns.interpolationMismatch.length > 0; + + if (!nsProblems) { + print( + `[OK] ${ns.namespace}: key set matched (` + + `${ns.extra.length} missing / ${ns.shapeMismatch.length} shape / ` + + `${ns.interpolationMismatch.length} interpolation)`, + ); + continue; + } + + if (ns.onlyInEn) { + print(`[DIFF] ${ns.namespace}: namespace 只有 ${en} 侧`); + continue; + } + + print(`[DIFF] ${ns.namespace}:`); + if (ns.missing.length > 0) { + print(` missing (zh 有、en 无):`); + for (const k of ns.missing) print(` - ${k}`); + } + if (ns.extra.length > 0) { + print(` extra (en 有、zh 无):`); + for (const k of ns.extra) print(` - ${k}`); + } + if (ns.shapeMismatch.length > 0) { + print(` shape mismatch (叶子/对象不一致):`); + for (const k of ns.shapeMismatch) print(` - ${k}`); + } + if (ns.interpolationMismatch.length > 0) { + print(` interpolation mismatch ({{...}} 变量不一致):`); + for (const { key: k, en: ev, zh: zv } of ns.interpolationMismatch) + print(` - ${k}: en=[${ev.join(", ")}] zh=[${zv.join(", ")}]`); + } +} + +if (!anyLine) { + console.log(`OK: 未发现 ${en} / ${zh} 下的 locale 文件(或目录不存在)。`); +} + +if (failed) { + console.error("check-keys: FAILED — en/zh key sets are not consistent."); + process.exit(1); +} +console.log("check-keys: PASS — en/zh key sets consistent."); +process.exit(0); diff --git a/ui/litellm-dashboard/scripts/i18n/scan-hardcoded-lib.mjs b/ui/litellm-dashboard/scripts/i18n/scan-hardcoded-lib.mjs new file mode 100644 index 00000000000..a554db09d88 --- /dev/null +++ b/ui/litellm-dashboard/scripts/i18n/scan-hardcoded-lib.mjs @@ -0,0 +1,139 @@ +// T-03 硬编码字符串扫描库(纯 Node,无第三方依赖)。 +// 只报告疑似"用户可见"的硬编码文案,绝不改写代码、绝不自动生成 key。 +// 供人工 review(方案 §3.4 硬约束:工具只报告)。 +// +// 策略(line-based 启发式,适合作为报告工具而非精确解析器): +// 1. JSX 文本:`>文案<` 形态的非空白/非注释/非纯符号文本。 +// 2. 可访问性/文案属性字面量:aria-label / title / placeholder / alt / label。 +// 3. 常见未国际化消息入口:toast( ... ) / notify( ... ) / useMessage / useToast。 +// 排除: +// - 已 t() / 包裹的文案 +// - 纯数字/符号/URL/路径/正则/占位符 +// - 标识符(模式串)、CSS 类、data-slot、样式令牌 +// - 明显是模型名/API 字段/日志/代码示例的标识符 + +import { readFileSync } from "node:fs"; + +// ---------- 单个候选 ---------- +// { file, line, col, kind, text } + +// 判文案属性值是否"疑似用户可见文案"(过滤纯符号/数字/占位)。 +const PLACEHOLDER_RE = /[{}]/; +const URL_RE = /^(https?:)?\/\//i; +const PATH_RE = /^[./~][\w./-]*$/; + +export function isLikelyCopy(s) { + const t = s.trim(); + if (t.length === 0) return false; + // 纯数字/符号组合(不含字母) + if (/^[\d\s.,%:+\-*/()_'"`]+$/.test(t)) return false; + // 占位符(含 {{ }} 或 { } 渲染表达式)——由插值负责,不视为硬编码 + if (PLACEHOLDER_RE.test(t)) return false; + // URL / 路径 + if (URL_RE.test(t) || PATH_RE.test(t)) return false; + // 单 token 且形式为 CSS 类/标识符(连字符/下划线/无空格)——排除模型名、类名、变量 + if (/^[A-Za-z0-9][\w-]*$/.test(t)) return false; + // 必须含字母 + if (!/[A-Za-z]/.test(t)) return false; + // 至少两个词,保证是短语/句子而非单词级标识符片段 + const words = t.split(/\s+/).filter(Boolean); + if (words.length < 2) return false; + return true; +} + +// 过滤已 包裹(或其参数为 t() 表达式)的文本。 +// 行内出现 ]/.test(context); +} + +// 从单行提取 `attr="value"` 形态的属性字面量。 +// attr 限定于文案属性集合。 +const ATTR_TEXT_RE = /\b(aria-label|title|placeholder|alt)="([^"]*)"/g; +export function findCopyAttributes(line) { + const out = []; + let m; + ATTR_TEXT_RE.lastIndex = 0; + while ((m = ATTR_TEXT_RE.exec(line)) !== null) { + out.push({ attr: m[1], text: m[2], col: m.index }); + } + return out; +} + +// 从 JSX 文本提取 `>text<`。只捕获紧跟 `>`、后跟 `` 的文本节点。 +// 形如:hello world 或 hi +export function findJsxText(line) { + const out = []; + const re = />([^<>{}\n]+) 0) { + out.push({ text: t, col: m.index + 1 }); + } + } + return out; +} + +// 常见未国际化消息入口:toast( / notify( / setToast( / useMessage / enqueueSnackbar / addToast +const MSG_ENTRY_RE = /\b(toast|notify|enqueueSnackbar|addToast|showToast|useMessage|useToast)\s*\(([^)]*)\)/g; +export function findMessageEntries(line) { + const out = []; + let m; + MSG_ENTRY_RE.lastIndex = 0; + while ((m = MSG_ENTRY_RE.exec(line)) !== null) { + const arg = m[2].trim(); + // 仅字符串字面量(单/双引号)视为候选;对象/表达式跳过 + const strHit = arg.match(/^(['"])(.*?)\1/); + if (strHit) { + out.push({ entry: m[1], text: strHit[2], col: m.index }); + } + } + return out; +} + +// 主扫描:给定文件绝对路径与内容行数组,返回候选列表。 +export function scanContent(source, filePath) { + const lines = source.split("\n"); + const candidates = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const stripped = line.replace(/\/\/.*$/, "").replace(/\/\*[\s\S]*?\*\//g, ""); + if (stripped.trim() === "") continue; + + // 该行是否含 (其 JSX 文本已国际化,跳过 JSX 文本候选项) + const trans = lineHasTransWrapper(line); + + // 1) 文案属性(aria-label/title/placeholder/alt 的纯字符串字面量; + // t("...") 形式是 aria-label={t(...)},不匹配本引号模式,天然排除) + for (const { attr, text, col } of findCopyAttributes(stripped)) { + if (isLikelyCopy(text)) { + candidates.push({ file: filePath, line: i + 1, col, kind: `attr:${attr}`, text }); + } + } + + // 2) JSX 文本 + if (!trans) { + for (const { text, col } of findJsxText(stripped)) { + if (isLikelyCopy(text)) { + candidates.push({ file: filePath, line: i + 1, col, kind: "jsx-text", text }); + } + } + } + + // 3) 消息入口 + for (const { entry, text, col } of findMessageEntries(line)) { + if (isLikelyCopy(text)) { + candidates.push({ file: filePath, line: i + 1, col, kind: `entry:${entry}`, text }); + } + } + } + return candidates; +} + +export function scanFile(filePath) { + const source = readFileSync(filePath, "utf8"); + return scanContent(source, filePath); +} diff --git a/ui/litellm-dashboard/scripts/i18n/scan-hardcoded.mjs b/ui/litellm-dashboard/scripts/i18n/scan-hardcoded.mjs new file mode 100644 index 00000000000..6e831aa25f6 --- /dev/null +++ b/ui/litellm-dashboard/scripts/i18n/scan-hardcoded.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// T-03 硬编码字符串扫描 CLI —— 只报告,不自动改写,不自动生成 key(方案 §3.4)。 +// 扫描 .ts / .tsx 中疑似"用户可见"的硬编码文案,输出供人工 review 的清单。 +// +// 用法: +// node scripts/i18n/scan-hardcoded.mjs [dir|file ...] +// 默认扫描 src/(若存在)。 +// 可传多个目录/文件;目录会递归遍历 .ts/.tsx。 +// +// 输出带 "REPORT ONLY" 标注;退出码 0(报告工具不因命中失败)。 + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { scanContent } from "./scan-hardcoded-lib.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const DEFAULT_SRC = resolve(root, "src"); + +const IGNORED_DIRS = new Set(["node_modules", ".next", "out", "dist", ".git", "coverage"]); +const TARGET_EXT = new Set([".ts", ".tsx"]); + +function collectFiles(path, acc = []) { + const st = statSync(path); + if (st.isFile()) { + if (TARGET_EXT.has(extname(path))) acc.push(path); + return acc; + } + for (const entry of readdirSync(path)) { + const full = join(path, entry); + if (statSync(full).isDirectory()) { + if (IGNORED_DIRS.has(entry)) continue; + collectFiles(full, acc); + } else if (TARGET_EXT.has(extname(full))) { + acc.push(full); + } + } + return acc; +} + +const inputs = process.argv.slice(2).filter((a) => !a.startsWith("-")); +const targets = inputs.length > 0 ? inputs : [DEFAULT_SRC]; + +const files = []; +for (const t of targets) { + if (!existsSync(t)) { + console.error(`scan-hardcoded: path not found: ${t}`); + process.exit(2); + } + collectFiles(t, files); +} + +// 按文件分组去重排序 +const unique = [...new Set(files)].sort(); +const grouped = new Map(); +let total = 0; + +for (const f of unique) { + const source = readFileSync(f, "utf8"); + const hits = scanContent(source, f); + if (hits.length > 0) grouped.set(f, hits); + total += hits.length; +} + +console.log( + "TODO REPORT — 疑似硬编码\"用户可见\"文案候选,供人工 review,工具不做改写。", +); +console.log(`扫描 ${unique.length} 个文件,${total} 个候选命中。\n`); + +for (const [f, hits] of grouped) { + console.log(`# ${f}`); + for (const h of hits) { + console.log(` ${h.line}:${h.col} [${h.kind}] ${JSON.stringify(h.text)}`); + } + console.log(""); +} + +if (total === 0) { + console.log(`OK: 未发现疑似硬编码的用户可见文案候选。`); +} diff --git a/ui/litellm-dashboard/tests/i18n/check-keys-lib.test.ts b/ui/litellm-dashboard/tests/i18n/check-keys-lib.test.ts new file mode 100644 index 00000000000..42d8a7b10b7 --- /dev/null +++ b/ui/litellm-dashboard/tests/i18n/check-keys-lib.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + flattenDict, + extractInterpolationVars, + diffDicts, + diffLocaleDirs, + hasDiff, +} from "../../scripts/i18n/check-keys-lib.mjs"; + +describe("flattenDict", () => { + it("flattens nested objects with dotted paths", () => { + const flat = flattenDict({ a: { b: { c: "x" } }, d: "y" }); + expect(flat.get("a.b.c")).toEqual({ value: "x", isLeaf: true }); + expect(flat.get("d")).toEqual({ value: "y", isLeaf: true }); + // 分支节点也被记录形状 + expect(flat.get("a")).toEqual({ value: undefined, isLeaf: false }); + }); +}); + +describe("extractInterpolationVars", () => { + it("extracts {{var}} names", () => { + expect([...extractInterpolationVars("Hello {{name}}, you have {{count}}")).toEqual(["name", "count"]); + }); + it("returns empty set for non-strings", () => { + expect(extractInterpolationVars(42).size).toBe(0); + expect(extractInterpolationVars("no placeholders").size).toBe(0); + }); +}); + +describe("diffDicts", () => { + it("reports missing/extra/insert keys and interpolation mismatch", () => { + const en = { + title: "Hello", + name: "Hi {{name}}", + nested: { keep: "same", gone: "en only" }, + }; + const zh = { + title: "你好", + name: "你好 {{name}},{{count}}", // 插值变量不一致(多 count) + nested: { keep: "相同", added: "zh only" }, + brandNew: "新增", + }; + const d = diffDicts(en, zh); + expect(d.extra.sort()).toEqual(["nested.gone"]); // en 有、zh 无 + expect(d.missing.sort()).toEqual(["brandNew", "nested.added"]); // zh 有、en 无 + expect(d.shapeMismatch).toEqual([]); + expect(d.interpolationMismatch.map((x) => x.key)).toEqual(["name"]); + }); + + it("reports shape mismatch when a leaf becomes an object", () => { + const d = diffDicts({ key: "x" }, { key: { sub: "y" } }); + expect(d.shapeMismatch).toEqual(["key"]); + }); +}); + +describe("diffLocaleDirs", () => { + const tmp = mkdtempSync(join(tmpdir(), "check-keys-test-")); + const enDir = join(tmp, "en"); + const zhDir = join(tmp, "zh-CN"); + mkdirSync(enDir); + mkdirSync(zhDir); + + const write = (dir: string, name: string, obj: unknown) => + writeFileSync(join(dir, `${name}.json`), JSON.stringify(obj)); + + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + it("returns no diff when key sets match", () => { + write(enDir, "common", { ok: "OK", cancel: "Cancel", greeting: "Hi {{name}}" }); + write(zhDir, "common", { ok: "确定", cancel: "取消", greeting: "你好 {{name}}" }); + const d = diffLocaleDirs(enDir, zhDir); + expect(hasDiff(d)).toBe(false); + }); + + it("flags a namespace missing in zh (enOnly)", () => { + // en 有 common,zh 没有 + write(enDir, "common", { ok: "OK" }); + write(enDir, "onlyEn", { x: "1" }); + write(zhDir, "common", { ok: "确定" }); + const d = diffLocaleDirs(enDir, zhDir); + expect(d.enOnly).toContain("onlyEn"); + expect(hasDiff(d)).toBe(true); + }); + + it("flags interpolation mismatch across dirs", () => { + write(enDir, "common", { greeting: "Hi {{name}}" }); + write(zhDir, "common", { greeting: "你好 {{name}},{{count}}" }); + const d = diffLocaleDirs(enDir, zhDir); + expect(d.namespaces.find((n) => n.namespace === "common").interpolationMismatch.length).toBe(1); + expect(hasDiff(d)).toBe(true); + }); + + it("flags a namespace only present in zh (zhOnly)", () => { + write(enDir, "common", { a: "1" }); + write(zhDir, "common", { a: "1" }); + write(zhDir, "zhOnlyNs", { b: "2" }); + const d = diffLocaleDirs(enDir, zhDir); + expect(d.zhOnly).toContain("zhOnlyNs"); + expect(hasDiff(d)).toBe(true); + }); + + it("flags a missing key across dirs", () => { + write(enDir, "common", { ok: "OK", cancel: "Cancel" }); + write(zhDir, "common", { ok: "确定" }); // 缺 cancel + const d = diffLocaleDirs(enDir, zhDir); + expect(d.namespaces.find((n) => n.namespace === "common").extra).toContain("cancel"); + expect(hasDiff(d)).toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/tests/i18n/scan-hardcoded-lib.test.ts b/ui/litellm-dashboard/tests/i18n/scan-hardcoded-lib.test.ts new file mode 100644 index 00000000000..ae5a6b3ef0a --- /dev/null +++ b/ui/litellm-dashboard/tests/i18n/scan-hardcoded-lib.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { + isLikelyCopy, + findJsxText, + findCopyAttributes, + findMessageEntries, + scanContent, +} from "../../scripts/i18n/scan-hardcoded-lib.mjs"; + +describe("isLikelyCopy", () => { + it("accepts user-facing phrases", () => { + expect(isLikelyCopy("Save Changes")).toBe(true); + expect(isLikelyCopy("Tenant not found")).toBe(true); + }); + + it("rejects numbers/symbols, identifiers, urls, paths, placeholders", () => { + expect(isLikelyCopy("123")).toBe(false); + expect(isLikelyCopy("42,000")).toBe(false); + expect(isLikelyCopy("gpt-4o")).toBe(false); // 模型名 + expect(isLikelyCopy("api_keys")).toBe(false); // API 字段 + expect(isLikelyCopy("bg-red-500")).toBe(false); // CSS 类 + expect(isLikelyCopy("https://example.com")).toBe(false); + expect(isLikelyCopy("/api/v1/models")).toBe(false); + expect(isLikelyCopy("Hello {{name}}")).toBe(false); // 插值占位 + expect(isLikelyCopy("")).toBe(false); + }); +}); + +describe("findJsxText", () => { + it("extracts visible JSX text between tags", () => { + const hits = findJsxText(""); + expect(hits.map((h) => h.text)).toEqual(["Save Changes"]); + }); + it("ignores empty and expression containers", () => { + expect(findJsxText("
{value}
").map((h) => h.text)).toEqual([]); + expect(findJsxText("
").map((h) => h.text)).toEqual([]); + }); +}); + +describe("findCopyAttributes", () => { + it("extracts a11y/copy attribute literals", () => { + const hits = findCopyAttributes(`aria-label="Close dialog" title="More info"`); + expect(hits.map((h) => [h.attr, h.text])).toEqual([ + ["aria-label", "Close dialog"], + ["title", "More info"], + ]); + }); + it("does not match unrelated attributes", () => { + expect(findCopyAttributes(`className="x" data-slot="y"`)).toEqual([]); + }); +}); + +describe("findMessageEntries", () => { + it("finds toast/notify string literal args", () => { + expect(findMessageEntries(`toast("Saved successfully")`).map((h) => [h.entry, h.text])).toEqual([ + ["toast", "Saved successfully"], + ]); + }); + it("skips non-string args", () => { + expect(findMessageEntries(`toast(errorObj)`)).toEqual([]); + }); +}); + +describe("scanContent", () => { + const SAMPLE = ` +import { t } from "i18next"; + +export const label = "not-copy"; // identifier-style single token, excluded +export const aria = 'aria-label="Close dialog"'; +function Comp() { + return ( +
+ + {t("common:already.translated")} + Tenant unavailable +
pure
+
+ ); +} +toast("Tenant created"); +`; + + it("finds jsx-text, attributes, and message entries; skips t()/identifiers/data-slot", () => { + const hits = scanContent(SAMPLE, "/fake/Comp.tsx"); + const texts = hits.map((h) => h.text); + expect(texts).toContain("Open settings"); // 属性(attr) 与 JSX 文本 + expect(texts).toContain("Tenant unavailable"); // JSX 文本 + expect(texts).toContain("Tenant created"); // toast 入口 + // t() 已国际化、单 token 标识符、data-slot 内部纯词不应被命中 + expect(texts).not.toContain("common:already.translated"); + expect(texts).not.toContain("not-copy"); + expect(texts).not.toContain("pure"); + // 每个候选都带 file/line/kind + for (const h of hits) { + expect(h.file).toBe("/fake/Comp.tsx"); + expect(typeof h.line).toBe("number"); + expect(typeof h.kind).toBe("string"); + } + }); +});