// 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 this'}, {'fr.json': {'a.b': 'Lisez ceci et cela'}}, ); expect(code).toBe(1); expect(stderr).toContain('unknown variable/tag "b" not present in source'); }); test.each([ ['variable', 'Hello {name}', 'Bonjour', 'name'], ['tag', 'Read this', '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}'}, {'fr.json': {'a.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 this', "Lisez l'article"], ['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 this'}, {'fr.json': {'a.b': "Lisez l''article"}}, ); expect(code).toBe(0); }); test('accepts a typographic apostrophe', () => { const {code} = check( {'a.b': 'Read this'}, {'fr.json': {'a.b': 'Lisez l’article'}}, ); 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)'); }); }); });