【调整】微信公众号图标调整

This commit is contained in:
chenzhihua
2025-06-10 16:35:11 +08:00
parent c5167b374e
commit d8f4ee0e80
326 changed files with 7428 additions and 1851 deletions

View File

@@ -0,0 +1,2 @@
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
.specstory/**

1
frontend/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

51
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
.turbo
pnpm-lock.yaml
.specstory/**、
.sync-log
.sync-log/**
.sync-git
.sync-git/**
.cursor/rules

View File

@@ -19,7 +19,7 @@ export interface ApiProjectType {
// $t('t_0_1747886301644')
export const MessagePushConfig = {
mail: { name: $t('t_68_1745289354676'), type: 'mail' },
wecom: { name: $t('t_33_1746773350932'), type: 'wecom' },
workwx: { name: $t('t_33_1746773350932'), type: 'workwx' },
dingtalk: { name: $t('t_32_1746773348993'), type: 'dingtalk' },
feishu: { name: $t('t_34_1746773350153'), type: 'feishu' },
webhook: { name: 'WebHook', type: 'webhook' },

View File

@@ -87,14 +87,14 @@ export default defineConfig({
// remotePath: '/www/allinssl/frontend',
// clearRemote: true,
// },
{
host: '192.168.69.167',
port: 22,
username: 'root',
password: 'www.bt.cn',
remotePath: '/www/allinssl/frontend',
clearRemote: true,
},
// {
// host: '192.168.69.167',
// port: 22,
// username: 'root',
// password: 'www.bt.cn',
// remotePath: '/www/allinssl/frontend',
// clearRemote: true,
// },
]),
// 项目同步git
pluginProjectSyncGit({
@@ -105,12 +105,12 @@ export default defineConfig({
// targetDir: 'allinssl-gitlab',
// discardChanges: true,
// },
// {
// repo: 'https://github.com/allinssl/allinssl.git',
// branch: '1.0.4',
// targetDir: 'allinssl-github',
// discardChanges: true,
// },
{
repo: 'https://github.com/allinssl/allinssl.git',
branch: '1.0.5',
targetDir: 'allinssl-github',
discardChanges: true,
},
],
localSync: [
{

View File

@@ -0,0 +1,69 @@
import { jest } from '@jest/globals'
import { TranslationAdapter } from '../src/translation/adapter/index.js'
import { AIBatchAdapter } from '../src/translation/adapter/aiBatchAdapter.js'
import { TraditionalApiAdapter } from '../src/translation/adapter/traditionalApiAdapter.js'
import { ZhipuAITranslator } from '../src/translation/ai/zhipuAI.js'
import * as api1 from '../src/translation/traditional/api1.js'
describe('翻译适配器测试', () => {
describe('TranslationAdapter基类', () => {
it('不应该允许直接实例化抽象基类', () => {
expect(() => new TranslationAdapter()).toThrow('翻译适配器:抽象类不能被直接实例化')
})
})
describe('AI批量翻译适配器', () => {
const adapter = new AIBatchAdapter()
const text = '你好'
const apiKey = 'test-key'
const languages = ['enUS', 'jaJP']
const maxRetries = 3
it('应该实现translate方法', () => {
expect(typeof adapter.translate).toBe('function')
})
it('应该正确处理翻译失败和重试机制', async () => {
const mockTranslate = jest
.spyOn(ZhipuAITranslator.prototype, 'translate')
.mockRejectedValueOnce(new Error('API调用失败'))
.mockResolvedValueOnce({
text,
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
})
const result = await adapter.translate(text, apiKey, languages, maxRetries)
expect(mockTranslate).toHaveBeenCalledTimes(2)
expect(result.translations.enUS).toBe('Hello')
expect(result.translations.jaJP).toBe('こんにちは')
})
})
describe('传统API翻译适配器', () => {
const adapter = new TraditionalApiAdapter(api1)
it('应该验证API模块的有效性', () => {
expect(() => new TraditionalApiAdapter()).toThrow('传统API适配器无效的API模块')
})
it('应该能够获取支持的语言列表', () => {
const supportedLanguages = adapter.getSupportedLanguages()
expect(Array.isArray(supportedLanguages)).toBe(true)
expect(supportedLanguages.length).toBeGreaterThan(0)
})
it('应该正确处理不支持的语言', async () => {
const text = '你好'
const apiKey = 'test-key'
const languages = ['invalidLang']
const maxRetries = 3
await expect(adapter.translate(text, apiKey, languages, maxRetries)).rejects.toThrow(
'传统API适配器不支持的目标语言',
)
})
})
})

View File

@@ -0,0 +1,120 @@
import { jest } from '@jest/globals'
import { promises as fs } from 'fs'
import path from 'path'
import { CacheManager } from '../src/cache/index.js'
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn(),
access: jest.fn(),
},
}))
describe('CacheManager', () => {
const cachePath = './test-cache.json'
let cacheManager
beforeEach(() => {
cacheManager = new CacheManager(cachePath)
})
afterEach(() => {
jest.clearAllMocks()
})
describe('initCache', () => {
it('应该正确初始化缓存', async () => {
const mockCacheData = {
test: {
text: 'test',
translations: { enUS: 'test' },
timestamp: '2023-01-01T00:00:00.000Z',
},
}
fs.access.mockResolvedValueOnce()
fs.readFile.mockResolvedValueOnce(JSON.stringify(mockCacheData))
await cacheManager.initCache()
expect(cacheManager.cache.get('test')).toEqual(mockCacheData.test)
})
it('处理缓存文件不存在的情况', async () => {
fs.access.mockRejectedValueOnce(new Error('文件不存在'))
await cacheManager.initCache()
expect(cacheManager.cache.size).toBe(0)
})
})
describe('getCachedTranslations', () => {
beforeEach(async () => {
cacheManager.cache.set('hello', {
text: 'hello',
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
timestamp: '2023-01-01T00:00:00.000Z',
})
})
it('应该返回缓存的翻译', async () => {
const texts = ['hello', 'world']
const languages = ['enUS', 'jaJP']
const { cached, uncached } = await cacheManager.getCachedTranslations(texts, languages)
expect(cached.hello).toBeDefined()
expect(uncached).toContain('world')
})
it('检查缓存项是否包含所有必要的语言', async () => {
const texts = ['hello']
const languages = ['enUS', 'jaJP', 'zhCN']
const { cached, uncached } = await cacheManager.getCachedTranslations(texts, languages)
expect(uncached).toContain('hello')
expect(Object.keys(cached)).toHaveLength(0)
})
})
describe('updateCache', () => {
it('应该正确更新缓存', async () => {
const texts = ['test']
const translations = [
{
text: 'test',
translations: {
enUS: 'Test',
jaJP: 'テスト',
},
},
]
const languages = ['enUS', 'jaJP']
await cacheManager.updateCache(texts, translations, languages)
const cached = cacheManager.cache.get('test')
expect(cached.translations.enUS).toBe('Test')
expect(cached.translations.jaJP).toBe('テスト')
expect(fs.writeFile).toHaveBeenCalled()
})
})
describe('cleanCache', () => {
it('应该删除无效的缓存项', async () => {
cacheManager.cache.set('valid', { text: 'valid' })
cacheManager.cache.set('invalid', { text: 'invalid' })
await cacheManager.cleanCache(['valid'])
expect(cacheManager.cache.has('valid')).toBe(true)
expect(cacheManager.cache.has('invalid')).toBe(false)
expect(fs.writeFile).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,133 @@
import { jest } from '@jest/globals'
import { promises as fs } from 'fs'
import path from 'path'
import { LogManager } from '../src/logManagement/index.js'
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
appendFile: jest.fn(),
readFile: jest.fn(),
readdir: jest.fn(),
stat: jest.fn(),
unlink: jest.fn(),
},
}))
describe('LogManager', () => {
const options = {
logPath: './test-logs',
errorLogFile: 'error.log',
infoLogFile: 'info.log',
}
let logManager
beforeEach(() => {
logManager = new LogManager(options)
jest.clearAllMocks()
})
describe('init', () => {
it('应该创建日志目录', async () => {
await logManager.init()
expect(fs.mkdir).toHaveBeenCalledWith(options.logPath, { recursive: true })
})
it('处理创建目录失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.mkdir.mockRejectedValueOnce(new Error('创建目录失败'))
await logManager.init()
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('logError', () => {
it('应该写入错误日志', async () => {
const error = new Error('测试错误')
await logManager.logError(error)
expect(fs.appendFile).toHaveBeenCalledWith(
expect.stringContaining('error.log'),
expect.stringContaining('测试错误'),
)
})
it('处理写入错误日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.appendFile.mockRejectedValueOnce(new Error('写入失败'))
await logManager.logError(new Error('测试错误'))
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('logInfo', () => {
it('应该写入信息日志', async () => {
const message = '测试信息'
await logManager.logInfo(message)
expect(fs.appendFile).toHaveBeenCalledWith(expect.stringContaining('info.log'), expect.stringContaining(message))
})
it('处理写入信息日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.appendFile.mockRejectedValueOnce(new Error('写入失败'))
await logManager.logInfo('测试信息')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('cleanLogs', () => {
it('应该删除过期的日志文件', async () => {
const now = Date.now()
const oldDate = now - 7 * 24 * 60 * 60 * 1000 - 1000 // 7天+1秒前
fs.readdir.mockResolvedValueOnce(['old.log', 'new.log'])
fs.stat.mockImplementation((path) => {
return Promise.resolve({
mtimeMs: path.includes('old') ? oldDate : now,
})
})
await logManager.cleanLogs(7)
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('old.log'))
expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining('new.log'))
})
it('处理清理日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.readdir.mockRejectedValueOnce(new Error('读取目录失败'))
await logManager.cleanLogs(7)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('getLogs', () => {
it('应该返回指定数量的日志行', async () => {
const logContent = '行1\n行2\n行3\n行4\n行5'
fs.readFile.mockResolvedValueOnce(logContent)
const lines = await logManager.getLogs('info', 3)
expect(lines).toHaveLength(3)
expect(lines[2]).toBe('行5')
})
it('处理读取日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.readFile.mockRejectedValueOnce(new Error('读取文件失败'))
const lines = await logManager.getLogs('error', 5)
expect(lines).toHaveLength(0)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
})

View File

@@ -0,0 +1,100 @@
import { Utils } from '../src/utils/index.js'
describe('Utils Extended Features', () => {
describe('chunkArray', () => {
it('应该正确分块数组', () => {
const array = [1, 2, 3, 4, 5, 6, 7]
const size = 3
const chunks = Utils.chunkArray(array, size)
expect(chunks).toHaveLength(3)
expect(chunks[0]).toEqual([1, 2, 3])
expect(chunks[1]).toEqual([4, 5, 6])
expect(chunks[2]).toEqual([7])
})
it('处理空数组', () => {
const chunks = Utils.chunkArray([], 2)
expect(chunks).toHaveLength(0)
})
})
describe('delay', () => {
it('应该延迟执行指定时间', async () => {
const start = Date.now()
await Utils.delay(100)
const duration = Date.now() - start
expect(duration).toBeGreaterThanOrEqual(100)
})
})
describe('extractChineseTexts', () => {
it('应该正确提取中文内容', () => {
const content = `
$t('你好世界')
$t("测试文本")
$t('Hello World')
`
const templateRegex = /\$t\(['"]([^'"]+)['"]\)/g
const texts = Utils.extractChineseTexts(content, templateRegex)
expect(texts.size).toBe(2)
expect(texts.has('你好世界')).toBe(true)
expect(texts.has('测试文本')).toBe(true)
expect(texts.has('Hello World')).toBe(false)
})
})
describe('mergeTranslations', () => {
it('应该正确合并翻译结果', () => {
const target = {
key1: 'old value 1',
key2: 'old value 2',
}
const source = {
key1: 'new value 1',
key3: 'new value 3',
}
const result = Utils.mergeTranslations(target, source)
expect(result).toEqual({
key1: 'new value 1',
key2: 'old value 2',
key3: 'new value 3',
})
})
})
describe('isValidLanguageCode', () => {
it('应该验证语言代码格式', () => {
expect(Utils.isValidLanguageCode('zhCN')).toBe(true)
expect(Utils.isValidLanguageCode('enUS')).toBe(true)
expect(Utils.isValidLanguageCode('zh-CN')).toBe(false)
expect(Utils.isValidLanguageCode('123')).toBe(false)
})
})
describe('formatError', () => {
it('应该正确格式化错误信息', () => {
const error = new Error('测试错误')
const formatted = Utils.formatError(error)
expect(formatted).toHaveProperty('message', '测试错误')
expect(formatted).toHaveProperty('stack')
expect(formatted).toHaveProperty('timestamp')
expect(new Date(formatted.timestamp)).toBeInstanceOf(Date)
})
})
describe('generateId', () => {
it('应该生成唯一的标识符', () => {
const id1 = Utils.generateId()
const id2 = Utils.generateId()
expect(id1).toMatch(/^translation_\d+$/)
expect(id2).toMatch(/^translation_\d+$/)
expect(id1).not.toBe(id2)
})
})
})

View File

@@ -0,0 +1,62 @@
import { Utils } from '../src/utils/index.js'
describe('Utils', () => {
describe('isChineseText', () => {
it('应该正确识别中文文本', () => {
expect(Utils.isChineseText('你好')).toBe(true)
expect(Utils.isChineseText('Hello')).toBe(false)
expect(Utils.isChineseText('Hello 你好')).toBe(true)
})
})
describe('validateConfig', () => {
it('应该验证配置对象', () => {
const validConfig = {
apiKey: { zhipuAI: 'test-key' },
languages: ['zhCN', 'enUS'],
concurrency: 10,
interval: 1000,
}
const errors = Utils.validateConfig(validConfig)
expect(errors).toHaveLength(0)
})
it('应该检测无效的配置', () => {
const invalidConfig = {
apiKey: 'invalid',
languages: ['invalid'],
concurrency: -1,
interval: 'invalid',
}
const errors = Utils.validateConfig(invalidConfig)
expect(errors.length).toBeGreaterThan(0)
})
})
describe('parseLanguageCode', () => {
it('应该正确解析语言代码', () => {
const result = Utils.parseLanguageCode('zhCN')
expect(result).toEqual({
language: 'zh',
region: 'CN',
})
})
})
describe('formatTranslations', () => {
it('应该正确格式化翻译结果', () => {
const translations = {
hello: ' Hello World ',
welcome: ' 欢迎 ',
}
const formatted = Utils.formatTranslations(translations)
expect(formatted).toEqual({
hello: 'Hello World',
welcome: '欢迎',
})
})
})
})

View File

@@ -0,0 +1,112 @@
import { jest } from '@jest/globals'
import axios from 'axios'
import { ZhipuAITranslator } from '../src/translation/ai/zhipuAI.js'
jest.mock('axios')
describe('ZhipuAITranslator', () => {
const apiKey = 'test-key'
let translator
beforeEach(() => {
translator = new ZhipuAITranslator(apiKey)
jest.clearAllMocks()
})
describe('generatePrompt', () => {
it('应该生成正确的提示词', () => {
const text = '你好'
const languages = ['enUS', 'jaJP']
const prompt = translator.generatePrompt(text, languages)
expect(prompt).toContain('请将以下文本翻译成')
expect(prompt).toContain('en-US, ja-JP')
expect(prompt).toContain(text)
})
})
describe('translate', () => {
const text = '你好'
const languages = ['enUS', 'jaJP']
it('应该成功调用API并返回翻译结果', async () => {
const mockResponse = {
data: {
choices: [
{
message: {
content: JSON.stringify({
'en-US': 'Hello',
'ja-JP': 'こんにちは',
}),
},
},
],
},
}
axios.post.mockResolvedValueOnce(mockResponse)
const result = await translator.translate(text, languages)
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/chatglm_turbo'),
expect.any(Object),
expect.objectContaining({
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}),
)
expect(result).toEqual({
text: '你好',
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
})
})
it('处理API调用失败的情况', async () => {
axios.post.mockRejectedValueOnce(new Error('API调用失败'))
await expect(translator.translate(text, languages)).rejects.toThrow('智谱AI翻译失败')
})
it('处理无效的API响应', async () => {
const mockResponse = {
data: {},
}
axios.post.mockResolvedValueOnce(mockResponse)
await expect(translator.translate(text, languages)).rejects.toThrow('无效的API响应')
})
})
describe('validateApiKey', () => {
it('应该成功验证有效的API密钥', async () => {
axios.get.mockResolvedValueOnce({})
const isValid = await translator.validateApiKey()
expect(isValid).toBe(true)
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('/validate'),
expect.objectContaining({
headers: {
Authorization: `Bearer ${apiKey}`,
},
}),
)
})
it('处理无效的API密钥', async () => {
axios.get.mockRejectedValueOnce(new Error('无效的密钥'))
const isValid = await translator.validateApiKey()
expect(isValid).toBe(false)
})
})
})

View File

@@ -0,0 +1,91 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# IDE files
.idea
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Vite
dist
dist-ssr
*.local
# Vitest
/coverage

View File

@@ -2,7 +2,7 @@
"name": "@baota/vite-plugin-turborepo-deploy",
"version": "3.1.0",
"description": "Vite plugin for automated Turborepo workspace build deployment, local file sync, and Git management.",
"main": "dist/index.js",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"type": "module",
@@ -15,9 +15,15 @@
},
"exports": {
".": {
"import": "./dist/index.es.js",
"require": "./dist/index.cjs.js",
"types": "./dist/index.d.ts"
"development": {
"import": "./src/index.ts",
"require": "./src/index.ts"
},
"default": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
},
"files": [
@@ -37,17 +43,21 @@
"node": ">=16"
},
"dependencies": {
"archiver": "^7.0.1",
"chalk": "^5.3.0",
"fs-extra": "^11.2.0",
"ora": "^8.0.1",
"picomatch": "^3.0.1",
"simple-git": "^3.22.0",
"yauzl": "^3.2.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/archiver": "^6.0.3",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.11.24",
"@types/picomatch": "^2.3.3",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"eslint": "^8.57.0",

View File

@@ -1,23 +1,23 @@
import type { LocalSyncConfig } from '../types';
import type { Logger } from "./logger";
import fs from 'fs-extra';
import path from 'path';
import picomatch from 'picomatch'; // For glob matching if not using regex directly
import os from "os";
import { exec as execCallback } from "child_process";
/**
* 本地文件同步模塊 - 跨平台實現
*
* 使用 Node.js 原生庫實現跨平台文件壓縮和解壓功能:
* - archiver: 跨平台壓縮庫,替代 Unix zip 命令
* - yauzl: 跨平台解壓庫,替代 Unix unzip 命令
* - fs-extra: 增強的文件系統操作
*
* 支持 Windows、Linux、macOS 等所有 Node.js 支持的平台
*/
// 使用Promise包装exec函数不依赖util.promisify
const exec = (command: string): Promise<{ stdout: string; stderr: string }> => {
return new Promise((resolve, reject) => {
execCallback(command, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
};
import type { LocalSyncConfig } from "../types";
import type { Logger } from "./logger";
import fs from "fs-extra";
import path from "path";
import picomatch from "picomatch"; // For glob matching if not using regex directly
import os from "os";
import archiver from "archiver";
import yauzl from "yauzl";
import { isSubdirectoryOf, analyzePathRelationship } from "./utils";
// 缓存已创建的临时压缩文件
interface CompressionCache {
@@ -60,66 +60,66 @@ async function createTempDir(): Promise<string> {
}
/**
* 生成排除选项字符串
* 生成排除模式數組,適配 archiver 庫的 ignore 選項
* @param config 同步配置
* @param sourcePath 源路径
* @param targetPath 目标路径
* @param tempDir 临时目录
* @returns 排除选项字符串
* @returns 排除模式數組
*/
function generateExcludeOptions(
function generateExcludePatterns(
config: LocalSyncConfig,
sourcePath: string,
targetPath: string,
tempDir: string,
): string {
let excludeOptions = "";
): string[] {
const excludePatterns: string[] = [];
// 理排除目
// 理排除目
if (config.excludeDirs && config.excludeDirs.length > 0) {
const excludeDirsFormatted = config.excludeDirs
.map((dir) => {
// 移除通配符,获取基本目录名
const baseDirName = dir.replace(/^\*\*\//, "");
return `-x "*${baseDirName}*"`;
})
.join(" ");
excludeOptions += ` ${excludeDirsFormatted}`;
config.excludeDirs.forEach((dir) => {
// 移除通配符前綴,轉換為 glob 模式
const baseDirName = dir.replace(/^\*\*\//, "");
excludePatterns.push(`**/${baseDirName}/**`);
excludePatterns.push(`${baseDirName}/**`);
});
}
// 理排除文件
// 理排除文件
if (config.excludeFiles && config.excludeFiles.length > 0) {
const excludeFilesFormatted = config.excludeFiles
.map((file) => {
return `-x "*${file.replace(/^\*\*\//, "")}*"`;
})
.join(" ");
excludeOptions += ` ${excludeFilesFormatted}`;
config.excludeFiles.forEach((file) => {
const baseFileName = file.replace(/^\*\*\//, "");
excludePatterns.push(`**/${baseFileName}`);
excludePatterns.push(`${baseFileName}`);
});
}
// 理正排除
// 理正排除
if (config.exclude && config.exclude.length > 0) {
const excludeRegexFormatted = config.exclude
.map((pattern) => {
return `-x "*${pattern}*"`;
})
.join(" ");
excludeOptions += ` ${excludeRegexFormatted}`;
config.exclude.forEach((pattern) => {
// 將正則模式轉換為 glob 模式
excludePatterns.push(`**/*${pattern}*`);
excludePatterns.push(`*${pattern}*`);
});
}
// 始排除目标路径,避免递归
// 始排除目標路徑,避免遞歸
const relativeTargetPath = path.relative(sourcePath, targetPath);
if (relativeTargetPath) {
excludeOptions += ` -x "*${relativeTargetPath}*"`;
if (relativeTargetPath && relativeTargetPath !== ".") {
excludePatterns.push(`${relativeTargetPath}/**`);
excludePatterns.push(`**/${relativeTargetPath}/**`);
}
// 排除所有.sync-git目录
excludeOptions += ` -x "*.sync-git*"`;
// 排除所有 .sync-git 目錄
excludePatterns.push("**/.sync-git/**");
excludePatterns.push(".sync-git/**");
// 排除临时目录
excludeOptions += ` -x "*${path.basename(tempDir)}*"`;
// 排除臨時目錄
const tempDirName = path.basename(tempDir);
excludePatterns.push(`**/${tempDirName}/**`);
excludePatterns.push(`${tempDirName}/**`);
return excludeOptions;
return excludePatterns;
}
/**
@@ -157,6 +157,100 @@ function cleanExpiredCache(): void {
}
}
/**
* 處理同步錯誤,提供具體的診斷信息和解決建議
* @param error 捕獲的錯誤
* @param sourcePath 源路徑
* @param targetPath 目標路徑
* @param config 同步配置
* @param logger 日誌記錄器
*/
function handleSyncError(
error: Error,
sourcePath: string,
targetPath: string,
config: LocalSyncConfig,
logger: Logger,
): void {
logger.error(`❌ 同步失敗: ${sourcePath} -> ${targetPath}`);
// 根據錯誤類型提供具體的診斷和建議
const errorMessage = error.message.toLowerCase();
if (
errorMessage.includes("cannot copy") &&
errorMessage.includes("subdirectory")
) {
logger.error(`🚨 檢測到自引用複製錯誤 - 這正是我們修復的問題!`);
logger.error(` 錯誤詳情: ${error.message}`);
logger.error(` 這表示路徑檢測邏輯可能仍有問題,請檢查:`);
logger.error(` 1. 源路徑: ${sourcePath}`);
logger.error(` 2. 目標路徑: ${targetPath}`);
logger.error(` 3. 路徑關係檢測是否正確工作`);
logger.error(` 💡 解決方案:`);
logger.error(` - 確保目標路徑不是源路徑的子目錄`);
logger.error(` - 或者使用相對路徑配置`);
logger.error(` - 檢查 excludeDirs 配置是否包含目標目錄`);
} else if (
errorMessage.includes("enoent") ||
errorMessage.includes("no such file")
) {
logger.error(`📁 文件或目錄不存在錯誤`);
logger.error(` 錯誤詳情: ${error.message}`);
logger.error(` 💡 解決方案:`);
logger.error(` - 檢查源路徑是否存在: ${sourcePath}`);
logger.error(` - 確保父目錄有寫入權限`);
logger.error(` - 檢查路徑中是否包含特殊字符`);
} else if (
errorMessage.includes("eacces") ||
errorMessage.includes("permission denied")
) {
logger.error(`🔒 權限錯誤`);
logger.error(` 錯誤詳情: ${error.message}`);
logger.error(` 💡 解決方案:`);
logger.error(` - 檢查目標目錄的寫入權限`);
logger.error(` - 確保沒有文件被其他程序占用`);
logger.error(` - 在 Windows 上可能需要以管理員身份運行`);
} else if (
errorMessage.includes("enospc") ||
errorMessage.includes("no space")
) {
logger.error(`💾 磁盤空間不足錯誤`);
logger.error(` 錯誤詳情: ${error.message}`);
logger.error(` 💡 解決方案:`);
logger.error(` - 清理磁盤空間`);
logger.error(` - 檢查目標磁盤的可用空間`);
} else if (
errorMessage.includes("emfile") ||
errorMessage.includes("too many open files")
) {
logger.error(`📂 文件句柄過多錯誤`);
logger.error(` 錯誤詳情: ${error.message}`);
logger.error(` 💡 解決方案:`);
logger.error(` - 增加系統文件句柄限制`);
logger.error(` - 檢查是否有文件泄漏`);
logger.error(` - 考慮使用 excludeDirs 減少處理的文件數量`);
} else {
logger.error(`❓ 未知錯誤`);
logger.error(` 錯誤詳情: ${error.message}`);
logger.error(` 💡 通用解決方案:`);
logger.error(` - 檢查網絡連接(如果涉及遠程路徑)`);
logger.error(` - 確保所有路徑都是有效的`);
logger.error(` - 嘗試減少同步的文件數量`);
}
// 提供配置建議
logger.error(`⚙️ 當前配置信息:`);
logger.error(` 模式: ${config.mode || "incremental"}`);
logger.error(` 清空目標: ${config.clearTarget || false}`);
logger.error(` 僅添加: ${config.addOnly || false}`);
logger.error(` 排除目錄數量: ${config.excludeDirs?.length || 0}`);
logger.error(` 排除文件數量: ${config.excludeFiles?.length || 0}`);
// 記錄完整的錯誤棧以便調試
logger.verbose(`完整錯誤棧: ${error.stack}`);
}
/**
* 使用压缩方式处理源目录到子目录的复制,支持缓存
* @param sourcePath 源路径
@@ -198,23 +292,26 @@ async function syncViaCompression(
try {
if (needToCreateZip) {
// 需要创建新的压缩文件
const excludeOptions = generateExcludeOptions(
const excludePatterns = generateExcludePatterns(
config,
sourcePath,
targetPath,
tempDir!,
);
// 压缩源目录内容到临时文件
// 使用跨平台壓縮函數
logger.info(`压缩源目录 ${sourcePath} 到临时文件 ${tempZipFile}...`);
const zipCmd = `cd "${sourcePath}" && zip -r "${tempZipFile}" .${excludeOptions}`;
logger.verbose(`执行命令: ${zipCmd}`);
await exec(zipCmd);
await createZipWithArchiver(
sourcePath,
tempZipFile,
excludePatterns,
logger,
);
// 将新创建的压缩文件加入缓存
compressionCache[cacheKey] = {
zipFile: tempZipFile,
excludeOptions: excludeOptions,
excludeOptions: excludePatterns.join(","),
expiry: Date.now() + CACHE_TTL,
};
logger.verbose(
@@ -229,11 +326,9 @@ async function syncViaCompression(
}
await fs.ensureDir(targetPath);
// 解压缩到目标目录
// 使用跨平台解壓函數
logger.info(`解压临时文件到目标目录 ${targetPath}...`);
const unzipCmd = `unzip -o "${tempZipFile}" -d "${targetPath}"`;
logger.verbose(`执行命令: ${unzipCmd}`);
await exec(unzipCmd);
await extractZipWithYauzl(tempZipFile, targetPath, logger);
logger.info(`成功通过压缩方案同步 ${sourcePath}${targetPath}`);
} catch (error: any) {
@@ -264,6 +359,207 @@ async function syncViaCompression(
}
}
/**
* 使用 archiver 庫創建跨平台壓縮文件
* @param sourcePath 源路徑
* @param targetZipFile 目標zip文件路徑
* @param excludePatterns 排除模式數組
* @param logger 日誌記錄器
* @returns Promise<void>
*/
async function createZipWithArchiver(
sourcePath: string,
targetZipFile: string,
excludePatterns: string[],
logger: Logger,
): Promise<void> {
return new Promise((resolve, reject) => {
// 確保目標目錄存在
fs.ensureDirSync(path.dirname(targetZipFile));
// 創建輸出流
const output = fs.createWriteStream(targetZipFile);
// 創建歸檔器實例,使用最高壓縮級別
const archive = archiver("zip", {
zlib: { level: 9 },
});
// 監聽輸出流事件
output.on("close", () => {
logger.info(`壓縮完成,總共 ${archive.pointer()} 字節`);
resolve();
});
output.on("error", (err) => {
logger.error(`輸出流錯誤: ${err.message}`);
reject(err);
});
// 監聽歸檔器錯誤事件
archive.on("error", (err) => {
logger.error(`壓縮過程錯誤: ${err.message}`);
reject(err);
});
// 監聽進度事件
archive.on("progress", (progress) => {
logger.verbose(
`壓縮進度: 已處理 ${progress.entries.processed}/${progress.entries.total} 個條目`,
);
});
// 將歸檔器輸出管道連接到文件
archive.pipe(output);
try {
// 添加目錄及其內容,使用排除規則
archive.glob("**/*", {
cwd: sourcePath,
ignore: excludePatterns,
dot: true, // 包含隱藏文件
});
logger.info(`開始壓縮 ${sourcePath}${targetZipFile}...`);
logger.verbose(`排除模式: ${excludePatterns.join(", ")}`);
// 完成歸檔器
archive.finalize();
} catch (error: any) {
logger.error(`壓縮設置錯誤: ${error.message}`);
reject(error);
}
});
}
/**
* 使用 yauzl 庫創建跨平台解壓文件
* @param zipFile 壓縮文件路徑
* @param targetPath 目標解壓路徑
* @param logger 日誌記錄器
* @returns Promise<void>
*/
async function extractZipWithYauzl(
zipFile: string,
targetPath: string,
logger: Logger,
): Promise<void> {
return new Promise((resolve, reject) => {
// 確保目標目錄存在
fs.ensureDirSync(targetPath);
let extractedCount = 0;
let totalEntries = 0;
// 打開 zip 文件
yauzl.open(zipFile, { lazyEntries: true }, (err, zipfile) => {
if (err) {
logger.error(`無法打開壓縮文件 ${zipFile}: ${err.message}`);
reject(err);
return;
}
if (!zipfile) {
const error = new Error("zipfile is undefined");
logger.error(`壓縮文件對象為空: ${zipFile}`);
reject(error);
return;
}
totalEntries = zipfile.entryCount;
logger.info(
`開始解壓 ${zipFile}${targetPath},共 ${totalEntries} 個條目`,
);
// 監聽條目事件
zipfile.on("entry", (entry) => {
const entryPath = entry.fileName;
const fullPath = path.join(targetPath, entryPath);
// 路徑安全檢查,防止目錄遍歷攻擊
const normalizedPath = path.normalize(fullPath);
if (!normalizedPath.startsWith(path.normalize(targetPath))) {
logger.error(`檢測到不安全的路徑: ${entryPath}`);
zipfile.readEntry();
return;
}
// 檢查是否為目錄
if (entryPath.endsWith("/")) {
// 創建目錄
fs.ensureDirSync(fullPath);
logger.verbose(`創建目錄: ${entryPath}`);
extractedCount++;
// 繼續讀取下一個條目
zipfile.readEntry();
} else {
// 提取文件
zipfile.openReadStream(entry, (err, readStream) => {
if (err) {
logger.error(`無法讀取文件 ${entryPath}: ${err.message}`);
reject(err);
return;
}
if (!readStream) {
logger.error(`讀取流為空: ${entryPath}`);
reject(new Error(`無法創建讀取流: ${entryPath}`));
return;
}
// 確保父目錄存在
fs.ensureDirSync(path.dirname(fullPath));
// 創建寫入流
const writeStream = fs.createWriteStream(fullPath);
// 處理流錯誤
readStream.on("error", (err) => {
logger.error(`讀取流錯誤 ${entryPath}: ${err.message}`);
reject(err);
});
writeStream.on("error", (err) => {
logger.error(`寫入流錯誤 ${entryPath}: ${err.message}`);
reject(err);
});
// 文件寫入完成
writeStream.on("close", () => {
extractedCount++;
logger.verbose(
`提取文件: ${entryPath} (${extractedCount}/${totalEntries})`,
);
// 繼續讀取下一個條目
zipfile.readEntry();
});
// 將讀取流管道連接到寫入流
readStream.pipe(writeStream);
});
}
});
// 監聽結束事件
zipfile.on("end", () => {
logger.info(`解壓完成,共提取 ${extractedCount} 個條目`);
resolve();
});
// 監聽錯誤事件
zipfile.on("error", (err) => {
logger.error(`解壓過程錯誤: ${err.message}`);
reject(err);
});
// 開始讀取第一個條目
zipfile.readEntry();
});
});
}
export async function performLocalSync(
configs: LocalSyncConfig[],
workspaceRoot: string,
@@ -298,9 +594,36 @@ export async function performLocalSync(
const targetPath = path.resolve(workspaceRoot, target);
// 检查目标路径是否是源路径的子目录或相同目录
const isSubdirectory =
targetPath.startsWith(sourcePath + path.sep) ||
targetPath === sourcePath;
// 使用工具函數進行路徑比較,確保跨平台兼容性
const pathAnalysis = analyzePathRelationship(targetPath, sourcePath);
const isSubdirectory = isSubdirectoryOf(targetPath, sourcePath);
// 添加详细的路径调试日誌輸出
logger.verbose(`路径正规化处理:`);
logger.verbose(
` 源路径: ${sourcePath} -> ${pathAnalysis.normalizedSource}`,
);
logger.verbose(
` 目标路径: ${targetPath} -> ${pathAnalysis.normalizedTarget}`,
);
logger.verbose(`子目录检测结果: ${isSubdirectory}`);
if (isSubdirectory) {
logger.verbose(`子目录检测详情:`);
logger.verbose(` startsWith 检查: ${pathAnalysis.startsWithCheck}`);
logger.verbose(` 相等检查: ${pathAnalysis.equalityCheck}`);
logger.verbose(` 路径分隔符: '${pathAnalysis.separator}'`);
}
// 配置驗證和用戶友好的錯誤處理
await validateAndWarnPathConfiguration(
config,
sourcePath,
targetPath,
isSubdirectory,
pathAnalysis,
logger,
);
logger.info(
`正在同步 ${sourcePath}${targetPath} (模式: ${config.mode || "incremental"})`,
@@ -441,10 +764,8 @@ export async function performLocalSync(
logger.info(`成功同步 ${config.source}${target}`);
} catch (error: any) {
logger.error(
`${sourcePath} 同步到 ${targetPath} 时出错: ${error.message}`,
error,
);
// 增強的錯誤處理,提供具體的診斷信息
handleSyncError(error, sourcePath, targetPath, config, logger);
// 软错误:继续执行其他任务
}
}
@@ -452,6 +773,77 @@ export async function performLocalSync(
logger.info("本地文件同步完成");
}
/**
* 驗證路徑配置並提供用戶友好的警告和建議
* @param config 同步配置
* @param sourcePath 源路徑
* @param targetPath 目標路徑
* @param isSubdirectory 是否為子目錄
* @param pathAnalysis 路徑分析結果
* @param logger 日誌記錄器
*/
async function validateAndWarnPathConfiguration(
config: LocalSyncConfig,
sourcePath: string,
targetPath: string,
isSubdirectory: boolean,
pathAnalysis: ReturnType<typeof analyzePathRelationship>,
logger: Logger,
): Promise<void> {
// 檢查相同路徑的情況
if (pathAnalysis.equalityCheck) {
logger.warn(`⚠️ 源路徑和目標路徑相同: ${sourcePath}`);
logger.warn(` 這可能表示配置錯誤,請檢查您的 localSync 配置`);
logger.warn(` 建議:修改 target 路徑以避免自我複製`);
return;
}
// 檢查子目錄情況的配置建議
if (isSubdirectory) {
logger.info(`🔍 檢測到目標路徑是源路徑的子目錄,將使用壓縮方案`);
// 針對不同模式提供建議
if (config.mode === "mirror") {
logger.warn(`⚠️ 鏡像模式 + 子目錄配置可能導致不必要的複雜性`);
logger.warn(` 建議:考慮使用 'copy' 或 'incremental' 模式`);
}
// 檢查是否缺少必要的排除配置
if (!config.excludeDirs || config.excludeDirs.length === 0) {
logger.warn(`⚠️ 子目錄同步時建議配置 excludeDirs 以避免無限遞歸`);
logger.warn(
` 建議:添加 excludeDirs: ['.sync-git', 'node_modules', '.git']`,
);
}
// 特別警告常見的錯誤模式
const relativePath = path.relative(sourcePath, targetPath);
if (relativePath.includes(".sync-git")) {
logger.info(`✅ 檢測到目標在 .sync-git 目錄中,這是推薦的配置`);
} else {
logger.warn(`⚠️ 目標路徑不在 .sync-git 目錄中: ${relativePath}`);
logger.warn(` 建議:將目標設置為 '.sync-git/your-target' 以保持組織性`);
}
}
// 檢查路徑格式問題
if (sourcePath.includes("\\") && targetPath.includes("/")) {
logger.warn(`⚠️ 檢測到混合路徑分隔符,已自動正規化處理`);
logger.info(` 原始: 源='${sourcePath}' 目標='${targetPath}'`);
logger.info(
` 正規化: 源='${pathAnalysis.normalizedSource}' 目標='${pathAnalysis.normalizedTarget}'`,
);
}
// 檢查潛在的性能問題
if (config.source === "/" && !config.excludeDirs?.includes("node_modules")) {
logger.warn(`⚠️ 從根目錄 '/' 同步時強烈建議排除 node_modules`);
logger.warn(
` 建議:添加 excludeDirs: ['node_modules', '.git', 'dist', 'build']`,
);
}
}
/**
* 递归获取目录中的所有文件路径
* @param dir 要扫描的目录
@@ -473,4 +865,4 @@ async function getAllFiles(dir: string): Promise<string[]> {
}
}
return results;
}
}

View File

@@ -127,4 +127,69 @@ export async function safeRemoveFile(filePath: string): Promise<void> {
}
throw error;
}
}
/**
* 正規化多個路徑,解決跨平台兼容性問題
* @param paths 需要正規化的路徑數組
* @returns 正規化後的路徑數組
*/
export function normalizePaths(...paths: string[]): string[] {
return paths.map((p) => path.normalize(p));
}
/**
* 檢查目標路徑是否是源路徑的子目錄或相同目錄
* 使用路徑正規化處理,確保跨平台兼容性
* @param targetPath 目標路徑
* @param sourcePath 源路徑
* @returns 如果目標路徑是源路徑的子目錄或相同目錄則返回 true
*/
export function isSubdirectoryOf(
targetPath: string,
sourcePath: string,
): boolean {
// 正規化路徑以確保跨平台兼容性
const normalizedTarget = path.normalize(targetPath);
const normalizedSource = path.normalize(sourcePath);
// 檢查是否為子目錄或相同目錄
return (
normalizedTarget.startsWith(normalizedSource + path.sep) ||
normalizedTarget === normalizedSource
);
}
/**
* 檢查路徑關係的詳細信息,便於調試
* @param targetPath 目標路徑
* @param sourcePath 源路徑
* @returns 包含檢查詳情的對象
*/
export function analyzePathRelationship(
targetPath: string,
sourcePath: string,
): {
isSubdirectory: boolean;
normalizedTarget: string;
normalizedSource: string;
startsWithCheck: boolean;
equalityCheck: boolean;
separator: string;
} {
const normalizedTarget = path.normalize(targetPath);
const normalizedSource = path.normalize(sourcePath);
const startsWithCheck = normalizedTarget.startsWith(
normalizedSource + path.sep,
);
const equalityCheck = normalizedTarget === normalizedSource;
return {
isSubdirectory: startsWithCheck || equalityCheck,
normalizedTarget,
normalizedSource,
startsWithCheck,
equalityCheck,
separator: path.sep,
};
}

View File

@@ -0,0 +1,276 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { performAutoCommit } from "../../src/core/autoCommitHandler";
import path from "path";
import fs from "fs-extra";
// 模拟依赖
vi.mock("simple-git", () => {
// 创建简单的模拟实现
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(true),
branchLocal: vi.fn().mockResolvedValue({ current: "develop" }),
log: vi.fn().mockResolvedValue({
all: [
{ hash: "1234567", message: "一般提交1" },
{ hash: "2345678", message: "一般提交2" },
{ hash: "3456789", message: "/** 提交分隔符 **/" },
{ hash: "4567890", message: "之前的提交" },
],
}),
status: vi
.fn()
.mockResolvedValue({ isClean: vi.fn().mockReturnValue(true) }),
add: vi.fn().mockResolvedValue(undefined),
commit: vi.fn().mockResolvedValue(undefined),
push: vi.fn().mockResolvedValue(undefined),
checkout: vi.fn().mockResolvedValue(undefined),
reset: vi.fn().mockResolvedValue(undefined),
};
return {
default: vi.fn().mockReturnValue(mockGit),
__esModule: true,
};
});
vi.mock("fs-extra", () => ({
existsSync: vi.fn().mockReturnValue(true),
__esModule: true,
}));
// 模拟日志记录器
const mockLogger = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
verbose: vi.fn(),
setLogLevel: vi.fn(),
};
describe("performAutoCommit", () => {
const mockViteRoot = "/test/root";
const mockSharedCommitMessagesHolder = { current: null as string[] | null };
beforeEach(() => {
vi.clearAllMocks();
});
it("应该处理单个项目的自动提交 (独立模块)", async () => {
const config = {
projects: [
{
targetDir: "services/api-gateway",
projectName: "api-gateway",
watchAuthor: "张三",
push: true,
},
],
enableSharedCommits: true,
insertSeparator: true,
};
await performAutoCommit(
config,
mockViteRoot,
mockLogger,
mockSharedCommitMessagesHolder,
);
// 验证日志和基本操作
expect(mockLogger.info).toHaveBeenCalledWith("开始自动提交操作...");
expect(mockLogger.info).toHaveBeenCalledWith("已重置共享提交信息缓冲区");
expect(mockLogger.info).toHaveBeenCalledWith(
"处理自动提交项目: api-gateway",
);
expect(mockLogger.info).toHaveBeenCalledWith("自动提交操作完成");
// 验证Git操作
const simpleGit = require("simple-git").default;
const mockGit = simpleGit();
expect(mockGit.log).toHaveBeenCalled();
expect(mockGit.commit).toHaveBeenCalledTimes(2); // 一次正常提交,一次分隔符
expect(mockGit.push).toHaveBeenCalledTimes(2); // 一次正常推送,一次分隔符推送
});
it("应该在禁用共享提交时不重置共享缓冲区", async () => {
const config = {
projects: [
{
targetDir: "services/api-gateway",
projectName: "api-gateway",
watchAuthor: "张三",
},
],
enableSharedCommits: false,
insertSeparator: true,
};
await performAutoCommit(
config,
mockViteRoot,
mockLogger,
mockSharedCommitMessagesHolder,
);
// 验证没有重置共享缓冲区
expect(mockLogger.info).not.toHaveBeenCalledWith(
"已重置共享提交信息缓冲区",
);
});
it("应该在项目不存在时跳过处理", async () => {
// 模拟文件不存在
vi.mocked(fs.existsSync).mockReturnValueOnce(false);
const config = {
projects: [
{
targetDir: "services/non-existent",
projectName: "non-existent",
watchAuthor: "张三",
},
],
};
await performAutoCommit(
config,
mockViteRoot,
mockLogger,
mockSharedCommitMessagesHolder,
);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("不存在,跳过此项目"),
);
// 验证没有进行Git操作
const simpleGit = require("simple-git").default;
expect(simpleGit).not.toHaveBeenCalled();
});
it("应该使用共享提交信息", async () => {
// 设置共享提交信息
mockSharedCommitMessagesHolder.current = ["[abcdef1] 共享的提交信息"];
const config = {
projects: [
{
targetDir: "services/consumer",
projectName: "consumer",
useSharedCommits: true,
push: true,
},
],
enableSharedCommits: true,
};
await performAutoCommit(
config,
mockViteRoot,
mockLogger,
mockSharedCommitMessagesHolder,
);
// 验证使用了共享提交信息
expect(mockLogger.info).toHaveBeenCalledWith("[consumer] 使用共享提交信息");
// 验证提交操作
const simpleGit = require("simple-git").default;
const mockGit = simpleGit();
expect(mockGit.commit).toHaveBeenCalled();
expect(mockGit.push).toHaveBeenCalled();
});
it("应该在没有提交时跳过", async () => {
// 模拟没有提交记录
const simpleGit = require("simple-git").default;
const mockGit = simpleGit();
vi.mocked(mockGit.log).mockResolvedValueOnce({ all: [] });
const config = {
projects: [
{
targetDir: "services/no-commits",
projectName: "no-commits",
watchAuthor: "张三",
},
],
};
await performAutoCommit(
config,
mockViteRoot,
mockLogger,
mockSharedCommitMessagesHolder,
);
// 验证没有进行提交操作
expect(mockGit.commit).not.toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(
"[no-commits] 没有要处理的新提交",
);
});
it("应该处理分支切换", async () => {
const config = {
projects: [
{
targetDir: "services/branch-test",
projectName: "branch-test",
watchAuthor: "张三",
branch: "feature/test",
},
],
};
// 模拟当前分支不是目标分支
const simpleGit = require("simple-git").default;
const mockGit = simpleGit();
vi.mocked(mockGit.branchLocal).mockResolvedValueOnce({ current: "main" });
await performAutoCommit(
config,
mockViteRoot,
mockLogger,
mockSharedCommitMessagesHolder,
);
// 验证分支切换
expect(mockGit.checkout).toHaveBeenCalledWith("feature/test");
expect(mockLogger.info).toHaveBeenCalledWith("切换到分支 feature/test...");
});
it("应该支持多项目并发处理", async () => {
const config = {
projects: [
{
targetDir: "services/api-gateway",
projectName: "api-gateway",
watchAuthor: "张三",
push: true,
},
{
targetDir: "services/user-service",
projectName: "user-service",
useSharedCommits: true,
push: true,
},
],
enableSharedCommits: true,
};
await performAutoCommit(
config,
mockViteRoot,
mockLogger,
mockSharedCommitMessagesHolder,
);
// 验证处理了两个项目
expect(mockLogger.info).toHaveBeenCalledWith(
"处理自动提交项目: api-gateway",
);
expect(mockLogger.info).toHaveBeenCalledWith(
"处理自动提交项目: user-service",
);
});
});

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { loadConfig, processTasks } from "../../src/core/config";
import path from "path";
// 模拟依赖
vi.mock("../../src/core/logger", () => ({
createLogger: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
verbose: vi.fn(),
setLogLevel: vi.fn(),
})),
}));
vi.mock("../../src/core/localSync", () => ({
performLocalSync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../src/core/gitHandler", () => ({
updateGitProjects: vi.fn().mockResolvedValue(undefined),
}));
// 导入模拟的模块
import { performLocalSync } from "../../src/core/localSync";
import { updateGitProjects } from "../../src/core/gitHandler";
describe("loadConfig", () => {
it("应该返回空对象当没有提供选项时", () => {
const config = loadConfig(undefined, "/test/root");
expect(config).toEqual({});
});
it("应该验证并返回有效的配置", () => {
const options = {
localSync: [{ source: "src", target: "dist" }],
gitProjects: [
{
repo: "https://github.com/example/repo.git",
branch: "main",
targetDir: "services/repo",
},
],
};
const config = loadConfig(options, "/test/root");
expect(config).toEqual(options);
});
it("应该在无效配置时抛出错误", () => {
const invalidOptions = {
localSync: [
{ source: "", target: "dist" }, // 无效的source
],
};
expect(() => loadConfig(invalidOptions, "/test/root")).toThrow();
});
});
describe("processTasks", () => {
const mockLogger = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
verbose: vi.fn(),
setLogLevel: vi.fn(),
};
const mockViteConfig = {
root: "/test/root",
} as any;
const mockSharedCommitMessagesHolder = { current: null as string[] | null };
beforeEach(() => {
vi.clearAllMocks();
});
it("应该按顺序执行配置的任务", async () => {
const config = {
localSync: [{ source: "src", target: "dist" }],
gitProjects: [
{
repo: "https://github.com/example/repo.git",
branch: "main",
targetDir: "services/repo",
},
],
taskOrder: ["localSync", "updateGitProjects"],
};
await processTasks(
config,
mockViteConfig,
mockLogger,
mockSharedCommitMessagesHolder,
);
expect(mockLogger.info).toHaveBeenCalledWith(
"执行任务顺序: localSync, updateGitProjects",
);
expect(mockLogger.info).toHaveBeenCalledWith("开始任务: localSync");
expect(performLocalSync).toHaveBeenCalledWith(
config.localSync,
mockViteConfig.root,
mockLogger,
);
expect(mockLogger.info).toHaveBeenCalledWith("任务 localSync 完成。");
expect(mockLogger.info).toHaveBeenCalledWith("开始任务: updateGitProjects");
expect(updateGitProjects).toHaveBeenCalledWith(
config.gitProjects,
mockViteConfig.root,
mockLogger,
mockSharedCommitMessagesHolder,
);
expect(mockLogger.info).toHaveBeenCalledWith(
"任务 updateGitProjects 完成。",
);
});
it("应该使用默认任务顺序当未指定时", async () => {
const config = {
localSync: [{ source: "src", target: "dist" }],
gitProjects: [
{
repo: "https://github.com/example/repo.git",
branch: "main",
targetDir: "services/repo",
},
],
// 未指定taskOrder
};
await processTasks(
config,
mockViteConfig,
mockLogger,
mockSharedCommitMessagesHolder,
);
// 默认顺序: localSync, updateGitProjects
expect(mockLogger.info).toHaveBeenCalledWith(
"执行任务顺序: localSync, updateGitProjects",
);
expect(performLocalSync).toHaveBeenCalled();
expect(updateGitProjects).toHaveBeenCalled();
});
it("应该处理任务错误并继续执行", async () => {
const config = {
localSync: [{ source: "src", target: "dist" }],
gitProjects: [
{
repo: "https://github.com/example/repo.git",
branch: "main",
targetDir: "services/repo",
},
],
taskOrder: ["localSync", "updateGitProjects"],
};
const error = new Error("测试错误");
(performLocalSync as any).mockRejectedValueOnce(error);
await processTasks(
config,
mockViteConfig,
mockLogger,
mockSharedCommitMessagesHolder,
);
expect(mockLogger.error).toHaveBeenCalledWith(
"任务 localSync 执行错误: 测试错误",
error,
);
expect(updateGitProjects).toHaveBeenCalled(); // 第二个任务仍然执行
});
it("应该在关键错误时中断流程", async () => {
const config = {
localSync: [{ source: "src", target: "dist" }],
gitProjects: [
{
repo: "https://github.com/example/repo.git",
branch: "main",
targetDir: "services/repo",
},
],
taskOrder: ["localSync", "updateGitProjects"],
};
const criticalError = new Error("关键错误") as Error & {
isCritical: boolean;
};
criticalError.isCritical = true;
(performLocalSync as any).mockRejectedValueOnce(criticalError);
await expect(
processTasks(
config,
mockViteConfig,
mockLogger,
mockSharedCommitMessagesHolder,
),
).rejects.toThrow("关键错误");
expect(updateGitProjects).not.toHaveBeenCalled(); // 第二个任务不应执行
});
});

View File

@@ -0,0 +1,270 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import path from "path";
import fs from "fs-extra";
import {
resolvePath,
createError,
formatDate,
isAbsolutePath,
safeRemoveFile,
isSubdirectoryOf,
normalizePaths,
analyzePathRelationship,
} from "../../src/core/utils";
// 创建临时测试文件的路径
const tmpDir = path.join(process.cwd(), "tests", "tmp");
const tmpFile = path.join(tmpDir, "test-file.txt");
// 确保临时目录存在
beforeAll(async () => {
await fs.ensureDir(tmpDir);
});
// 测试后清理
afterAll(async () => {
await fs.remove(tmpDir);
});
describe("resolvePath", () => {
it("应该正确解析相对路径", () => {
const viteRoot = "/test/root";
const relativePath = "src/components";
const expected = path.resolve(viteRoot, relativePath);
expect(resolvePath(viteRoot, relativePath)).toBe(expected);
});
it("应该保留绝对路径", () => {
const viteRoot = "/test/root";
const absolutePath = "/absolute/path";
expect(resolvePath(viteRoot, absolutePath)).toBe(absolutePath);
});
});
describe("createError", () => {
it("应该创建非关键错误", () => {
const message = "Test error";
const error = createError(message);
expect(error.message).toBe(message);
expect(error.isCritical).toBe(false);
expect(error.timestamp).toBeInstanceOf(Date);
});
it("应该创建关键错误", () => {
const message = "Critical error";
const error = createError(message, true);
expect(error.message).toBe(message);
expect(error.isCritical).toBe(true);
expect(error.timestamp).toBeInstanceOf(Date);
});
});
describe("formatDate", () => {
it("应该正确格式化日期", () => {
const date = new Date(2023, 0, 1); // 2023-01-01
expect(formatDate(date)).toBe("2023-01-01");
const date2 = new Date(2023, 11, 31); // 2023-12-31
expect(formatDate(date2)).toBe("2023-12-31");
});
it("应该在月份和日期前补零", () => {
const date = new Date(2023, 0, 1); // 2023-01-01
expect(formatDate(date)).toBe("2023-01-01");
const date2 = new Date(2023, 8, 9); // 2023-09-09
expect(formatDate(date2)).toBe("2023-09-09");
});
});
describe("isAbsolutePath", () => {
it("应该识别绝对路径", () => {
const absolutePath = path.resolve("/absolute/path");
expect(isAbsolutePath(absolutePath)).toBe(true);
});
it("应该识别相对路径", () => {
expect(isAbsolutePath("relative/path")).toBe(false);
expect(isAbsolutePath("./relative/path")).toBe(false);
});
});
describe("safeRemoveFile", () => {
it("应该删除存在的文件", async () => {
// 创建测试文件
await fs.writeFile(tmpFile, "test content");
expect(fs.existsSync(tmpFile)).toBe(true);
// 删除文件
await safeRemoveFile(tmpFile);
expect(fs.existsSync(tmpFile)).toBe(false);
});
it("应该安全地处理不存在的文件", async () => {
const nonExistentFile = path.join(tmpDir, "non-existent.txt");
// 确保文件不存在
if (fs.existsSync(nonExistentFile)) {
await fs.remove(nonExistentFile);
}
// 不应抛出错误
await expect(safeRemoveFile(nonExistentFile)).resolves.not.toThrow();
});
});
describe("normalizePaths", () => {
it("应该正规化单个路径", () => {
const paths = normalizePaths("C:/test\\path//file.txt");
expect(paths).toHaveLength(1);
expect(paths[0]).toBe(path.normalize("C:/test\\path//file.txt"));
});
it("应该正规化多个路径", () => {
const input = ["C:/test\\path1", "D:\\test/path2", "./relative\\path"];
const result = normalizePaths(...input);
expect(result).toHaveLength(3);
expect(result[0]).toBe(path.normalize("C:/test\\path1"));
expect(result[1]).toBe(path.normalize("D:\\test/path2"));
expect(result[2]).toBe(path.normalize("./relative\\path"));
});
it("应该处理空路径数组", () => {
const result = normalizePaths();
expect(result).toHaveLength(0);
});
});
describe("isSubdirectoryOf", () => {
describe("Unix/Linux 路径格式", () => {
it("应该识别子目录", () => {
expect(isSubdirectoryOf("/root/sub", "/root")).toBe(true);
expect(isSubdirectoryOf("/root/sub/deep", "/root")).toBe(true);
expect(isSubdirectoryOf("/root/sub/deep/file.txt", "/root")).toBe(true);
});
it("应该识别相同目录", () => {
expect(isSubdirectoryOf("/root", "/root")).toBe(true);
expect(isSubdirectoryOf("/root/", "/root")).toBe(true);
expect(isSubdirectoryOf("/root", "/root/")).toBe(true);
});
it("应该拒绝非子目录", () => {
expect(isSubdirectoryOf("/other", "/root")).toBe(false);
expect(isSubdirectoryOf("/root-similar", "/root")).toBe(false);
expect(isSubdirectoryOf("/", "/root")).toBe(false);
});
});
describe("Windows 路径格式", () => {
it("应该识别子目录", () => {
expect(isSubdirectoryOf("C:\\root\\sub", "C:\\root")).toBe(true);
expect(isSubdirectoryOf("C:\\root\\sub\\deep", "C:\\root")).toBe(true);
});
it("应该识别相同目录", () => {
expect(isSubdirectoryOf("C:\\root", "C:\\root")).toBe(true);
expect(isSubdirectoryOf("C:\\root\\", "C:\\root")).toBe(true);
});
it("应该拒绝非子目录", () => {
expect(isSubdirectoryOf("D:\\root", "C:\\root")).toBe(false);
expect(isSubdirectoryOf("C:\\other", "C:\\root")).toBe(false);
});
});
describe("混合路径分隔符處理", () => {
it("应该处理混合的路径分隔符", () => {
// 這是實際錯誤場景:正規化應該統一分隔符
expect(isSubdirectoryOf("C:/root\\.sync-git\\target", "C:\\root")).toBe(
true,
);
expect(isSubdirectoryOf("C:\\root/.sync-git/target", "C:/root")).toBe(
true,
);
});
it("应该处理尾随分隔符", () => {
expect(isSubdirectoryOf("C:/root/sub/", "C:/root/")).toBe(true);
expect(isSubdirectoryOf("C:\\root\\sub\\", "C:\\root\\")).toBe(true);
});
});
describe("邊界條件測試", () => {
it("应该处理根目录", () => {
if (process.platform === "win32") {
expect(isSubdirectoryOf("C:\\", "C:\\")).toBe(true);
expect(isSubdirectoryOf("C:\\sub", "C:\\")).toBe(true);
} else {
expect(isSubdirectoryOf("/", "/")).toBe(true);
expect(isSubdirectoryOf("/sub", "/")).toBe(true);
}
});
it("应该处理相对路径", () => {
expect(isSubdirectoryOf("./sub", ".")).toBe(true);
expect(isSubdirectoryOf("../other", ".")).toBe(false);
expect(isSubdirectoryOf("sub/deep", "sub")).toBe(true);
});
it("应该处理空字符串和特殊字符", () => {
expect(isSubdirectoryOf("", "")).toBe(true);
expect(isSubdirectoryOf("a", "")).toBe(false);
expect(isSubdirectoryOf("", "a")).toBe(false);
});
});
});
describe("analyzePathRelationship", () => {
it("应该提供详细的路径分析", () => {
const targetPath = "C:/root\\.sync-git\\target";
const sourcePath = "C:\\root";
const analysis = analyzePathRelationship(targetPath, sourcePath);
expect(analysis.isSubdirectory).toBe(true);
expect(analysis.normalizedTarget).toBe(path.normalize(targetPath));
expect(analysis.normalizedSource).toBe(path.normalize(sourcePath));
expect(analysis.startsWithCheck).toBe(true);
expect(analysis.equalityCheck).toBe(false);
expect(analysis.separator).toBe(path.sep);
});
it("应该分析相同路径", () => {
const targetPath = "C:\\root";
const sourcePath = "C:/root";
const analysis = analyzePathRelationship(targetPath, sourcePath);
expect(analysis.isSubdirectory).toBe(true);
expect(analysis.startsWithCheck).toBe(false);
expect(analysis.equalityCheck).toBe(true);
});
it("应该分析非子目录关系", () => {
const targetPath = "/other/path";
const sourcePath = "/root";
const analysis = analyzePathRelationship(targetPath, sourcePath);
expect(analysis.isSubdirectory).toBe(false);
expect(analysis.startsWithCheck).toBe(false);
expect(analysis.equalityCheck).toBe(false);
});
it("应该包含正確的分隔符信息", () => {
const analysis = analyzePathRelationship("any", "path");
expect(analysis.separator).toBe(path.sep);
if (process.platform === "win32") {
expect(analysis.separator).toBe("\\");
} else {
expect(analysis.separator).toBe("/");
}
});
});

View File

@@ -7,40 +7,79 @@ export default defineConfig({
lib: {
entry: resolve(__dirname, "src/index.ts"),
name: "VitePluginTurborepoDeploy",
fileName: (format) => `index.${format}.js`,
formats: ["es", "cjs", "umd"],
fileName: (format) => `index.${format === "es" ? "mjs" : "cjs"}`,
formats: ["es", "cjs"],
},
rollupOptions: {
external: [
// Vite
"vite",
"fs-extra",
"simple-git",
"chalk",
"ora",
"zod",
// Node.js built-ins
"path",
"os",
"crypto",
"child_process",
"util",
"fs",
"stream",
"events",
"zlib",
"assert",
"constants",
"url",
"buffer",
"string_decoder",
// Node.js prefixed modules
"node:path",
"node:os",
"node:crypto",
"node:child_process",
"node:util",
"node:fs",
"node:fs/promises",
"node:stream",
"node:events",
"node:zlib",
"node:assert",
"node:constants",
"node:url",
"node:buffer",
"node:string_decoder",
// Third-party dependencies
"fs-extra",
"simple-git",
"chalk",
"ora",
"zod",
"picomatch",
"archiver",
"yauzl",
],
output: {
globals: {
vite: "Vite",
"fs-extra": "fsExtra",
"simple-git": "simpleGit",
chalk: "Chalk",
ora: "Ora",
zod: "Zod",
path: "path",
os: "os",
crypto: "crypto",
child_process: "childProcess",
util: "util",
fs: "fs",
stream: "stream",
events: "events",
zlib: "zlib",
assert: "assert",
constants: "constants",
url: "url",
buffer: "buffer",
string_decoder: "stringDecoder",
"fs-extra": "fsExtra",
"simple-git": "simpleGit",
chalk: "Chalk",
ora: "Ora",
zod: "Zod",
picomatch: "picomatch",
archiver: "archiver",
yauzl: "yauzl",
},
},
},
@@ -50,9 +89,8 @@ export default defineConfig({
plugins: [
dts({
insertTypesEntry: true,
outputDir: "dist",
outDir: "dist",
staticImport: true,
skipDiagnostics: false,
}),
],
// 优化构建过程中的代码分析

6265
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff