You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

check-i18n.test.ts 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. // Mock functions to simulate the check-i18n functionality
  4. const vm = require('node:vm')
  5. const transpile = require('typescript').transpile
  6. describe('check-i18n script functionality', () => {
  7. const testDir = path.join(__dirname, '../i18n-test')
  8. const testEnDir = path.join(testDir, 'en-US')
  9. const testZhDir = path.join(testDir, 'zh-Hans')
  10. // Helper function that replicates the getKeysFromLanguage logic
  11. async function getKeysFromLanguage(language: string, testPath = testDir): Promise<string[]> {
  12. return new Promise((resolve, reject) => {
  13. const folderPath = path.resolve(testPath, language)
  14. const allKeys: string[] = []
  15. if (!fs.existsSync(folderPath)) {
  16. resolve([])
  17. return
  18. }
  19. fs.readdir(folderPath, (err, files) => {
  20. if (err) {
  21. reject(err)
  22. return
  23. }
  24. const translationFiles = files.filter(file => /\.(ts|js)$/.test(file))
  25. translationFiles.forEach((file) => {
  26. const filePath = path.join(folderPath, file)
  27. const fileName = file.replace(/\.[^/.]+$/, '')
  28. const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) =>
  29. c.toUpperCase(),
  30. )
  31. try {
  32. const content = fs.readFileSync(filePath, 'utf8')
  33. const moduleExports = {}
  34. const context = {
  35. exports: moduleExports,
  36. module: { exports: moduleExports },
  37. require,
  38. console,
  39. __filename: filePath,
  40. __dirname: folderPath,
  41. }
  42. vm.runInNewContext(transpile(content), context)
  43. const translationObj = (context.module.exports as any).default || context.module.exports
  44. if (!translationObj || typeof translationObj !== 'object')
  45. throw new Error(`Error parsing file: ${filePath}`)
  46. const nestedKeys: string[] = []
  47. const iterateKeys = (obj: any, prefix = '') => {
  48. for (const key in obj) {
  49. const nestedKey = prefix ? `${prefix}.${key}` : key
  50. if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
  51. // This is an object (but not array), recurse into it but don't add it as a key
  52. iterateKeys(obj[key], nestedKey)
  53. }
  54. else {
  55. // This is a leaf node (string, number, boolean, array, etc.), add it as a key
  56. nestedKeys.push(nestedKey)
  57. }
  58. }
  59. }
  60. iterateKeys(translationObj)
  61. const fileKeys = nestedKeys.map(key => `${camelCaseFileName}.${key}`)
  62. allKeys.push(...fileKeys)
  63. }
  64. catch (error) {
  65. reject(error)
  66. }
  67. })
  68. resolve(allKeys)
  69. })
  70. })
  71. }
  72. beforeEach(() => {
  73. // Clean up and create test directories
  74. if (fs.existsSync(testDir))
  75. fs.rmSync(testDir, { recursive: true })
  76. fs.mkdirSync(testDir, { recursive: true })
  77. fs.mkdirSync(testEnDir, { recursive: true })
  78. fs.mkdirSync(testZhDir, { recursive: true })
  79. })
  80. afterEach(() => {
  81. // Clean up test files
  82. if (fs.existsSync(testDir))
  83. fs.rmSync(testDir, { recursive: true })
  84. })
  85. describe('Key extraction logic', () => {
  86. it('should extract only leaf node keys, not intermediate objects', async () => {
  87. const testContent = `const translation = {
  88. simple: 'Simple Value',
  89. nested: {
  90. level1: 'Level 1 Value',
  91. deep: {
  92. level2: 'Level 2 Value'
  93. }
  94. },
  95. array: ['not extracted'],
  96. number: 42,
  97. boolean: true
  98. }
  99. export default translation
  100. `
  101. fs.writeFileSync(path.join(testEnDir, 'test.ts'), testContent)
  102. const keys = await getKeysFromLanguage('en-US')
  103. expect(keys).toEqual([
  104. 'test.simple',
  105. 'test.nested.level1',
  106. 'test.nested.deep.level2',
  107. 'test.array',
  108. 'test.number',
  109. 'test.boolean',
  110. ])
  111. // Should not include intermediate object keys
  112. expect(keys).not.toContain('test.nested')
  113. expect(keys).not.toContain('test.nested.deep')
  114. })
  115. it('should handle camelCase file name conversion correctly', async () => {
  116. const testContent = `const translation = {
  117. key: 'value'
  118. }
  119. export default translation
  120. `
  121. fs.writeFileSync(path.join(testEnDir, 'app-debug.ts'), testContent)
  122. fs.writeFileSync(path.join(testEnDir, 'user_profile.ts'), testContent)
  123. const keys = await getKeysFromLanguage('en-US')
  124. expect(keys).toContain('appDebug.key')
  125. expect(keys).toContain('userProfile.key')
  126. })
  127. })
  128. describe('Missing keys detection', () => {
  129. it('should detect missing keys in target language', async () => {
  130. const enContent = `const translation = {
  131. common: {
  132. save: 'Save',
  133. cancel: 'Cancel',
  134. delete: 'Delete'
  135. },
  136. app: {
  137. title: 'My App',
  138. version: '1.0'
  139. }
  140. }
  141. export default translation
  142. `
  143. const zhContent = `const translation = {
  144. common: {
  145. save: '保存',
  146. cancel: '取消'
  147. // missing 'delete'
  148. },
  149. app: {
  150. title: '我的应用'
  151. // missing 'version'
  152. }
  153. }
  154. export default translation
  155. `
  156. fs.writeFileSync(path.join(testEnDir, 'test.ts'), enContent)
  157. fs.writeFileSync(path.join(testZhDir, 'test.ts'), zhContent)
  158. const enKeys = await getKeysFromLanguage('en-US')
  159. const zhKeys = await getKeysFromLanguage('zh-Hans')
  160. const missingKeys = enKeys.filter(key => !zhKeys.includes(key))
  161. expect(missingKeys).toContain('test.common.delete')
  162. expect(missingKeys).toContain('test.app.version')
  163. expect(missingKeys).toHaveLength(2)
  164. })
  165. })
  166. describe('Extra keys detection', () => {
  167. it('should detect extra keys in target language', async () => {
  168. const enContent = `const translation = {
  169. common: {
  170. save: 'Save',
  171. cancel: 'Cancel'
  172. }
  173. }
  174. export default translation
  175. `
  176. const zhContent = `const translation = {
  177. common: {
  178. save: '保存',
  179. cancel: '取消',
  180. delete: '删除', // extra key
  181. extra: '额外的' // another extra key
  182. },
  183. newSection: {
  184. someKey: '某个值' // extra section
  185. }
  186. }
  187. export default translation
  188. `
  189. fs.writeFileSync(path.join(testEnDir, 'test.ts'), enContent)
  190. fs.writeFileSync(path.join(testZhDir, 'test.ts'), zhContent)
  191. const enKeys = await getKeysFromLanguage('en-US')
  192. const zhKeys = await getKeysFromLanguage('zh-Hans')
  193. const extraKeys = zhKeys.filter(key => !enKeys.includes(key))
  194. expect(extraKeys).toContain('test.common.delete')
  195. expect(extraKeys).toContain('test.common.extra')
  196. expect(extraKeys).toContain('test.newSection.someKey')
  197. expect(extraKeys).toHaveLength(3)
  198. })
  199. })
  200. describe('File filtering logic', () => {
  201. it('should filter keys by specific file correctly', async () => {
  202. // Create multiple files
  203. const file1Content = `const translation = {
  204. button: 'Button',
  205. text: 'Text'
  206. }
  207. export default translation
  208. `
  209. const file2Content = `const translation = {
  210. title: 'Title',
  211. description: 'Description'
  212. }
  213. export default translation
  214. `
  215. fs.writeFileSync(path.join(testEnDir, 'components.ts'), file1Content)
  216. fs.writeFileSync(path.join(testEnDir, 'pages.ts'), file2Content)
  217. fs.writeFileSync(path.join(testZhDir, 'components.ts'), file1Content)
  218. fs.writeFileSync(path.join(testZhDir, 'pages.ts'), file2Content)
  219. const allEnKeys = await getKeysFromLanguage('en-US')
  220. const allZhKeys = await getKeysFromLanguage('zh-Hans')
  221. // Test file filtering logic
  222. const targetFile = 'components'
  223. const filteredEnKeys = allEnKeys.filter(key =>
  224. key.startsWith(targetFile.replace(/[-_](.)/g, (_, c) => c.toUpperCase())),
  225. )
  226. expect(allEnKeys).toHaveLength(4) // 2 keys from each file
  227. expect(filteredEnKeys).toHaveLength(2) // only components keys
  228. expect(filteredEnKeys).toContain('components.button')
  229. expect(filteredEnKeys).toContain('components.text')
  230. expect(filteredEnKeys).not.toContain('pages.title')
  231. expect(filteredEnKeys).not.toContain('pages.description')
  232. })
  233. })
  234. describe('Complex nested structure handling', () => {
  235. it('should handle deeply nested objects correctly', async () => {
  236. const complexContent = `const translation = {
  237. level1: {
  238. level2: {
  239. level3: {
  240. level4: {
  241. deepValue: 'Deep Value'
  242. },
  243. anotherValue: 'Another Value'
  244. },
  245. simpleValue: 'Simple Value'
  246. },
  247. directValue: 'Direct Value'
  248. },
  249. rootValue: 'Root Value'
  250. }
  251. export default translation
  252. `
  253. fs.writeFileSync(path.join(testEnDir, 'complex.ts'), complexContent)
  254. const keys = await getKeysFromLanguage('en-US')
  255. expect(keys).toContain('complex.level1.level2.level3.level4.deepValue')
  256. expect(keys).toContain('complex.level1.level2.level3.anotherValue')
  257. expect(keys).toContain('complex.level1.level2.simpleValue')
  258. expect(keys).toContain('complex.level1.directValue')
  259. expect(keys).toContain('complex.rootValue')
  260. // Should not include intermediate objects
  261. expect(keys).not.toContain('complex.level1')
  262. expect(keys).not.toContain('complex.level1.level2')
  263. expect(keys).not.toContain('complex.level1.level2.level3')
  264. expect(keys).not.toContain('complex.level1.level2.level3.level4')
  265. })
  266. })
  267. describe('Edge cases', () => {
  268. it('should handle empty objects', async () => {
  269. const emptyContent = `const translation = {
  270. empty: {},
  271. withValue: 'value'
  272. }
  273. export default translation
  274. `
  275. fs.writeFileSync(path.join(testEnDir, 'empty.ts'), emptyContent)
  276. const keys = await getKeysFromLanguage('en-US')
  277. expect(keys).toContain('empty.withValue')
  278. expect(keys).not.toContain('empty.empty')
  279. })
  280. it('should handle special characters in keys', async () => {
  281. const specialContent = `const translation = {
  282. 'key-with-dash': 'value1',
  283. 'key_with_underscore': 'value2',
  284. 'key.with.dots': 'value3',
  285. normalKey: 'value4'
  286. }
  287. export default translation
  288. `
  289. fs.writeFileSync(path.join(testEnDir, 'special.ts'), specialContent)
  290. const keys = await getKeysFromLanguage('en-US')
  291. expect(keys).toContain('special.key-with-dash')
  292. expect(keys).toContain('special.key_with_underscore')
  293. expect(keys).toContain('special.key.with.dots')
  294. expect(keys).toContain('special.normalKey')
  295. })
  296. it('should handle different value types', async () => {
  297. const typesContent = `const translation = {
  298. stringValue: 'string',
  299. numberValue: 42,
  300. booleanValue: true,
  301. nullValue: null,
  302. undefinedValue: undefined,
  303. arrayValue: ['array', 'values'],
  304. objectValue: {
  305. nested: 'nested value'
  306. }
  307. }
  308. export default translation
  309. `
  310. fs.writeFileSync(path.join(testEnDir, 'types.ts'), typesContent)
  311. const keys = await getKeysFromLanguage('en-US')
  312. expect(keys).toContain('types.stringValue')
  313. expect(keys).toContain('types.numberValue')
  314. expect(keys).toContain('types.booleanValue')
  315. expect(keys).toContain('types.nullValue')
  316. expect(keys).toContain('types.undefinedValue')
  317. expect(keys).toContain('types.arrayValue')
  318. expect(keys).toContain('types.objectValue.nested')
  319. expect(keys).not.toContain('types.objectValue')
  320. })
  321. })
  322. describe('Real-world scenario tests', () => {
  323. it('should handle app-debug structure like real files', async () => {
  324. const appDebugEn = `const translation = {
  325. pageTitle: {
  326. line1: 'Prompt',
  327. line2: 'Engineering'
  328. },
  329. operation: {
  330. applyConfig: 'Publish',
  331. resetConfig: 'Reset',
  332. debugConfig: 'Debug'
  333. },
  334. generate: {
  335. instruction: 'Instructions',
  336. generate: 'Generate',
  337. resTitle: 'Generated Prompt',
  338. noDataLine1: 'Describe your use case on the left,',
  339. noDataLine2: 'the orchestration preview will show here.'
  340. }
  341. }
  342. export default translation
  343. `
  344. const appDebugZh = `const translation = {
  345. pageTitle: {
  346. line1: '提示词',
  347. line2: '编排'
  348. },
  349. operation: {
  350. applyConfig: '发布',
  351. resetConfig: '重置',
  352. debugConfig: '调试'
  353. },
  354. generate: {
  355. instruction: '指令',
  356. generate: '生成',
  357. resTitle: '生成的提示词',
  358. noData: '在左侧描述您的用例,编排预览将在此处显示。' // This is extra
  359. }
  360. }
  361. export default translation
  362. `
  363. fs.writeFileSync(path.join(testEnDir, 'app-debug.ts'), appDebugEn)
  364. fs.writeFileSync(path.join(testZhDir, 'app-debug.ts'), appDebugZh)
  365. const enKeys = await getKeysFromLanguage('en-US')
  366. const zhKeys = await getKeysFromLanguage('zh-Hans')
  367. const missingKeys = enKeys.filter(key => !zhKeys.includes(key))
  368. const extraKeys = zhKeys.filter(key => !enKeys.includes(key))
  369. expect(missingKeys).toContain('appDebug.generate.noDataLine1')
  370. expect(missingKeys).toContain('appDebug.generate.noDataLine2')
  371. expect(extraKeys).toContain('appDebug.generate.noData')
  372. expect(missingKeys).toHaveLength(2)
  373. expect(extraKeys).toHaveLength(1)
  374. })
  375. it('should handle time structure with operation nested keys', async () => {
  376. const timeEn = `const translation = {
  377. months: {
  378. January: 'January',
  379. February: 'February'
  380. },
  381. operation: {
  382. now: 'Now',
  383. ok: 'OK',
  384. cancel: 'Cancel',
  385. pickDate: 'Pick Date'
  386. },
  387. title: {
  388. pickTime: 'Pick Time'
  389. },
  390. defaultPlaceholder: 'Pick a time...'
  391. }
  392. export default translation
  393. `
  394. const timeZh = `const translation = {
  395. months: {
  396. January: '一月',
  397. February: '二月'
  398. },
  399. operation: {
  400. now: '此刻',
  401. ok: '确定',
  402. cancel: '取消',
  403. pickDate: '选择日期'
  404. },
  405. title: {
  406. pickTime: '选择时间'
  407. },
  408. pickDate: '选择日期', // This is extra - duplicates operation.pickDate
  409. defaultPlaceholder: '请选择时间...'
  410. }
  411. export default translation
  412. `
  413. fs.writeFileSync(path.join(testEnDir, 'time.ts'), timeEn)
  414. fs.writeFileSync(path.join(testZhDir, 'time.ts'), timeZh)
  415. const enKeys = await getKeysFromLanguage('en-US')
  416. const zhKeys = await getKeysFromLanguage('zh-Hans')
  417. const missingKeys = enKeys.filter(key => !zhKeys.includes(key))
  418. const extraKeys = zhKeys.filter(key => !enKeys.includes(key))
  419. expect(missingKeys).toHaveLength(0) // No missing keys
  420. expect(extraKeys).toContain('time.pickDate') // Extra root-level pickDate
  421. expect(extraKeys).toHaveLength(1)
  422. // Should have both keys available
  423. expect(zhKeys).toContain('time.operation.pickDate') // Correct nested key
  424. expect(zhKeys).toContain('time.pickDate') // Extra duplicate key
  425. })
  426. })
  427. describe('Statistics calculation', () => {
  428. it('should calculate correct difference statistics', async () => {
  429. const enContent = `const translation = {
  430. key1: 'value1',
  431. key2: 'value2',
  432. key3: 'value3'
  433. }
  434. export default translation
  435. `
  436. const zhContentMissing = `const translation = {
  437. key1: 'value1',
  438. key2: 'value2'
  439. // missing key3
  440. }
  441. export default translation
  442. `
  443. const zhContentExtra = `const translation = {
  444. key1: 'value1',
  445. key2: 'value2',
  446. key3: 'value3',
  447. key4: 'extra',
  448. key5: 'extra2'
  449. }
  450. export default translation
  451. `
  452. fs.writeFileSync(path.join(testEnDir, 'stats.ts'), enContent)
  453. // Test missing keys scenario
  454. fs.writeFileSync(path.join(testZhDir, 'stats.ts'), zhContentMissing)
  455. const enKeys = await getKeysFromLanguage('en-US')
  456. const zhKeysMissing = await getKeysFromLanguage('zh-Hans')
  457. expect(enKeys.length - zhKeysMissing.length).toBe(1) // +1 means 1 missing key
  458. // Test extra keys scenario
  459. fs.writeFileSync(path.join(testZhDir, 'stats.ts'), zhContentExtra)
  460. const zhKeysExtra = await getKeysFromLanguage('zh-Hans')
  461. expect(enKeys.length - zhKeysExtra.length).toBe(-2) // -2 means 2 extra keys
  462. })
  463. })
  464. })