174 lines
5.7 KiB
JavaScript
174 lines
5.7 KiB
JavaScript
import assert from "node:assert/strict"
|
|
import test from "node:test"
|
|
import { readFile, readdir } from "node:fs/promises"
|
|
import path from "node:path"
|
|
|
|
const WEB_ROOT = new URL("../", import.meta.url)
|
|
const LOCALES = ["zh-CN", "en-US", "ru-RU"]
|
|
const SOURCE_DIRS = ["app", "components", "lib"]
|
|
const SKIP_DIRS = new Set(["node_modules", ".next", "out", "public"])
|
|
|
|
async function collectSourceFiles(dir) {
|
|
const entries = await readdir(new URL(dir, WEB_ROOT), { withFileTypes: true })
|
|
const files = []
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
if (!SKIP_DIRS.has(entry.name)) files.push(...(await collectSourceFiles(path.posix.join(dir, entry.name))))
|
|
continue
|
|
}
|
|
if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\.mjs$/.test(entry.name)) {
|
|
files.push(path.posix.join(dir, entry.name))
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
/**
|
|
* Finds duplicate keys inside any JSON object. JSON.parse keeps only the last
|
|
* occurrence, so a repeated top-level key silently deletes every sibling of the
|
|
* block that shadowed it - that is how the workflowRun namespace lost 39 keys.
|
|
*/
|
|
function findDuplicateKeys(text) {
|
|
const stack = []
|
|
const trail = []
|
|
const duplicates = []
|
|
let index = 0
|
|
let inString = false
|
|
let escaped = false
|
|
let stringStart = -1
|
|
let expectingKey = false
|
|
|
|
while (index < text.length) {
|
|
const char = text[index]
|
|
|
|
if (inString) {
|
|
if (escaped) escaped = false
|
|
else if (char === "\\") escaped = true
|
|
else if (char === '"') {
|
|
inString = false
|
|
const raw = text.slice(stringStart + 1, index)
|
|
const top = stack[stack.length - 1]
|
|
if (expectingKey && top && !top.isArray) {
|
|
if (top.keys.has(raw)) duplicates.push([...trail, raw].join("."))
|
|
else top.keys.add(raw)
|
|
expectingKey = false
|
|
}
|
|
}
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (char === '"') {
|
|
inString = true
|
|
escaped = false
|
|
stringStart = index
|
|
index += 1
|
|
continue
|
|
}
|
|
if (char === "{") {
|
|
stack.push({ isArray: false, keys: new Set() })
|
|
trail.push("<object>")
|
|
expectingKey = true
|
|
} else if (char === "[") {
|
|
stack.push({ isArray: true, keys: new Set() })
|
|
trail.push("<array>")
|
|
expectingKey = false
|
|
} else if (char === "}" || char === "]") {
|
|
stack.pop()
|
|
trail.pop()
|
|
expectingKey = false
|
|
} else if (char === ",") {
|
|
const top = stack[stack.length - 1]
|
|
expectingKey = Boolean(top) && !top.isArray
|
|
} else if (char === ":") {
|
|
expectingKey = false
|
|
}
|
|
index += 1
|
|
}
|
|
|
|
return duplicates
|
|
}
|
|
|
|
test("no message bundle declares the same key twice", async () => {
|
|
for (const locale of LOCALES) {
|
|
const source = await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8")
|
|
assert.deepEqual(findDuplicateKeys(source), [], `${locale}.json has duplicate keys`)
|
|
}
|
|
})
|
|
|
|
// Keys the UI resolves through t()/translateMessage(), plus the data-driven
|
|
// titleKey-style props. Scanning only t() calls misses lib/navigation.tsx,
|
|
// which is where the reported sidebar gaps lived.
|
|
const KEY_PATTERNS = [
|
|
/\b(?:t|translateCurrentMessage|translateMessage)\(\s*["'`]([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["'`]/g,
|
|
/\b(?:titleKey|labelKey|descriptionKey|textKey|placeholderKey|ariaLabelKey|tooltipKey):\s*["'`]([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["'`]/g,
|
|
]
|
|
|
|
test("every key the UI asks for exists in every locale", async () => {
|
|
const bundles = {}
|
|
for (const locale of LOCALES) {
|
|
bundles[locale] = JSON.parse(await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8"))
|
|
}
|
|
const lookup = (bundle, key) => key.split(".").reduce((value, segment) => (value == null ? undefined : value[segment]), bundle)
|
|
|
|
const files = (await Promise.all(SOURCE_DIRS.map((dir) => collectSourceFiles(dir)))).flat()
|
|
const missing = []
|
|
|
|
for (const file of files) {
|
|
const source = await readFile(new URL(file, WEB_ROOT), "utf8")
|
|
for (const pattern of KEY_PATTERNS) {
|
|
for (const match of source.matchAll(pattern)) {
|
|
for (const locale of LOCALES) {
|
|
if (lookup(bundles[locale], match[1]) === undefined) {
|
|
missing.push(`${file}: ${match[1]} (missing in ${locale})`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.deepEqual([...new Set(missing)], [])
|
|
})
|
|
|
|
// Template keys such as `supportPublic.comment.sort.${sort}` cannot be resolved
|
|
// by scanning source, so their known value sets are pinned here.
|
|
const DYNAMIC_KEYS = {
|
|
"supportPublic.comment.sort": ["default", "hot", "latest"],
|
|
"supportPublic.help": ["previous", "next"],
|
|
supportChat: ["connecting", "connected", "disconnected"],
|
|
agentTeamSchedule: ["weekdayMon", "weekdayTue", "weekdayWed", "weekdayThu", "weekdayFri", "weekdaySat", "weekdaySun"],
|
|
supportHelpWorkbench: [
|
|
"publishedAction",
|
|
"publishedConfirmTitle",
|
|
"publishedConfirmDescription",
|
|
"publishedSuccess",
|
|
"draftAction",
|
|
"draftConfirmTitle",
|
|
"draftConfirmDescription",
|
|
"draftSuccess",
|
|
"hiddenAction",
|
|
"hiddenConfirmTitle",
|
|
"hiddenConfirmDescription",
|
|
"hiddenSuccess",
|
|
],
|
|
}
|
|
|
|
test("every dynamically composed key variant exists in every locale", async () => {
|
|
const bundles = {}
|
|
for (const locale of LOCALES) {
|
|
bundles[locale] = JSON.parse(await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8"))
|
|
}
|
|
const lookup = (bundle, key) => key.split(".").reduce((value, segment) => (value == null ? undefined : value[segment]), bundle)
|
|
|
|
const missing = []
|
|
for (const [prefix, variants] of Object.entries(DYNAMIC_KEYS)) {
|
|
for (const variant of variants) {
|
|
for (const locale of LOCALES) {
|
|
if (lookup(bundles[locale], `${prefix}.${variant}`) === undefined) {
|
|
missing.push(`${prefix}.${variant} (missing in ${locale})`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(missing, [])
|
|
}) |