import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import en from '../locales/en.json' with { type: 'json' }; import kitEn from '../../../waybetter/packages/waybetter/locales/en.json' with { type: 'json' }; import languages from '../locales/languages.json' with { type: 'json' }; import { direction, formatMessage } from '../../../waybetter/packages/runtime/src/i18n.ts'; import { resolveLocale } from '../../../waybetter/packages/shell/src/frame/language.ts'; const desktop = fileURLToPath(new URL('..', import.meta.url)); const root = join(desktop, '../..'); const messages: Record = { ...kitEn.messages, ...en.messages }; /** * The UI languages X's own web client offers (its locale chunk loader, * `loadLanguage.registerChunkLoader` in abs.twimg.com/responsive-web/client-web/main.*.js, read * 2026-09-29), as catalog tags: X's `msa`/`no`/`zh-cn`/`zh-tw` are `ms`/`nb`/`zh-Hans`/`zh-Hant`, * and its pseudo-locales `en-ss`/`en-xx` are left out. docs/TRANSLATING.md names the source. */ const X_LANGUAGES = [ 'ar', 'ar-x-fm', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'en-GB', 'es', 'eu', 'fa', 'fi', 'fil', 'fr', 'ga', 'gl', 'gu', 'ha', 'he', 'hi', 'hr', 'hu', 'id', 'ig', 'it', 'ja', 'kn', 'ko', 'mr', 'ms', 'nb', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'sr', 'sv', 'ta', 'th', 'tr', 'uk', 'ur', 'vi', 'yo', 'zh-Hans', 'zh-Hant', ]; function sources(dir: string, pattern: RegExp): string[] { const out: string[] = []; for (const name of readdirSync(dir)) { const path = join(dir, name); if (statSync(path).isDirectory()) out.push(...sources(path, pattern)); else if (pattern.test(name)) out.push(path); } return out; } test('every catalog passes scripts/i18n-check.mjs and the generated files are current', () => { const run = spawnSync(process.execPath, [join(root, 'scripts/i18n-check.mjs')], { encoding: 'utf8' }); assert.equal(run.status, 0, `${run.stderr}\n${run.stdout.split('\n').slice(-3).join('\n')}`); }); test('every literal key the app looks up exists in the English source catalog', () => { // t('…'), tx('…'), coreText('…') in the shell, panel, popover and X runtime; tr/trf(app, "…") natively. const call = /\b(?:t|tx|coreText|i18n\.t|i18n\.tx)\(\s*(['"])([a-z][A-Za-z0-9_.-]*)\1/g; const native = /\btrf?\(\s*&?app,\s*"([a-z][A-Za-z0-9_.-]*)"/g; const missing: string[] = []; let seen = 0; for (const file of sources(join(desktop, 'src'), /\.(ts|tsx)$/)) { for (const match of readFileSync(file, 'utf8').matchAll(call)) { seen += 1; if (!(match[2]! in messages)) missing.push(`${file.slice(desktop.length)}: ${match[2]}`); } } for (const file of sources(join(desktop, 'src-tauri/src'), /\.rs$/)) { for (const match of readFileSync(file, 'utf8').matchAll(native)) { seen += 1; if (!match[1]!.startsWith('native.') || !(match[1]! in messages)) missing.push(`${file.slice(desktop.length)}: ${match[1]}`); } } // (The panel's own lookups are the framework's: waybetter/packages/shell/tests/panel.test.ts.) assert.ok(seen > 250, `${seen} lookups found (the scan pattern is stale)`); assert.deepEqual(missing, []); }); test('languages.json lists every catalog, and the catalogs are exactly the languages X offers', () => { const files = readdirSync(join(desktop, 'locales')).filter(file => file.endsWith('.json') && !['languages.json', 'en.meta.json'].includes(file)); const codes = files.map(file => file.replace(/\.json$/, '')).sort(); assert.deepEqual(codes, [...X_LANGUAGES].sort()); assert.deepEqual(languages.map(language => language.code).sort(), codes); assert.equal(languages[0]!.code, 'en', 'English first'); for (const language of languages) { const catalog = JSON.parse(readFileSync(join(desktop, 'locales', `${language.code}.json`), 'utf8')); assert.equal(language.name, catalog.name, language.code); assert.equal(language.englishName, catalog.englishName, language.code); assert.equal(language.status, catalog.status, language.code); assert.equal(language.dir, direction(language.code), language.code); assert.ok(language.coverage > 0.99 && language.coverage <= 1, `${language.code} coverage ${language.coverage}`); } for (const rtl of ['ar', 'ar-x-fm', 'fa', 'he', 'ur']) assert.equal(languages.find(language => language.code === rtl)?.dir, 'rtl', rtl); }); test('plural messages render in languages with one form and with many', () => { const load = (code: string) => JSON.parse(readFileSync(join(desktop, 'locales', `${code}.json`), 'utf8')).messages as Record; const key = 'fonts.weightCount'; assert.equal(formatMessage(messages[key]!, { count: 1 }, 'en'), '1 weight'); assert.equal(formatMessage(messages[key]!, { count: 2 }, 'en'), '2 weights'); for (const code of ['ja', 'vi', 'ar', 'ru', 'tr']) { const text = formatMessage(load(code)[key] ?? messages[key]!, { count: 5 }, code); assert.match(text, /5|٥/, `${code}: ${text}`); } }); test('RTL styles use logical properties (no physical left/right margins or paddings)', () => { const physical = /(?:margin|padding)-(?:left|right)\s*:|(?:^|[\s;{])(?:border-(?:left|right))\s*:|text-align\s*:\s*(?:left|right)/m; const offenders: string[] = []; for (const file of sources(join(desktop, 'src'), /\.css$/)) { readFileSync(file, 'utf8').split('\n').forEach((line, index) => { if (physical.test(line) && !line.includes('rtl-ok')) offenders.push(`${file.slice(desktop.length)}:${index + 1}`); }); } assert.deepEqual(offenders, []); }); test('language tiers: 17 supported languages (en-GB with English), the rest community; automatic choices never land on a community language', () => { const codes = languages.map(language => language.code); const supported = languages.filter(language => language.tier === 'supported').map(language => language.code); assert.deepEqual([...supported].sort(), ['ar', 'de', 'en', 'en-GB', 'es', 'fr', 'hi', 'id', 'it', 'ja', 'ko', 'nl', 'pt', 'ru', 'th', 'tr', 'vi', 'zh-Hant'].sort()); assert.equal(languages.length - supported.length, 33); const config = JSON.parse(readFileSync(join(root, 'waybetter.i18n.json'), 'utf8')); assert.deepEqual([...config.supported].sort(), [...supported].sort(), 'languages.json is generated from waybetter.i18n.json'); for (const code of codes) { for (const preference of ['system', 'site'] as const) { const locale = resolveLocale(preference, preference === 'site' ? code : null, [code], codes, supported); assert.ok(supported.includes(locale), `${preference} ${code} -> ${locale}`); } assert.equal(resolveLocale(code, null, ['de'], codes, supported), code, `explicit ${code}`); } const auto = (tag: string) => resolveLocale('system', null, [tag], codes, supported); assert.equal(auto('pt-BR'), 'pt'); assert.equal(auto('pt-PT'), 'pt'); assert.equal(auto('zh-TW'), 'zh-Hant'); assert.equal(auto('zh-HK'), 'zh-Hant'); assert.equal(auto('en-AU'), 'en'); assert.equal(auto('en-GB'), 'en-GB'); assert.equal(auto('sv-SE'), 'en', 'Swedish is a community translation'); assert.equal(auto('zh-CN'), 'en', 'Simplified Chinese is a community translation'); });