snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,368 @@
// 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)');
});
});
});