Files
webmail/lib/__tests__/translations.test.ts
T

191 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment node
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
const rootDir = path.resolve(__dirname, '../..');
const localesDir = path.join(rootDir, 'locales');
const referenceLocale = 'en';
function getLeafKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [];
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
keys.push(...getLeafKeys(value as Record<string, unknown>, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}
function loadLocale(locale: string): Record<string, unknown> {
const filePath = path.join(localesDir, locale, 'common.json');
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
function resolveKey(obj: Record<string, unknown>, key: string): unknown {
return key.split('.').reduce<unknown>((o, p) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[p] : undefined), obj);
}
// Collect source files recursively
function getSourceFiles(dir: string): string[] {
const results: string[] = [];
let entries: fs.Dirent[];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== '__tests__') {
results.push(...getSourceFiles(fullPath));
} else if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) {
results.push(fullPath);
}
}
return results;
}
/**
* Extract translation keys from source, respecting which variable maps to which namespace.
* Handles multiple useTranslations calls per file (even reusing the same variable name
* in different functions) by finding, for each t("key") call, the nearest preceding
* useTranslations assignment to that variable.
*/
function extractUsedKeys(filePath: string): string[] {
const content = fs.readFileSync(filePath, 'utf-8');
const keys: string[] = [];
// Collect all variable→namespace assignments with their positions
const assignRegex = /const\s+(\w+)\s*=\s*useTranslations\(\s*["']([^"']*)["']\s*\)/g;
const assignments: { varName: string; namespace: string; index: number }[] = [];
let m: RegExpExecArray | null;
while ((m = assignRegex.exec(content)) !== null) {
assignments.push({ varName: m[1], namespace: m[2], index: m.index });
}
if (assignments.length === 0) return keys;
// Get unique variable names
const varNames = [...new Set(assignments.map((a) => a.varName))];
// For each variable, find its t("key") calls and resolve namespace by position
for (const varName of varNames) {
const varAssignments = assignments.filter((a) => a.varName === varName);
const callRegex = new RegExp(`\\b${varName}\\(\\s*["']([^"'{}]+)["']`, 'g');
while ((m = callRegex.exec(content)) !== null) {
const key = m[1];
// Find the nearest preceding assignment for this variable
const ns = varAssignments
.filter((a) => a.index < m!.index)
.sort((a, b) => b.index - a.index)[0]?.namespace;
if (ns === undefined) continue;
keys.push(ns ? `${ns}.${key}` : key);
}
}
return [...new Set(keys)];
}
function collectUsedKeysByFile(files: string[]): Map<string, string[]> {
const keyToFiles = new Map<string, string[]>();
for (const filePath of files) {
for (const key of extractUsedKeys(filePath)) {
const existing = keyToFiles.get(key) ?? [];
if (!existing.includes(filePath)) {
existing.push(filePath);
keyToFiles.set(key, existing);
}
}
}
return keyToFiles;
}
const locales = fs
.readdirSync(localesDir)
.filter((entry) => fs.statSync(path.join(localesDir, entry)).isDirectory());
const referenceData = loadLocale(referenceLocale);
const referenceKeys = getLeafKeys(referenceData);
describe('translations completeness', () => {
it('reference locale (en) should have keys', () => {
expect(referenceKeys.length).toBeGreaterThan(0);
});
const otherLocales = locales.filter((l) => l !== referenceLocale);
it.each(otherLocales)('%s should have all keys from en', (locale) => {
const localeKeys = new Set(getLeafKeys(loadLocale(locale)));
const missing = referenceKeys.filter((key) => !localeKeys.has(key));
expect(missing, `Missing ${missing.length} keys in "${locale}":\n${missing.join('\n')}`).toEqual([]);
});
it.each(otherLocales)('%s should not have extra keys absent from en', (locale) => {
const localeKeys = getLeafKeys(loadLocale(locale));
const referenceSet = new Set(referenceKeys);
const extra = localeKeys.filter((key) => !referenceSet.has(key));
expect(extra, `Extra ${extra.length} keys in "${locale}":\n${extra.join('\n')}`).toEqual([]);
});
});
describe('translations used in source code exist in en locale', () => {
const srcDirs = ['components', 'app', 'hooks', 'lib', 'stores', 'contexts'].map((d) => path.join(rootDir, d));
const allFiles = srcDirs.flatMap((d) => getSourceFiles(d));
const usedKeysByFile = collectUsedKeysByFile(allFiles);
const usedKeys = [...usedKeysByFile.keys()].sort();
it('all translation keys referenced in source should exist in en locale', () => {
const missing = usedKeys.filter((key) => resolveKey(referenceData, key) === undefined);
const details = missing.map((key) => {
const relativeFiles = (usedKeysByFile.get(key) ?? [])
.map((filePath) => path.relative(rootDir, filePath))
.sort();
return `${key}\n used in:\n - ${relativeFiles.join('\n - ')}`;
});
expect(
missing,
`${missing.length} translation key(s) used in source code but missing from en locale:\n${details.join('\n')}`,
).toEqual([]);
});
});
describe('mail terms are not mistranslated', () => {
const locales = fs.readdirSync(localesDir).filter((l) => l !== referenceLocale);
// "Composer" was translated as the music composer (or a printer in tr) in
// several languages, and "Create" as "manufacture" in mn.
const WRONG_WORDS: Record<string, RegExp> = {
da: /komponist/i,
es: /\bcompositor/i,
fr: /compositeur/i,
it: /\bcompositore/i,
mn: /Хөгжмийн зохиолч|Үйлдвэрлэх/,
pt: /\bcompositor/i,
ro: /compozitor/i,
tr: /yazıcı/i,
uk: /композитор/i,
};
it.each(Object.keys(WRONG_WORDS))('%s does not call the composer a music composer or a printer', (locale) => {
const table = loadLocale(locale);
const wrong = getLeafKeys(table)
.map((key) => [key, resolveKey(table, key)] as const)
.filter(([, value]) => typeof value === 'string' && WRONG_WORDS[locale].test(value));
expect(wrong).toEqual([]);
});
// A certificate's subject is its holder, not an email's subject line.
// Turkish keeps "Konu", the word Windows' certificate dialog uses.
it.each(locales.filter((l) => l !== 'tr'))('%s names the certificate subject differently from the email subject', (locale) => {
const table = loadLocale(locale);
expect(resolveKey(table, 'smime.cert_subject')).not.toBe(resolveKey(table, 'email_composer.subject'));
});
});