cherry-studio/scripts/__tests__/check-hardcoded-strings.test.ts
SuYao 5c013cdb63
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
feat(i18n): add hardcoded string detection script and CI check (#12547)
* feat(i18n): clean hardcoded ui string add ci

* fix(i18n): update snap

* fix: test

* fix(i18n): update plurals

* chore: revert other branch change

* refactor: use ast detect hardcoded string

* chore: typo error

* feat(i18n): add webview app

* chore(i18n): update i18n

* fix: pr comment

* chore: distinguish label and labelKey

* refactor: align v2 desgin
2026-01-24 18:19:38 +08:00

361 lines
12 KiB
TypeScript

import * as path from 'path'
import { Node, Project } from 'ts-morph'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
HardcodedStringDetector,
hasCJK,
hasEnglishUIText,
isInCodeContext,
isNonUIString,
shouldSkipNode
} from '../check-hardcoded-strings'
function createTestProject() {
return new Project({
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
compilerOptions: { jsx: 2 } // React JSX
})
}
function findStringLiteral(project: Project, code: string, targetString: string): Node | undefined {
const sourceFile = project.createSourceFile('test.tsx', code, { overwrite: true })
let found: Node | undefined
sourceFile.forEachDescendant((node) => {
if (Node.isStringLiteral(node) && node.getLiteralValue() === targetString) {
found = node
}
})
return found
}
function findTemplateLiteral(project: Project, code: string): Node | undefined {
const sourceFile = project.createSourceFile('test.tsx', code, { overwrite: true })
let found: Node | undefined
sourceFile.forEachDescendant((node) => {
if (Node.isNoSubstitutionTemplateLiteral(node) || Node.isTemplateExpression(node)) {
found = node
}
})
return found
}
// Mock fs module
vi.mock('fs')
describe('check-hardcoded-strings', () => {
const mockSrcDir = '/mock/src/renderer/src'
beforeEach(() => {
vi.resetAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('hasCJK', () => {
it('should detect Chinese characters', () => {
expect(hasCJK('测试文本')).toBe(true)
expect(hasCJK('Hello 世界')).toBe(true)
expect(hasCJK('中文')).toBe(true)
})
it('should detect Japanese characters', () => {
expect(hasCJK('こんにちは')).toBe(true) // Hiragana
expect(hasCJK('カタカナ')).toBe(true) // Katakana
expect(hasCJK('日本語')).toBe(true) // Kanji
})
it('should detect Korean characters', () => {
expect(hasCJK('한국어')).toBe(true) // Hangul
expect(hasCJK('안녕하세요')).toBe(true)
})
it('should return false for non-CJK text', () => {
expect(hasCJK('Hello World')).toBe(false)
expect(hasCJK('12345')).toBe(false)
expect(hasCJK('')).toBe(false)
})
})
describe('hasEnglishUIText', () => {
it('should detect English UI text patterns', () => {
expect(hasEnglishUIText('Create New File')).toBe(true)
expect(hasEnglishUIText('Save As')).toBe(true)
expect(hasEnglishUIText('Open Project')).toBe(true)
})
it('should reject single words', () => {
expect(hasEnglishUIText('Save')).toBe(false)
expect(hasEnglishUIText('Cancel')).toBe(false)
})
it('should reject lowercase text', () => {
expect(hasEnglishUIText('create new file')).toBe(false)
expect(hasEnglishUIText('save as')).toBe(false)
})
it('should reject too long phrases', () => {
expect(hasEnglishUIText('This Is A Very Long Phrase With Many Words')).toBe(false)
})
})
describe('isNonUIString', () => {
it('should identify empty strings', () => {
expect(isNonUIString('')).toBe(true)
})
it('should identify pure numbers', () => {
expect(isNonUIString('123')).toBe(true)
expect(isNonUIString('0')).toBe(true)
expect(isNonUIString('999')).toBe(true)
})
it('should not mark regular UI text as non-UI', () => {
expect(isNonUIString('Hello World')).toBe(false)
expect(isNonUIString('Save')).toBe(false)
expect(isNonUIString('确认')).toBe(false)
expect(isNonUIString('请输入内容')).toBe(false)
expect(isNonUIString('-')).toBe(false) // Even short strings may be UI in specific contexts
})
it('should not filter technical strings (now handled by AST context)', () => {
// With AST-based detection, these are no longer filtered
// because we only check specific UI contexts where they rarely appear
expect(isNonUIString('./path/to/file')).toBe(false)
expect(isNonUIString('https://example.com')).toBe(false)
expect(isNonUIString('#fff')).toBe(false)
expect(isNonUIString('snake_case_id')).toBe(false)
})
})
describe('File filtering', () => {
const IGNORED_DIRS = ['__tests__', 'node_modules', 'i18n', 'locales', 'types', 'assets']
const IGNORED_FILES = ['*.test.ts', '*.test.tsx', '*.d.ts']
const mockShouldSkipFile = (filePath: string): boolean => {
const relativePath = filePath.replace(mockSrcDir + '/', '')
if (IGNORED_DIRS.some((dir) => relativePath.includes(dir))) {
return true
}
const fileName = path.basename(filePath)
if (
IGNORED_FILES.some((pattern) => {
const regex = new RegExp(pattern.replace('*', '.*'))
return regex.test(fileName)
})
) {
return true
}
return false
}
it('should skip test files', () => {
expect(mockShouldSkipFile(`${mockSrcDir}/components/Button.test.tsx`)).toBe(true)
expect(mockShouldSkipFile(`${mockSrcDir}/utils/helper.test.ts`)).toBe(true)
})
it('should skip type definition files', () => {
expect(mockShouldSkipFile(`${mockSrcDir}/types/index.d.ts`)).toBe(true)
})
it('should skip i18n/locales directories', () => {
expect(mockShouldSkipFile(`${mockSrcDir}/i18n/locales/en-us.json`)).toBe(true)
expect(mockShouldSkipFile(`${mockSrcDir}/locales/zh-cn.json`)).toBe(true)
})
it('should skip __tests__ directories', () => {
expect(mockShouldSkipFile(`${mockSrcDir}/components/__tests__/Button.test.tsx`)).toBe(true)
})
it('should NOT skip regular component files', () => {
expect(mockShouldSkipFile(`${mockSrcDir}/components/Button.tsx`)).toBe(false)
expect(mockShouldSkipFile(`${mockSrcDir}/pages/Home.tsx`)).toBe(false)
})
it('should NOT skip regular TypeScript files', () => {
expect(mockShouldSkipFile(`${mockSrcDir}/utils/helper.ts`)).toBe(false)
})
})
describe('HardcodedStringDetector', () => {
// These are integration tests that would require actual files
// For unit testing, we test the exported utility functions instead
it('should be instantiable', () => {
const detector = new HardcodedStringDetector()
expect(detector).toBeDefined()
})
})
describe('Legacy pattern compatibility (regex patterns for reference)', () => {
// Keep legacy pattern tests for backward compatibility reference
const CHINESE_PATTERNS = [
{ regex: />([^<]*[\u4e00-\u9fff][^<]*)</g, name: 'JSX text content' },
{
regex: /(?:placeholder|title|label|message|description|tooltip)=["']([^"']*[\u4e00-\u9fff][^"']*)["']/g,
name: 'attribute'
}
]
it('should detect Chinese characters in JSX text content (regex)', () => {
const testLine = '<span>测试文本</span>'
const matches = testLine.match(CHINESE_PATTERNS[0].regex)
expect(matches).not.toBeNull()
})
it('should detect Chinese characters in placeholder attribute (regex)', () => {
const testLine = 'placeholder="请输入内容"'
const matches = testLine.match(CHINESE_PATTERNS[1].regex)
expect(matches).not.toBeNull()
})
it('should detect Chinese characters in title attribute (regex)', () => {
const testLine = 'title="提示信息"'
const matches = testLine.match(CHINESE_PATTERNS[1].regex)
expect(matches).not.toBeNull()
})
})
describe('shouldSkipNode', () => {
let project: Project
beforeEach(() => {
project = createTestProject()
})
it('should skip import declarations', () => {
const node = findStringLiteral(project, `import { foo } from 'some-module'`, 'some-module')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should skip export declarations', () => {
const node = findStringLiteral(project, `export { foo } from 'some-module'`, 'some-module')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should skip logger calls', () => {
const node = findStringLiteral(project, `logger.info('测试日志')`, '测试日志')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should skip console calls', () => {
const node = findStringLiteral(project, `console.log('测试日志')`, '测试日志')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should skip t() translation function calls', () => {
const node = findStringLiteral(project, `t('common.save')`, 'common.save')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should skip type alias declarations', () => {
const node = findStringLiteral(project, `type Status = '成功' | '失败'`, '成功')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should skip interface declarations', () => {
const node = findStringLiteral(project, `interface Foo { status: '成功' }`, '成功')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should skip enum members', () => {
const node = findStringLiteral(project, `enum Status { Success = '成功' }`, '成功')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should skip language/locale variable declarations', () => {
const node = findStringLiteral(project, `const languageOptions = ['中文', 'English']`, '中文')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(true)
})
it('should NOT skip regular string literals', () => {
const node = findStringLiteral(project, `const message = '测试消息'`, '测试消息')
expect(node).toBeDefined()
expect(shouldSkipNode(node!)).toBe(false)
})
})
describe('isInCodeContext', () => {
let project: Project
beforeEach(() => {
project = createTestProject()
})
it('should detect tagged template expressions with css tag', () => {
const node = findTemplateLiteral(project, 'const style = css`color: red;`')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should detect tagged template expressions with styled tag', () => {
const node = findTemplateLiteral(project, 'const Button = styled.button`padding: 10px;`')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should detect CSS variable names', () => {
const node = findStringLiteral(project, `const customStyle = 'color: blue'`, 'color: blue')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should detect code variable names', () => {
const node = findStringLiteral(project, `const pythonCode = 'print("hello")'`, 'print("hello")')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should detect CSS property assignments', () => {
const node = findStringLiteral(project, `const obj = { style: 'color: red' }`, 'color: red')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should detect code property assignments', () => {
const node = findStringLiteral(project, `const obj = { script: 'console.log(1)' }`, 'console.log(1)')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should detect JSX style attributes', () => {
const node = findStringLiteral(project, `<div style={'color: red'} />`, 'color: red')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should detect executeJavaScript calls', () => {
const node = findStringLiteral(project, `webview.executeJavaScript('document.title')`, 'document.title')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should detect executeJavaScript with string concatenation', () => {
const node = findStringLiteral(project, `webview.executeJavaScript('var x = ' + value + ';')`, 'var x = ')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(true)
})
it('should NOT detect regular strings', () => {
const node = findStringLiteral(project, `const message = '普通消息'`, '普通消息')
expect(node).toBeDefined()
expect(isInCodeContext(node!)).toBe(false)
})
})
})