ci(i18n): fail on new English-identical translations (#1793)

* ci(i18n): fail on new English-identical translations

The drift check only warned about locale values identical to English, so
untranslated strings kept landing. It now fails on any such value that
tools/i18n/identical-en-baseline.json does not record for that locale and
key. The baseline captures today's 2,015 entries: legitimately identical
values (brand and technical names, language autonyms, PIN) and the
existing debt. An entry only covers the English text it recorded, so
copying reworded English into a locale fails too.

Baseline entries that are no longer identical are reported, not fatal.
`pnpm run i18n:baseline:update` rewrites the baseline deliberately; CI
runs `pnpm run i18n:validate` (node tests, then the check) and never
rewrites it. `--fail-on-identical` remains as a strict audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): keep the baseline intact on failed updates and strict audits

`--update-baseline` now writes nothing while any locale is unreadable or
has missing or extra keys, so an incomplete translation cannot reshape
the baseline. `--fail-on-identical` no longer reads the baseline it
ignores, so a damaged file cannot block a strict audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-03 16:00:11 +02:00
1 parent 4adc3ba20f
commit 0768ae5ea2
7 files changed
+2671 -83

No files matched your search

+6 -2
View File
@@ -390,8 +390,12 @@ jobs:
- name: Typecheck Jest spec programs
run: pnpm run typecheck:spec:test && pnpm run typecheck:spec
- name: Check i18n drift
run: pnpm run i18n:check
# Fails on missing or extra keys and on values identical to
# English that tools/i18n/identical-en-baseline.json does not list.
# The baseline only changes through a reviewed
# `pnpm run i18n:baseline:update`; CI never rewrites it.
- name: Check i18n drift and untranslated values
run: pnpm run i18n:validate
# Node specs for the benchmark and journey harness (about ten
# seconds). They live in the E2E app, which the unit coverage scope
+22 -4
View File
@@ -242,14 +242,32 @@ pnpm run coverage:e2e:v8:web
## I18n
```bash
pnpm run i18n:check
pnpm run i18n:validate # checker unit tests, then the check (CI)
pnpm run i18n:check # the check only
pnpm run i18n:baseline:update # deliberate: rewrite the English-identical baseline
```
The i18n check is non-mutating. It compares every locale file in
`apps/web/src/assets/i18n/` against `en.json` and fails on missing or extra keys.
Identical English fallback values are reported as warnings by default; use
`node tools/i18n/check-drift.mjs --fail-on-identical` for a stricter translation
audit.
It also fails on a locale value that is identical to English unless
`tools/i18n/identical-en-baseline.json` records that exact English text for the
same locale and key. The baseline holds values that are legitimately the same
in a language (brand and technical names, language autonyms, loanwords such as
"PIN") and the untranslated debt that existed when the guard was added, so new
keys must ship translated. An entry stops covering its key once the English
text changes, so copying reworded English into a locale fails as well.
Baseline entries that are no longer English-identical (translated, removed, or
reworded) are reported but do not fail. `pnpm run i18n:baseline:update`
rewrites the baseline from the current locale files, dropping those entries
and printing every added one; it writes nothing while any locale is unreadable
or has missing or extra keys. Run it only after translating, or for a value
that is legitimately identical in that language; review the diff, and never
run it in CI. A new locale starts with no baseline entries, so it has to
record its legitimate identical values the same way.
`node tools/i18n/check-drift.mjs --fail-on-identical` ignores the baseline for
a full translation audit. CI runs `pnpm run i18n:validate` in the unit test
job.
## Performance
+3
View File
@@ -79,6 +79,9 @@
"serve:website": "nx serve website",
"build:website": "nx build website",
"i18n:check": "node tools/i18n/check-drift.mjs",
"i18n:test": "node --test tools/i18n/check-drift.test.mjs",
"i18n:validate": "pnpm run i18n:test && pnpm run i18n:check",
"i18n:baseline:update": "node tools/i18n/check-drift.mjs --update-baseline",
"perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs",
"perf:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes",
"perf:journeys": "nx run electron-backend-e2e:journeys",
+18 -4
View File
@@ -49,10 +49,24 @@ For a non-mutating CI/agent check, run:
pnpm run i18n:check
```
The check fails on missing or extra keys against `en.json`. It reports values
that are still identical to English as warnings so untranslated fallback strings
remain visible without blocking key-parity validation. For stricter translation
audits, run:
The check fails on missing or extra keys against `en.json`, and on values that
are identical to English unless `identical-en-baseline.json` lists that exact
English text for the locale and key. Do not copy English into a locale as a
placeholder: translate it. The contract, including when an entry stops
covering a key, is in the
[validation map](../../docs/architecture/validation-map.md#i18n).
Baseline entries that are no longer identical are reported, not fatal. After
translating baselined values, or for a value that is legitimately the same in a
language (a brand, a technical name, a loanword), rewrite the baseline and
review every added entry in the diff:
```bash
pnpm run i18n:baseline:update
```
The checker's own tests run with `pnpm run i18n:test`; CI runs both through
`pnpm run i18n:validate`. For a full audit that ignores the baseline, run:
```bash
node tools/i18n/check-drift.mjs --fail-on-identical
+291 -73
View File
@@ -1,27 +1,47 @@
#!/usr/bin/env node
import { readdirSync, readFileSync } from 'node:fs';
/**
* Compares every locale in apps/web/src/assets/i18n/ with en.json.
*
* Missing or extra keys fail. A value identical to English fails too, unless
* tools/i18n/identical-en-baseline.json records that exact English text for
* that locale and key. The baseline holds values that are legitimately the
* same in a language (brands, technical terms, loanwords such as "PIN") and
* the untranslated debt that existed when the guard was introduced, so new
* keys must ship translated while old debt can be paid down.
*
* Baseline entries that no longer match an English-identical value are only
* reported. `--update-baseline` rewrites the baseline from the current files;
* run it deliberately and review the diff. CI never runs it.
* `--fail-on-identical` ignores the baseline for a strict audit.
*/
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..', '..');
const I18N_DIR = resolve(REPO_ROOT, 'apps/web/src/assets/i18n');
const EN_LOCALE = 'en.json';
export const I18N_DIR = resolve(__dirname, '../../apps/web/src/assets/i18n');
export const BASELINE_PATH = resolve(__dirname, 'identical-en-baseline.json');
const EN_LOCALE = 'en';
const MAX_EXAMPLES = 5;
const FAIL_ON_IDENTICAL = process.argv.includes('--fail-on-identical');
const UPDATE_COMMAND = 'pnpm run i18n:baseline:update';
const FLAGS = new Set(['--fail-on-identical', '--update-baseline', '--']);
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf8'));
}
function collectLeaves(value, prefix = '', leaves = new Map()) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
export function collectLeaves(value, prefix = '', leaves = new Map()) {
if (!isPlainObject(value)) {
throw new Error(`Expected object at ${prefix || '<root>'}`);
}
for (const [key, entry] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
if (isPlainObject(entry)) {
collectLeaves(entry, path, leaves);
} else {
leaves.set(path, entry);
@@ -31,10 +51,87 @@ function collectLeaves(value, prefix = '', leaves = new Map()) {
return leaves;
}
function listLocaleFiles() {
return readdirSync(I18N_DIR)
.filter((file) => file.endsWith('.json'))
.sort();
/** Keys whose non-empty locale string equals the English string. */
export function findIdenticalToEnglish(enLeaves, localeLeaves) {
const identical = new Map();
for (const [key, enValue] of enLeaves) {
const localeValue = localeLeaves.get(key);
if (
typeof enValue === 'string' &&
localeValue === enValue &&
enValue.trim().length > 0
) {
identical.set(key, enValue);
}
}
return identical;
}
/**
* Splits a locale's English-identical keys into baselined and new ones. An
* entry covers a key only while its recorded text is still the English text,
* so rewording English and copying the new text into a locale is new debt.
* Stale entries no longer describe an English-identical value: the key was
* translated or removed, or its English text changed.
*/
export function compareWithBaseline(identical, localeBaseline = new Map()) {
const baselined = [];
const unbaselined = [];
for (const [key, value] of identical) {
(localeBaseline.get(key) === value ? baselined : unbaselined).push(key);
}
const stale = [...localeBaseline]
.filter(([key, value]) => identical.get(key) !== value)
.map(([key]) => key);
return { baselined, unbaselined, stale };
}
/** Validates the baseline JSON into Map<locale, Map<key, englishText>>. */
export function parseBaseline(raw) {
if (!isPlainObject(raw)) {
throw new Error('expected an object of locales');
}
const baseline = new Map();
for (const [locale, entries] of Object.entries(raw)) {
if (!isPlainObject(entries)) {
throw new Error(`expected an object of keys for ${locale}`);
}
for (const [key, value] of Object.entries(entries)) {
if (typeof value !== 'string') {
throw new Error(`expected English text at ${locale} ${key}`);
}
}
baseline.set(locale, new Map(Object.entries(entries)));
}
return baseline;
}
/** Serialises Map<locale, Map<key, text>> with locales and keys sorted. */
export function serializeBaseline(identicalByLocale) {
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const output = {};
for (const locale of [...identicalByLocale.keys()].sort(byCodeUnit)) {
const entries = identicalByLocale.get(locale);
if (entries.size > 0) {
output[locale] = Object.fromEntries(
[...entries].sort(([a], [b]) => byCodeUnit(a, b))
);
}
}
return `${JSON.stringify(output, null, 4)}\n`;
}
function countChanges(from, to, onChange = () => undefined) {
let changes = 0;
for (const [locale, entries] of from) {
for (const [key, value] of entries) {
if (to.get(locale)?.get(key) !== value) {
changes += 1;
onChange(locale, key, value);
}
}
}
return changes;
}
function formatExamples(values) {
@@ -50,73 +147,194 @@ function formatError(error) {
return error instanceof Error ? error.message : String(error);
}
let enLeaves;
try {
enLeaves = collectLeaves(readJson(resolve(I18N_DIR, EN_LOCALE)));
} catch (error) {
console.error(`FAIL ${EN_LOCALE}: ${formatError(error)}`);
process.exit(1);
}
const enKeys = [...enLeaves.keys()];
let failed = false;
let identicalTotal = 0;
for (const localeFile of listLocaleFiles()) {
if (localeFile === EN_LOCALE) {
continue;
}
function readBaseline(baselinePath) {
let raw;
try {
const localeLeaves = collectLeaves(
readJson(resolve(I18N_DIR, localeFile))
);
const localeKeys = [...localeLeaves.keys()];
const missing = enKeys.filter((key) => !localeLeaves.has(key));
const extra = localeKeys.filter((key) => !enLeaves.has(key));
const identical = enKeys.filter((key) => {
if (!localeLeaves.has(key)) {
return false;
}
const localeValue = localeLeaves.get(key);
const enValue = enLeaves.get(key);
return (
typeof localeValue === 'string' &&
typeof enValue === 'string' &&
localeValue.trim().length > 0 &&
localeValue === enValue
);
});
identicalTotal += identical.length;
const localeFailed =
missing.length > 0 ||
extra.length > 0 ||
(FAIL_ON_IDENTICAL && identical.length > 0);
failed = failed || localeFailed;
const status = localeFailed ? 'FAIL' : 'ok';
console.log(
`${status} ${localeFile}: missing=${missing.length}${formatExamples(
missing
)} extra=${extra.length}${formatExamples(extra)} identical_en=${
identical.length
}${formatExamples(identical)}`
);
raw = readJson(baselinePath);
} catch (error) {
failed = true;
console.log(`FAIL ${localeFile}: ${formatError(error)}`);
if (error?.code === 'ENOENT') {
return new Map();
}
throw new Error(`${baselinePath}: ${formatError(error)}`);
}
try {
return parseBaseline(raw);
} catch (error) {
throw new Error(`${baselinePath}: ${formatError(error)}`);
}
}
if (identicalTotal > 0 && !FAIL_ON_IDENTICAL) {
console.log(
`warn identical_en=${identicalTotal}; pass --fail-on-identical to make English fallback values fatal.`
function listLocales(i18nDir) {
return readdirSync(i18nDir)
.filter((file) => file.endsWith('.json'))
.map((file) => file.slice(0, -'.json'.length))
.filter((locale) => locale !== EN_LOCALE)
.sort();
}
function checkLocale({ i18nDir, locale, enLeaves, baseline, mode }) {
const localeLeaves = collectLeaves(
readJson(resolve(i18nDir, `${locale}.json`))
);
const missing = [...enLeaves.keys()].filter(
(key) => !localeLeaves.has(key)
);
const extra = [...localeLeaves.keys()].filter((key) => !enLeaves.has(key));
const identical = findIdenticalToEnglish(enLeaves, localeLeaves);
const { baselined, unbaselined, stale } = compareWithBaseline(
identical,
baseline.get(locale)
);
const rejected =
mode === 'strict'
? [...identical.keys()]
: mode === 'update'
? []
: unbaselined;
return { missing, extra, identical, baselined, rejected, stale };
}
function reportLocale(log, file, result, mode) {
const { missing, extra, identical, baselined, rejected, stale } = result;
const failed = missing.length + extra.length + rejected.length > 0;
const baselineNote =
mode === 'baseline' ? ` baselined=${baselined.length}` : '';
log(
`${failed ? 'FAIL' : 'ok'} ${file}: missing=${
missing.length
}${formatExamples(missing)} extra=${extra.length}${formatExamples(
extra
)} identical_en=${identical.size}${baselineNote}`
);
if (mode === 'baseline' && stale.length > 0) {
log(
`note ${file}: ${stale.length} baseline entries are no longer English-identical${formatExamples(
stale
)}`
);
}
return failed;
}
function reportRejected(log, rejected, enLeaves, mode) {
if (rejected.length === 0) {
return;
}
const scope = mode === 'strict' ? '' : ' and are not in the baseline';
log(`${rejected.length} value(s) equal English${scope}:`);
for (const [file, key] of rejected) {
log(` ${file} ${key}: ${JSON.stringify(enLeaves.get(key))}`);
}
if (mode === 'baseline') {
log(
`Translate them (see the i18n-fill skill). Only if a value is legitimately the same in that language, run \`${UPDATE_COMMAND}\` and commit the reviewed baseline.`
);
}
}
function writeBaseline(log, baselinePath, previous, identicalByLocale) {
const added = countChanges(
identicalByLocale,
previous,
(locale, key, value) =>
log(` + ${locale} ${key}: ${JSON.stringify(value)}`)
);
const removed = countChanges(previous, identicalByLocale);
writeFileSync(baselinePath, serializeBaseline(identicalByLocale));
log(
`Baseline written: ${added} added, ${removed} removed. Review every added entry before committing.`
);
}
if (failed) {
process.exitCode = 1;
/** Runs the check and returns the process exit code. */
export function run({
i18nDir = I18N_DIR,
baselinePath = BASELINE_PATH,
argv = [],
log = console.log,
} = {}) {
const strict = argv.includes('--fail-on-identical');
const update = argv.includes('--update-baseline');
if (argv.some((arg) => !FLAGS.has(arg)) || (strict && update)) {
log(
`FAIL unsupported arguments: ${argv.join(' ')}; pass at most one of --fail-on-identical and --update-baseline.`
);
return 1;
}
const mode = strict ? 'strict' : update ? 'update' : 'baseline';
let enLeaves;
let baseline;
try {
enLeaves = collectLeaves(
readJson(resolve(i18nDir, `${EN_LOCALE}.json`))
);
// A strict audit ignores the baseline, so a damaged file cannot block it.
baseline = strict ? new Map() : readBaseline(baselinePath);
} catch (error) {
if (!enLeaves || !update) {
log(`FAIL ${formatError(error)}`);
return 1;
}
log(`note replacing unreadable baseline: ${formatError(error)}`);
baseline = new Map();
}
const locales = listLocales(i18nDir);
const identicalByLocale = new Map();
const rejected = [];
let failed = false;
let staleTotal = 0;
for (const locale of locales) {
const file = `${locale}.json`;
let result;
try {
result = checkLocale({ i18nDir, locale, enLeaves, baseline, mode });
} catch (error) {
failed = true;
log(`FAIL ${file}: ${formatError(error)}`);
continue;
}
identicalByLocale.set(locale, result.identical);
rejected.push(...result.rejected.map((key) => [file, key]));
staleTotal += result.stale.length;
failed = reportLocale(log, file, result, mode) || failed;
}
if (mode === 'baseline') {
for (const [locale, entries] of baseline) {
if (!locales.includes(locale)) {
staleTotal += entries.size;
log(`note ${locale}: baseline lists a locale with no file`);
}
}
if (staleTotal > 0) {
log(
`note ${staleTotal} stale baseline entries; run \`${UPDATE_COMMAND}\` to shrink the baseline.`
);
}
}
reportRejected(log, rejected, enLeaves, mode);
if (update) {
// Only a complete, readable set of locales is a state worth recording.
if (failed) {
log(
'FAIL baseline not written: fix the unreadable, missing or extra keys above first.'
);
return 1;
}
writeBaseline(log, baselinePath, baseline, identicalByLocale);
}
return failed ? 1 : 0;
}
const isMain =
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isMain) {
process.exitCode = run({ argv: process.argv.slice(2) });
}
+278
View File
@@ -0,0 +1,278 @@
import assert from 'node:assert/strict';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, test } from 'node:test';
import { parseBaseline, run, serializeBaseline } from './check-drift.mjs';
const EN = {
APP: { TITLE: 'IPTVnator', SAVE: 'Save' },
PIN: 'PIN',
};
let root;
let dir;
let baselinePath;
beforeEach(() => {
root = mkdtempSync(path.join(os.tmpdir(), 'i18n-drift-'));
dir = path.join(root, 'i18n');
mkdirSync(dir);
baselinePath = path.join(root, 'identical-en-baseline.json');
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function writeLocale(locale, content) {
writeFileSync(path.join(dir, `${locale}.json`), JSON.stringify(content));
}
function writeBaseline(content) {
writeFileSync(baselinePath, JSON.stringify(content));
}
function check(...argv) {
const lines = [];
const code = run({
i18nDir: dir,
baselinePath,
argv,
log: (line) => lines.push(line),
});
return { code, output: lines.join('\n') };
}
function readBaselineFile() {
return JSON.parse(readFileSync(baselinePath, 'utf8'));
}
test('fails on a new English-identical key that the baseline does not list', () => {
writeLocale('en', { ...EN, APP: { ...EN.APP, NEW: 'New label' } });
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern', NEW: 'New label' },
PIN: 'PIN',
});
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /^FAIL de\.json: .*identical_en=3 baselined=2$/m);
assert.match(output, /de\.json APP\.NEW: "New label"/);
});
test('passes a translated key', () => {
writeLocale('en', EN);
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
PIN: 'PIN-Code',
});
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator' } });
const { code, output } = check();
assert.equal(code, 0);
assert.match(output, /^ok de\.json: .*identical_en=1 baselined=1$/m);
});
test('passes an English-identical key listed in the baseline', () => {
writeLocale('en', EN);
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
PIN: 'PIN',
});
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
assert.equal(check().code, 0);
});
test('a baseline entry only covers the English text it recorded', () => {
// English was reworded and the new text copied into the locale: that is
// new untranslated text, not the value the baseline accepted.
writeLocale('en', { ...EN, APP: { ...EN.APP, SAVE: 'Save changes' } });
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Save changes' },
PIN: 'PIN-Code',
});
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', 'APP.SAVE': 'Save' } });
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /de\.json APP\.SAVE: "Save changes"/);
});
test('reports baseline entries that are no longer identical without failing', () => {
writeLocale('en', EN);
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
PIN: 'PIN-Code',
});
writeBaseline({
de: { 'APP.TITLE': 'IPTVnator', 'APP.SAVE': 'Save', PIN: 'PIN' },
fr: { PIN: 'PIN' },
});
const { code, output } = check();
assert.equal(code, 0);
assert.match(
output,
/^note de\.json: 2 baseline entries .*\(APP\.SAVE, PIN\)$/m
);
assert.match(output, /^note fr: baseline lists a locale with no file$/m);
assert.match(
output,
/^note 3 stale baseline entries; run .*i18n:baseline:update/m
);
});
test('a locale without baseline entries fails on every identical value', () => {
writeLocale('en', EN);
writeLocale('uk', {
APP: { TITLE: 'IPTVnator', SAVE: 'Зберегти' },
PIN: 'PIN-код',
});
const { code, output } = check();
assert.equal(code, 1);
assert.match(output, /uk\.json APP\.TITLE: "IPTVnator"/);
});
test('still fails on missing and extra keys', () => {
writeLocale('en', EN);
writeLocale('de', { APP: { TITLE: 'IPTVnator', OLD: 'Alt' }, PIN: 'PIN' });
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
const { code, output } = check();
assert.equal(code, 1);
assert.match(
output,
/^FAIL de\.json: missing=1 \(APP\.SAVE\) extra=1 \(APP\.OLD\)/m
);
});
test('--fail-on-identical ignores the baseline', () => {
writeLocale('en', EN);
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
PIN: 'PIN',
});
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
const { code, output } = check('--fail-on-identical');
assert.equal(code, 1);
assert.match(output, /de\.json APP\.TITLE: "IPTVnator"/);
assert.match(output, /de\.json PIN: "PIN"/);
});
test('--update-baseline records current identical values and drops resolved ones', () => {
writeLocale('en', EN);
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
PIN: 'PIN',
});
writeLocale('fr', {
APP: { TITLE: 'IPTVnator', SAVE: 'Enregistrer' },
PIN: 'Code PIN',
});
writeBaseline({ de: { 'APP.SAVE': 'Save' }, fr: { PIN: 'PIN' } });
const { code, output } = check('--update-baseline');
assert.equal(code, 0);
assert.match(output, /^ {2}\+ de PIN: "PIN"$/m);
assert.match(output, /Baseline written: 3 added, 2 removed\./);
assert.deepEqual(readBaselineFile(), {
de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' },
fr: { 'APP.TITLE': 'IPTVnator' },
});
assert.equal(check().code, 0);
});
test('--update-baseline keeps the old baseline when a locale is unreadable', () => {
writeLocale('en', EN);
writeFileSync(path.join(dir, 'de.json'), '{ broken');
writeBaseline({ de: { PIN: 'PIN' } });
const { code, output } = check('--update-baseline');
assert.equal(code, 1);
assert.match(output, /baseline not written/);
assert.deepEqual(readBaselineFile(), { de: { PIN: 'PIN' } });
});
test('--update-baseline keeps the old baseline while keys are missing or extra', () => {
writeLocale('en', EN);
writeLocale('de', { APP: { TITLE: 'IPTVnator', OLD: 'Alt' }, PIN: 'PIN' });
writeBaseline({ de: { 'APP.SAVE': 'Save', PIN: 'PIN' } });
const { code, output } = check('--update-baseline');
assert.equal(code, 1);
assert.match(output, /baseline not written/);
assert.deepEqual(readBaselineFile(), {
de: { 'APP.SAVE': 'Save', PIN: 'PIN' },
});
});
test('--fail-on-identical audits even when the baseline is damaged', () => {
writeLocale('en', EN);
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
PIN: 'PIN-Code',
});
writeFileSync(baselinePath, '{ broken');
const { code, output } = check('--fail-on-identical');
assert.equal(code, 1);
assert.match(output, /^FAIL de\.json: missing=0 extra=0 identical_en=1$/m);
assert.match(output, /de\.json APP\.TITLE: "IPTVnator"/);
assert.equal(check().code, 1);
});
test('rejects unknown and conflicting flags', () => {
writeLocale('en', EN);
assert.equal(check('--update').code, 1);
assert.equal(check('--fail-on-identical', '--update-baseline').code, 1);
});
test('rejects a malformed baseline', () => {
assert.throws(() => parseBaseline({ de: ['PIN'] }), /object of keys/);
assert.throws(() => parseBaseline({ de: { PIN: 1 } }), /English text/);
});
test('serialises locales and keys in a stable order', () => {
const serialized = serializeBaseline(
new Map([
[
'fr',
new Map([
['B', 'b'],
['A', 'a'],
]),
],
['de', new Map()],
['ar', new Map([['Z', 'z']])],
])
);
assert.equal(
serialized,
'{\n "ar": {\n "Z": "z"\n },\n "fr": {\n "A": "a",\n "B": "b"\n }\n}\n'
);
});
File diff suppressed because it is too large. Load diff