Files
cherry-studio/src/main/utils/__tests__/file.test.ts
T
自由的世界人 27af64f2bd fix: change jschardet to chardet (#8577)
* fix: change jschardet to chardet

* Update file.test.ts

* fix: error

* fix: test fail

* fix: test error

* Update file.test.ts

* fix: optimize details

* Update file.test.ts

* Update file.ts

* Update file.ts

* Update file.test.ts
2025-07-29 17:27:36 +08:00

484 lines
17 KiB
TypeScript

import * as fs from 'node:fs'
import * as fsPromises from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { FileTypes } from '@types'
import chardet from 'chardet'
import iconv from 'iconv-lite'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readTextFileWithAutoEncoding } from '../file'
import {
getAllFiles,
getAppConfigDir,
getConfigDir,
getFilesDir,
getFileType,
getTempDir,
isPathInside,
untildify
} from '../file'
// Mock dependencies
vi.mock('node:fs')
vi.mock('node:fs/promises')
vi.mock('node:os')
vi.mock('node:path')
vi.mock('uuid', () => ({
v4: () => 'mock-uuid'
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn((key) => {
if (key === 'temp') return '/mock/temp'
if (key === 'userData') return '/mock/userData'
return '/mock/unknown'
})
}
}))
describe('file', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock path.extname
vi.mocked(path.extname).mockImplementation((file) => {
const parts = file.split('.')
return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''
})
// Mock path.basename
vi.mocked(path.basename).mockImplementation((file) => {
const parts = file.split('/')
return parts[parts.length - 1]
})
// Mock path.join
vi.mocked(path.join).mockImplementation((...args) => args.join('/'))
// Mock os.homedir
vi.mocked(os.homedir).mockReturnValue('/mock/home')
})
afterEach(() => {
vi.resetAllMocks()
})
describe('getFileType', () => {
it('should return IMAGE for image extensions', () => {
expect(getFileType('.jpg')).toBe(FileTypes.IMAGE)
expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE)
expect(getFileType('.png')).toBe(FileTypes.IMAGE)
expect(getFileType('.gif')).toBe(FileTypes.IMAGE)
expect(getFileType('.webp')).toBe(FileTypes.IMAGE)
expect(getFileType('.bmp')).toBe(FileTypes.IMAGE)
})
it('should return VIDEO for video extensions', () => {
expect(getFileType('.mp4')).toBe(FileTypes.VIDEO)
expect(getFileType('.avi')).toBe(FileTypes.VIDEO)
expect(getFileType('.mov')).toBe(FileTypes.VIDEO)
expect(getFileType('.mkv')).toBe(FileTypes.VIDEO)
expect(getFileType('.flv')).toBe(FileTypes.VIDEO)
})
it('should return AUDIO for audio extensions', () => {
expect(getFileType('.mp3')).toBe(FileTypes.AUDIO)
expect(getFileType('.wav')).toBe(FileTypes.AUDIO)
expect(getFileType('.ogg')).toBe(FileTypes.AUDIO)
expect(getFileType('.flac')).toBe(FileTypes.AUDIO)
expect(getFileType('.aac')).toBe(FileTypes.AUDIO)
})
it('should return TEXT for text extensions', () => {
expect(getFileType('.txt')).toBe(FileTypes.TEXT)
expect(getFileType('.md')).toBe(FileTypes.TEXT)
expect(getFileType('.html')).toBe(FileTypes.TEXT)
expect(getFileType('.json')).toBe(FileTypes.TEXT)
expect(getFileType('.js')).toBe(FileTypes.TEXT)
expect(getFileType('.ts')).toBe(FileTypes.TEXT)
expect(getFileType('.css')).toBe(FileTypes.TEXT)
expect(getFileType('.java')).toBe(FileTypes.TEXT)
expect(getFileType('.py')).toBe(FileTypes.TEXT)
})
it('should return DOCUMENT for document extensions', () => {
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.doc')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)
})
it('should return OTHER for unknown extensions', () => {
expect(getFileType('.unknown')).toBe(FileTypes.OTHER)
expect(getFileType('')).toBe(FileTypes.OTHER)
expect(getFileType('.')).toBe(FileTypes.OTHER)
expect(getFileType('...')).toBe(FileTypes.OTHER)
expect(getFileType('.123')).toBe(FileTypes.OTHER)
})
it('should handle case-insensitive extensions', () => {
expect(getFileType('.JPG')).toBe(FileTypes.IMAGE)
expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO)
expect(getFileType('.HtMl')).toBe(FileTypes.TEXT)
expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT)
})
it('should handle extensions without leading dot', () => {
expect(getFileType('jpg')).toBe(FileTypes.OTHER)
expect(getFileType('pdf')).toBe(FileTypes.OTHER)
expect(getFileType('mp3')).toBe(FileTypes.OTHER)
})
it('should handle extreme cases', () => {
expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER)
expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER)
expect(getFileType('.文件')).toBe(FileTypes.OTHER)
expect(getFileType('.файл')).toBe(FileTypes.OTHER)
})
})
describe('getAllFiles', () => {
it('should return all valid files recursively', () => {
// Mock file system
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => {
if (dirPath === '/test') {
return ['file1.txt', 'file2.pdf', 'subdir']
} else if (dirPath === '/test/subdir') {
return ['file3.md', 'file4.docx']
}
return []
})
vi.mocked(fs.statSync).mockImplementation((filePath) => {
const isDir = String(filePath).endsWith('subdir')
return {
isDirectory: () => isDir,
size: 1024
} as fs.Stats
})
const result = getAllFiles('/test')
expect(result).toHaveLength(4)
expect(result[0].id).toBe('mock-uuid')
expect(result[0].name).toBe('file1.txt')
expect(result[0].type).toBe(FileTypes.TEXT)
expect(result[1].name).toBe('file2.pdf')
expect(result[1].type).toBe(FileTypes.DOCUMENT)
})
it('should skip hidden files', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt'])
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false,
size: 1024
} as fs.Stats)
const result = getAllFiles('/test')
expect(result).toHaveLength(1)
expect(result[0].name).toBe('visible.txt')
})
it('should skip unsupported file types', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf'])
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false,
size: 1024
} as fs.Stats)
const result = getAllFiles('/test')
// Should only include document.pdf as the others are excluded types
expect(result).toHaveLength(1)
expect(result[0].name).toBe('document.pdf')
expect(result[0].type).toBe(FileTypes.DOCUMENT)
})
it('should return empty array for empty directory', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue([])
const result = getAllFiles('/empty')
expect(result).toHaveLength(0)
})
it('should handle file system errors', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
throw new Error('Directory not found')
})
// Since the function doesn't have error handling, we expect it to propagate
expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found')
})
})
describe('getTempDir', () => {
it('should return correct temp directory path', () => {
const tempDir = getTempDir()
expect(tempDir).toBe('/mock/temp/CherryStudio')
})
})
describe('getFilesDir', () => {
it('should return correct files directory path', () => {
const filesDir = getFilesDir()
expect(filesDir).toBe('/mock/userData/Data/Files')
})
})
describe('getConfigDir', () => {
it('should return correct config directory path', () => {
const configDir = getConfigDir()
expect(configDir).toBe('/mock/home/.cherrystudio/config')
})
})
describe('getAppConfigDir', () => {
it('should return correct app config directory path', () => {
const appConfigDir = getAppConfigDir('test-app')
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app')
})
it('should handle empty app name', () => {
const appConfigDir = getAppConfigDir('')
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
})
})
describe('readTextFileWithAutoEncoding', () => {
const mockFilePath = '/path/to/mock/file.txt'
it('should read file with auto encoding', async () => {
const content = '这是一段GB18030编码的测试内容'
const buffer = iconv.encode(content, 'GB18030')
// 模拟文件读取和编码检测
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030')
const result = await readTextFileWithAutoEncoding(mockFilePath)
expect(result).toBe(content)
})
it('should try to fix bad detected encoding', async () => {
const content = '这是一段UTF-8编码的测试内容'
const buffer = iconv.encode(content, 'UTF-8')
// 模拟文件读取
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030')
const result = await readTextFileWithAutoEncoding(mockFilePath)
expect(result).toBe(content)
})
})
describe('untildify', () => {
it('should replace ~ with home directory for paths starting with ~', () => {
const mockHome = '/mock/home'
expect(untildify('~')).toBe(mockHome)
expect(untildify('~/Documents')).toBe('/mock/home/Documents')
expect(untildify('~\\Documents')).toBe('/mock/home\\Documents')
expect(untildify('~/Documents/file.txt')).toBe('/mock/home/Documents/file.txt')
expect(untildify('~\\Documents\\file.txt')).toBe('/mock/home\\Documents\\file.txt')
})
it('should not replace ~ when not at the beginning', () => {
expect(untildify('folder/~/file')).toBe('folder/~/file')
expect(untildify('/home/user/~')).toBe('/home/user/~')
expect(untildify('Documents/~backup')).toBe('Documents/~backup')
})
it('should not replace ~ when not followed by path separator or end of string', () => {
expect(untildify('~abc')).toBe('~abc')
expect(untildify('~user')).toBe('~user')
expect(untildify('~file.txt')).toBe('~file.txt')
})
it('should handle paths that do not start with ~', () => {
expect(untildify('/absolute/path')).toBe('/absolute/path')
expect(untildify('./relative/path')).toBe('./relative/path')
expect(untildify('../parent/path')).toBe('../parent/path')
expect(untildify('relative/path')).toBe('relative/path')
expect(untildify('C:\\Windows\\System32')).toBe('C:\\Windows\\System32')
})
it('should handle edge cases', () => {
expect(untildify('')).toBe('')
expect(untildify(' ')).toBe(' ')
expect(untildify('~/')).toBe('/mock/home/')
expect(untildify('~\\')).toBe('/mock/home\\')
})
it('should handle special characters and unicode', () => {
expect(untildify('~/文档')).toBe('/mock/home/文档')
expect(untildify('~/папка')).toBe('/mock/home/папка')
expect(untildify('~/folder with spaces')).toBe('/mock/home/folder with spaces')
expect(untildify('~/folder-with-dashes')).toBe('/mock/home/folder-with-dashes')
expect(untildify('~/folder_with_underscores')).toBe('/mock/home/folder_with_underscores')
})
})
describe('isPathInside', () => {
beforeEach(() => {
// Mock path.resolve to simulate path resolution
vi.mocked(path.resolve).mockImplementation((...args) => {
const joined = args.join('/')
return joined.startsWith('/') ? joined : `/${joined}`
})
// Mock path.normalize to simulate path normalization
vi.mocked(path.normalize).mockImplementation((p) => p.replace(/\/+/g, '/'))
// Mock path.relative to calculate relative paths
vi.mocked(path.relative).mockImplementation((from, to) => {
// Simple mock implementation for testing
const fromParts = from.split('/').filter((p) => p)
const toParts = to.split('/').filter((p) => p)
// Find common prefix
let i = 0
while (i < fromParts.length && i < toParts.length && fromParts[i] === toParts[i]) {
i++
}
// Calculate relative path
const upLevels = fromParts.length - i
const downPath = toParts.slice(i)
if (upLevels === 0 && downPath.length === 0) {
return ''
}
const result = ['..'.repeat(upLevels), ...downPath].filter((p) => p).join('/')
return result || '.'
})
// Mock path.isAbsolute
vi.mocked(path.isAbsolute).mockImplementation((p) => p.startsWith('/'))
})
describe('basic parent-child relationships', () => {
it('should return true when child is inside parent', () => {
expect(isPathInside('/root/test/child', '/root/test')).toBe(true)
expect(isPathInside('/root/test/deep/child', '/root/test')).toBe(true)
expect(isPathInside('child/deep', 'child')).toBe(true)
})
it('should return false when child is not inside parent', () => {
expect(isPathInside('/root/test', '/root/test/child')).toBe(false)
expect(isPathInside('/root/other', '/root/test')).toBe(false)
expect(isPathInside('/different/path', '/root/test')).toBe(false)
expect(isPathInside('child', 'child/deep')).toBe(false)
})
it('should return true when paths are the same', () => {
expect(isPathInside('/root/test', '/root/test')).toBe(true)
expect(isPathInside('child', 'child')).toBe(true)
})
})
describe('edge cases that startsWith cannot handle', () => {
it('should correctly distinguish similar path names', () => {
// The problematic case mentioned by user
expect(isPathInside('/root/test aaa', '/root/test')).toBe(false)
expect(isPathInside('/root/test', '/root/test aaa')).toBe(false)
// More similar cases
expect(isPathInside('/home/user-data', '/home/user')).toBe(false)
expect(isPathInside('/home/user', '/home/user-data')).toBe(false)
expect(isPathInside('/var/log-backup', '/var/log')).toBe(false)
})
it('should handle paths with spaces correctly', () => {
expect(isPathInside('/path with spaces/child', '/path with spaces')).toBe(true)
expect(isPathInside('/path with spaces', '/path with spaces/child')).toBe(false)
})
it('should handle Windows-style paths', () => {
// Mock for Windows paths
vi.mocked(path.resolve).mockImplementation((...args) => {
const joined = args.join('\\').replace(/\//g, '\\')
return joined.match(/^[A-Z]:/) ? joined : `C:${joined}`
})
vi.mocked(path.normalize).mockImplementation((p) => p.replace(/\\+/g, '\\'))
// Mock path.relative for Windows paths
vi.mocked(path.relative).mockImplementation((from, to) => {
const fromParts = from.split('\\').filter((p) => p && p !== 'C:')
const toParts = to.split('\\').filter((p) => p && p !== 'C:')
// Find common prefix
let i = 0
while (i < fromParts.length && i < toParts.length && fromParts[i] === toParts[i]) {
i++
}
// Calculate relative path
const upLevels = fromParts.length - i
const downPath = toParts.slice(i)
if (upLevels === 0 && downPath.length === 0) {
return ''
}
const upPath = Array(upLevels).fill('..').join('\\')
const result = [upPath, ...downPath].filter((p) => p).join('\\')
return result || '.'
})
expect(isPathInside('C:\\Users\\test\\child', 'C:\\Users\\test')).toBe(true)
expect(isPathInside('C:\\Users\\test aaa', 'C:\\Users\\test')).toBe(false)
})
})
describe('error handling', () => {
it('should return false when path operations throw errors', () => {
vi.mocked(path.resolve).mockImplementation(() => {
throw new Error('Path resolution failed')
})
expect(isPathInside('/any/path', '/any/parent')).toBe(false)
})
})
describe('comparison with startsWith behavior', () => {
const testCases: [string, string, boolean, boolean][] = [
['/root/test aaa', '/root/test', false, true], // isPathInside vs startsWith
['/root/test', '/root/test aaa', false, false],
['/root/test/child', '/root/test', true, true],
['/home/user-data', '/home/user', false, true]
]
it.each(testCases)(
'should correctly handle %s vs %s',
(child: string, parent: string, expectedIsPathInside: boolean, expectedStartsWith: boolean) => {
const isPathInsideResult = isPathInside(child, parent)
const startsWithResult = child.startsWith(parent)
expect(isPathInsideResult).toBe(expectedIsPathInside)
expect(startsWithResult).toBe(expectedStartsWith)
// Verify that isPathInside gives different (correct) result in problematic cases
if (expectedIsPathInside !== expectedStartsWith) {
expect(isPathInsideResult).not.toBe(startsWithResult)
}
}
)
})
})
})