Files
upstream-mattermost/webapp/channels/scripts/check_icu.test.js
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

369 lines
14 KiB
JavaScript
Raw Permalink 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.
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @jest-environment node
*/
// check_icu.mjs is an ESM CLI whose contract is its exit code, so these drive
// the real script in a subprocess rather than importing pieces of it.
const {spawnSync} = require('child_process');
const fs = require('fs');
const {createRequire} = require('module');
const os = require('os');
const path = require('path');
const SCRIPT = path.join(__dirname, 'check_icu.mjs');
/**
* Write a source catalog and one or more locale catalogs to a temp i18n dir, run
* the checker over that directory, and return its exit code and output. A
* catalog may be an object, or a string when the case needs to be malformed.
*/
function check(en, locales, {warnMissingKeys = false, extraFiles = {}} = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
const serialize = (body) => (typeof body === 'string' ? body : JSON.stringify(body));
fs.writeFileSync(path.join(dir, 'en.json'), serialize(en));
for (const [name, body] of Object.entries({...locales, ...extraFiles})) {
fs.writeFileSync(path.join(dir, name), serialize(body));
}
const args = [SCRIPT, dir, ...(warnMissingKeys ? ['--warn-missing-keys'] : [])];
const result = spawnSync(process.execPath, args, {encoding: 'utf8'});
return {code: result.status, stdout: result.stdout, stderr: result.stderr};
}
describe('check_icu', () => {
describe('parser', () => {
// The script's whole premise is that parsing here implies parsing under
// react-intl, which only holds while the two agree on a version. Three
// copies of this package exist in the tree, so a bare import picks up
// whichever npm hoisted -- for a long time, the older one
// babel-plugin-formatjs pulls. The script sidesteps that by resolving
// the parser from react-intl's own node_modules, which works only while
// react-intl keeps depending on the parser directly.
test('is the copy react-intl loads', () => {
const wantedByReactIntl = require('react-intl/package.json').dependencies['@formatjs/icu-messageformat-parser'];
expect(wantedByReactIntl).toBeDefined();
const fromReactIntl = createRequire(require.resolve('react-intl'));
const resolved = fromReactIntl('@formatjs/icu-messageformat-parser/package.json').version;
expect(resolved).toBe(wantedByReactIntl);
});
});
describe('well-formedness', () => {
test('accepts a clean catalog', () => {
const {code, stdout} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name}'}},
);
expect(code).toBe(0);
expect(stdout).toContain('OK:');
});
test('rejects invalid JSON', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': '{"a.b":'});
expect(code).toBe(1);
expect(stderr).toContain('invalid JSON');
});
test.each([
['null', null],
['a number', 5],
['an object', {nested: 'x'}],
])('rejects a value that is %s', (_label, value) => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {'a.b': value}});
expect(code).toBe(1);
expect(stderr).toContain('value is not a string');
});
test('rejects a translation that does not parse', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name'}},
);
expect(code).toBe(1);
expect(stderr).toContain('does not parse');
});
test('reports an unparseable source once, and skips its key', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name'},
{'fr.json': {'a.b': 'Bonjour {name}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('en.json:a.b: source does not parse');
expect(stderr).not.toContain('fr.json:a.b:');
});
});
describe('key parity', () => {
test('an extra key is always an error, since nothing will read it', () => {
const {code, stderr} = check(
{'a.b': 'Hello'},
{'fr.json': {'a.b': 'Bonjour', 'z.z': 'Orphelin'}},
);
expect(code).toBe(1);
expect(stderr).toContain('fr.json:z.z: extra key not in en.json');
});
test('a missing key is an error by default', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {}});
expect(code).toBe(1);
expect(stderr).toContain('fr.json:a.b: missing key');
});
test('a missing key is a warning under --warn-missing-keys', () => {
const {code, stderr} = check({'a.b': 'Hello'}, {'fr.json': {}}, {warnMissingKeys: true});
expect(code).toBe(0);
expect(stderr).toContain('fr.json:a.b: missing key');
expect(stderr).toContain('1 warning(s)');
});
});
describe('variables and tags', () => {
test('rejects a variable the source does not have', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{'fr.json': {'a.b': 'Bonjour {name}, {extra}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('unknown variable/tag "extra" not present in source');
});
test('rejects a tag the source does not have', () => {
const {code, stderr} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': 'Lisez <link>ceci</link> et <b>cela</b>'}},
);
expect(code).toBe(1);
expect(stderr).toContain('unknown variable/tag "b" not present in source');
});
test.each([
['variable', 'Hello {name}', 'Bonjour', 'name'],
['tag', 'Read <link>this</link>', 'Lisez ceci', 'link'],
])('rejects dropping a source %s', (_label, en, fr, dropped) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain(`source variable/tag "${dropped}" is missing from the translation`);
});
test('accepts a name used as both a tag and a variable', () => {
const {code} = check(
{'a.b': '<b>{b}</b>'},
{'fr.json': {'a.b': '<b>{b}</b>'}},
);
expect(code).toBe(0);
});
test('finds variables nested inside plural branches', () => {
const {code, stderr} = check(
{'a.b': '{count, plural, one {{name} item} other {{name} items}}'},
{'fr.json': {'a.b': '{count, plural, one {un article} other {des articles}}'}},
);
expect(code).toBe(1);
expect(stderr).toContain('source variable/tag "name" is missing from the translation');
});
});
describe('argument roles', () => {
// Demotion is the defect the role comparison exists for: it still
// parses and still renders, it just silently stops pluralizing.
test.each([
['plural', '{count, plural, one {# item} other {# items}}', '{count} articles'],
['number', 'You have {count, number} points', 'Vous avez {count} points'],
])('rejects demoting %s to a bare argument', (role, en, fr) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain(`the source only uses it as ${role}`);
});
// English needs a plural far less often than the languages it is
// translated into, so widening a bare argument is correct translation,
// not a defect.
test.each([
['a bare argument', '{count} items were deleted'],
['a number', '{count, number} items were deleted'],
])('accepts promoting %s to a plural', (_label, en) => {
const {code} = check(
{'a.b': en},
{'ru.json': {'a.b': '{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}'}},
);
expect(code).toBe(0);
});
test('accepts promoting a bare argument to a select', () => {
const {code} = check(
{'a.b': 'Status: {status}'},
{'fr.json': {'a.b': 'Statut : {status, select, active {actif} other {autre}}'}},
);
expect(code).toBe(0);
});
// Formatting types are not reachable from a bare argument: applying them
// to a value that is not a number or a date renders NaN or Invalid Date.
test.each([
['number', '{count, number}'],
['date', '{count, date}'],
])('rejects promoting a bare argument to %s', (role, fr) => {
const {code, stderr} = check({'a.b': '{count} items'}, {'fr.json': {'a.b': `${fr} articles`}});
expect(code).toBe(1);
expect(stderr).toContain(`"count" is used as ${role}`);
});
test('does not compare plural categories, which belong to the locale', () => {
const {code} = check(
{'a.b': '{count, plural, one {# item} other {# items}}'},
{'ru.json': {'a.b': '{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}'}},
);
expect(code).toBe(0);
});
test('does not treat # as a variable', () => {
const {code} = check(
{'a.b': '{count, plural, one {# item} other {# items}}'},
{'fr.json': {'a.b': '{count, plural, one {# article} other {# articles}}'}},
);
expect(code).toBe(0);
});
});
describe('apostrophes', () => {
// A lone apostrophe before ICU syntax opens a quoted literal that
// swallows the rest of the message. It still parses, so nothing else
// catches it.
test.each([
['a tag', 'Read <link>this</link>', "Lisez l'<link>article</link>"],
['a variable', 'Today is {day}', "Aujourd'hui c'est l'{day}"],
])('rejects an unescaped apostrophe before %s', (_label, en, fr) => {
const {code, stderr} = check({'a.b': en}, {'fr.json': {'a.b': fr}});
expect(code).toBe(1);
expect(stderr).toContain('opens a quoted literal');
});
test('accepts a doubled apostrophe, the documented elision escape', () => {
const {code} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': "Lisez l''<link>article</link>"}},
);
expect(code).toBe(0);
});
test('accepts a typographic apostrophe', () => {
const {code} = check(
{'a.b': 'Read <link>this</link>'},
{'fr.json': {'a.b': 'Lisez l’<link>article</link>'}},
);
expect(code).toBe(0);
});
test('does not flag quoting the source itself uses', () => {
const {code} = check(
{'a.b': "Use '{braces}' literally"},
{'fr.json': {'a.b': "Utilisez '{braces}' littéralement"}},
);
expect(code).toBe(0);
});
});
describe('cli', () => {
test.each([
['no arguments', []],
['more than one directory', ['a', 'b']],
])('exits 2 on %s', (_label, args) => {
const result = spawnSync(process.execPath, [SCRIPT, ...args], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('usage:');
});
test('exits 2 on a directory that does not exist', () => {
const missing = path.join(os.tmpdir(), 'check-icu-does-not-exist');
const result = spawnSync(process.execPath, [SCRIPT, missing], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('cannot read the i18n directory');
});
test('exits 2 when en.json is missing or malformed', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
fs.writeFileSync(path.join(dir, 'fr.json'), '{}');
const missing = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(missing.status).toBe(2);
expect(missing.stderr).toContain('cannot read');
fs.writeFileSync(path.join(dir, 'en.json'), '{not json');
const malformed = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(malformed.status).toBe(2);
expect(malformed.stderr).toContain('cannot read');
});
test('exits 2 when the directory holds no locale catalogs', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-icu-'));
fs.writeFileSync(path.join(dir, 'en.json'), '{}');
const result = spawnSync(process.execPath, [SCRIPT, dir], {encoding: 'utf8'});
expect(result.status).toBe(2);
expect(result.stderr).toContain('no locale catalogs');
});
// src/i18n holds i18n.ts and a CLAUDE.OPTIONAL.md beside the catalogs.
test('walks only the sibling json catalogs, and never en.json', () => {
const {code, stdout} = check(
{'a.b': 'Hello'},
{'fr.json': {'a.b': 'Bonjour'}},
{extraFiles: {'i18n.ts': 'export default 1;', 'notes.md': 'hi'}},
);
expect(code).toBe(0);
expect(stdout).toContain('OK: 1 locale files checked');
});
test('reports every locale file, not just the first that fails', () => {
const {code, stderr} = check(
{'a.b': 'Hello {name}'},
{
'fr.json': {'a.b': 'Bonjour'},
'de.json': {'a.b': 'Hallo'},
},
);
expect(code).toBe(1);
expect(stderr).toContain('fr.json:a.b:');
expect(stderr).toContain('de.json:a.b:');
expect(stderr).toContain('2 error(s) across 2 locale file(s)');
});
});
});