mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 20:19:09 +08:00
feat: 新增MAC微信发送消息 文字转语音 功能
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
|
||||
|
||||
const getStatus = vi.fn()
|
||||
const rebind = vi.fn()
|
||||
const selectImage = vi.fn()
|
||||
const selectVoice = vi.fn()
|
||||
const sendMessage = vi.fn()
|
||||
const getTextToSpeechSettings = vi.fn()
|
||||
const listTextToSpeechVoices = vi.fn()
|
||||
const synthesizeTextToSpeech = vi.fn()
|
||||
const removeGeneratedTextToSpeechAudio = vi.fn()
|
||||
|
||||
const contact = {
|
||||
m_nsUsrName: 'fixture-room@chatroom',
|
||||
m_nsNickName: '技术交流群',
|
||||
md5: 'fixture-md5',
|
||||
type: 'group' as const
|
||||
}
|
||||
|
||||
const readyStatus = {
|
||||
state: 'online' as const,
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
wechatPid: 4668,
|
||||
boundWechatPid: 4668,
|
||||
oneBotPid: 5401,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: true,
|
||||
wechatVersion: '4.1.11.53',
|
||||
runtimeReady: true,
|
||||
attachReady: true,
|
||||
baseAddress: '0x114ef8000',
|
||||
baseAddressReady: true,
|
||||
textHookInstalled: true,
|
||||
textHookReady: true,
|
||||
imageHookInstalled: true,
|
||||
imageHookReady: true,
|
||||
messageListenerReady: true,
|
||||
canSend: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
canSendVoice: true,
|
||||
message: '个人微信已绑定'
|
||||
}
|
||||
|
||||
describe('PersonalWechatSendDialog', () => {
|
||||
beforeEach(() => {
|
||||
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||
rebind.mockReset().mockResolvedValue(readyStatus)
|
||||
selectImage.mockReset().mockResolvedValue({
|
||||
canceled: false,
|
||||
path: '/Users/fixture/test.png',
|
||||
name: 'test.png'
|
||||
})
|
||||
selectVoice.mockReset().mockResolvedValue({
|
||||
canceled: false,
|
||||
path: '/Users/fixture/test.silk',
|
||||
name: 'test.silk'
|
||||
})
|
||||
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
|
||||
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
settings: {
|
||||
provider: 'fish-audio',
|
||||
hasApiKey: true,
|
||||
encryptionAvailable: true,
|
||||
selectedVoiceId: 'fish-warm-female',
|
||||
outputFormat: 'mp3',
|
||||
model: 's2.1-pro-free',
|
||||
phase: 'ready'
|
||||
},
|
||||
voices: []
|
||||
})
|
||||
listTextToSpeechVoices.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 'fish-warm-female',
|
||||
name: '暖阳女声',
|
||||
description: '自然温和',
|
||||
tags: ['女声'],
|
||||
languages: ['中文'],
|
||||
source: 'fish-audio'
|
||||
}
|
||||
],
|
||||
total: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 24,
|
||||
hasMore: false
|
||||
})
|
||||
synthesizeTextToSpeech.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
filePath: '/tmp/generated.mp3',
|
||||
audioDataUrl: 'data:audio/mpeg;base64,fixture'
|
||||
})
|
||||
removeGeneratedTextToSpeechAudio.mockReset().mockResolvedValue({ success: true })
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPersonalWechatSenderStatus: getStatus,
|
||||
rebindPersonalWechatSender: rebind,
|
||||
selectPersonalWechatImage: selectImage,
|
||||
selectPersonalWechatVoice: selectVoice,
|
||||
sendPersonalWechatMessage: sendMessage,
|
||||
getTextToSpeechSettings,
|
||||
listTextToSpeechVoices,
|
||||
synthesizeTextToSpeech,
|
||||
removeGeneratedTextToSpeechAudio
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('switches to voice mode and sends only the selected audio file', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
|
||||
expect(await screen.findByText('test.silk')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试发送语音到群聊' }))
|
||||
await waitFor(() =>
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'voice',
|
||||
to: 'fixture-room@chatroom',
|
||||
filePath: '/Users/fixture/test.silk',
|
||||
isGroup: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the selected TTS voice and keeps generation separate from sending', async () => {
|
||||
const openSettings = vi.fn()
|
||||
render(
|
||||
<PersonalWechatSendDialog
|
||||
contact={contact}
|
||||
isGroupChat
|
||||
onClose={vi.fn()}
|
||||
onOpenTextToSpeechSettings={openSettings}
|
||||
/>
|
||||
)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
||||
expect(await screen.findByText('暖阳女声')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toHaveValue('1')
|
||||
expect(screen.getByRole('button', { name: '生成语音' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往文字转语音设置' }))
|
||||
expect(openSettings).toHaveBeenCalledTimes(1)
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows binding diagnostics and only offers image and voice modes', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('PID 4668')).toBeInTheDocument()
|
||||
expect(screen.getByText('PID 5401 · 绑定 4668')).toBeInTheDocument()
|
||||
expect(screen.getByText('0x114ef8000')).toBeInTheDocument()
|
||||
expect(screen.getByText('已捕获,可发送')).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('radio', { name: '语音' })).toBeVisible()
|
||||
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to image mode and sends only the selected image', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。',
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
|
||||
expect(await screen.findByText('test.png')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'image',
|
||||
to: 'fixture-room@chatroom',
|
||||
filePath: '/Users/fixture/test.png',
|
||||
isGroup: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('offers a safe explicit rebind action', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('button', { name: '尝试重新绑定' }))
|
||||
await waitFor(() => expect(rebind).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('blocks image sending until the media Hook and image directory are initialized', async () => {
|
||||
getStatus.mockResolvedValue({
|
||||
...readyStatus,
|
||||
state: 'hook_not_ready',
|
||||
imageHookReady: false,
|
||||
canSendImage: false,
|
||||
message: '请先手动发送图片'
|
||||
})
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,44 @@ describe('daily report controls', () => {
|
||||
value: {
|
||||
getAppLogPath: vi.fn(async () => ''),
|
||||
revealAppLog: vi.fn(async () => undefined),
|
||||
getPersonalWechatSenderStatus: vi.fn(async () => ({
|
||||
state: 'online',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
runtimeReady: true,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: true,
|
||||
attachReady: true,
|
||||
baseAddressReady: true,
|
||||
textHookInstalled: true,
|
||||
textHookReady: true,
|
||||
imageHookInstalled: true,
|
||||
imageHookReady: true,
|
||||
messageListenerReady: true,
|
||||
canSend: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
canSendVoice: true,
|
||||
message: '个人微信已绑定'
|
||||
})),
|
||||
getTextToSpeechSettings: vi.fn(async () => ({
|
||||
success: true,
|
||||
settings: {
|
||||
provider: 'fish-audio',
|
||||
hasApiKey: false,
|
||||
hasStoredApiKey: false,
|
||||
hasEnvironmentApiKey: false,
|
||||
keySource: 'missing',
|
||||
encryptionAvailable: true,
|
||||
selectedVoiceId: '',
|
||||
outputFormat: 'mp3',
|
||||
model: 's2.1-pro-free',
|
||||
phase: 'ready'
|
||||
},
|
||||
voices: []
|
||||
})),
|
||||
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
|
||||
getGroupSnapshot: vi.fn(async () => ({
|
||||
members: [
|
||||
@@ -396,7 +434,7 @@ describe('daily report controls', () => {
|
||||
globalThis.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('keeps secondary report actions inside More and labels both AI model roles', () => {
|
||||
it('keeps the file action inside More and labels both AI model roles', () => {
|
||||
render(
|
||||
<>
|
||||
<ReportToolbar
|
||||
@@ -431,15 +469,86 @@ describe('daily report controls', () => {
|
||||
</>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: '生成微信卡片' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多' }))
|
||||
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多' }))
|
||||
expect(screen.getByRole('button', { name: '打开文件夹' })).toBeVisible()
|
||||
expect(screen.getByText('文字模型')).toBeVisible()
|
||||
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
|
||||
expect(screen.getByText('图片模型')).toBeVisible()
|
||||
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
|
||||
})
|
||||
|
||||
it('opens the current group send dialog with the report PNG preselected', async () => {
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-send',
|
||||
contactId: groupContact.md5,
|
||||
contactName: groupContact.m_nsNickName,
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||
reportDate: '2026-08-17',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture',
|
||||
pngPath: '/Users/fixture/测试群日报.png'
|
||||
}
|
||||
|
||||
render(
|
||||
<ReportViewer
|
||||
report={report}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
|
||||
sendTarget={groupContact}
|
||||
personalWechatSendSupported
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('测试群日报.png')).toBeVisible()
|
||||
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
|
||||
})
|
||||
|
||||
it('disables current group sending outside macOS with a readable hover hint', () => {
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-send-disabled',
|
||||
contactId: groupContact.md5,
|
||||
contactName: groupContact.m_nsNickName,
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||
reportDate: '2026-08-17',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture',
|
||||
pngPath: '/Users/fixture/测试群日报.png'
|
||||
}
|
||||
|
||||
render(
|
||||
<ReportViewer
|
||||
report={report}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
|
||||
sendTarget={groupContact}
|
||||
personalWechatSendSupported={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: '发送到当前群聊' })
|
||||
expect(button).toBeDisabled()
|
||||
expect(button.parentElement).toHaveAttribute('title', '仅支持 macOS')
|
||||
})
|
||||
|
||||
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
||||
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
||||
const report: GeneratedReportRecord = {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage'
|
||||
|
||||
const getSettings = vi.fn()
|
||||
const saveSettings = vi.fn()
|
||||
const listVoices = vi.fn()
|
||||
const openApiKeys = vi.fn()
|
||||
const getRuntimeStatus = vi.fn()
|
||||
const onRuntimeProgress = vi.fn(() => vi.fn())
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
settings: {
|
||||
provider: 'fish-audio' as const,
|
||||
hasApiKey: true,
|
||||
hasStoredApiKey: true,
|
||||
hasEnvironmentApiKey: false,
|
||||
keySource: 'secure-storage' as const,
|
||||
encryptionAvailable: true,
|
||||
selectedVoiceId: 'fish-warm-female',
|
||||
outputFormat: 'mp3' as const,
|
||||
model: 's2.1-pro-free' as const,
|
||||
phase: 'ready' as const
|
||||
},
|
||||
voices: [
|
||||
{
|
||||
id: 'fish-warm-female',
|
||||
name: '暖阳女声',
|
||||
description: '自然温和,适合日常对话',
|
||||
tags: ['女声', '自然', '普通话'],
|
||||
languages: ['中文'],
|
||||
source: 'fish-audio' as const
|
||||
},
|
||||
{
|
||||
id: 'fish-calm-male',
|
||||
name: '沉稳男声',
|
||||
description: '低沉清晰,适合知识说明',
|
||||
tags: ['男声', '沉稳', '普通话'],
|
||||
languages: ['中文'],
|
||||
source: 'fish-audio' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('TextToSpeechPage', () => {
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset().mockResolvedValue(response)
|
||||
listVoices.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
items: response.voices,
|
||||
total: response.voices.length,
|
||||
pageNumber: 1,
|
||||
pageSize: 24,
|
||||
hasMore: false
|
||||
})
|
||||
saveSettings.mockReset().mockImplementation(async (request) => ({
|
||||
...response,
|
||||
settings: {
|
||||
...response.settings,
|
||||
hasApiKey: Boolean(request.apiKey),
|
||||
selectedVoiceId: request.selectedVoiceId || response.settings.selectedVoiceId,
|
||||
model: request.model || response.settings.model
|
||||
}
|
||||
}))
|
||||
openApiKeys.mockReset().mockResolvedValue({ success: true })
|
||||
getRuntimeStatus.mockReset().mockResolvedValue({
|
||||
version: 'v0.0.18',
|
||||
state: 'ready',
|
||||
downloadedBytes: 100,
|
||||
totalBytes: 100,
|
||||
progress: 1,
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64',
|
||||
supported: true,
|
||||
removable: true
|
||||
})
|
||||
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getTextToSpeechSettings: getSettings,
|
||||
saveTextToSpeechSettings: saveSettings,
|
||||
listTextToSpeechVoices: listVoices,
|
||||
openFishAudioApiKeys: openApiKeys,
|
||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
||||
onPersonalWechatRuntimeProgress: onRuntimeProgress
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('searches, selects and securely saves an API key with a voice', async () => {
|
||||
const onNotice = vi.fn()
|
||||
render(<TextToSpeechPage onNotice={onNotice} />)
|
||||
|
||||
expect(await screen.findAllByText('暖阳女声')).not.toHaveLength(0)
|
||||
fireEvent.change(screen.getByPlaceholderText('按音色名称搜索'), {
|
||||
target: { value: '沉稳' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索' }))
|
||||
await waitFor(() =>
|
||||
expect(listVoices).toHaveBeenCalledWith({
|
||||
pageNumber: 1,
|
||||
pageSize: 24,
|
||||
title: '沉稳',
|
||||
tags: []
|
||||
})
|
||||
)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /沉稳男声/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('已安全保存;输入新 Key 可替换'), {
|
||||
target: { value: 'fish-fixture-key' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存 Key' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
apiKey: 'fish-fixture-key'
|
||||
})
|
||||
)
|
||||
expect(onNotice).toHaveBeenCalledWith('API Key 已安全保存')
|
||||
})
|
||||
|
||||
it('opens the official Fish Audio API key page through the main process', async () => {
|
||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||
await screen.findByText('微信发送组件')
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
|
||||
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
@@ -95,6 +95,21 @@ describe('preload IPC contract', () => {
|
||||
expect(invoke).toHaveBeenLastCalledWith('voice:removeModel')
|
||||
await api.openVoiceModelDirectory()
|
||||
expect(invoke).toHaveBeenLastCalledWith('voice:openModelDirectory')
|
||||
|
||||
await api.getPersonalWechatSenderStatus()
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getStatus')
|
||||
await api.rebindPersonalWechatSender()
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:rebind')
|
||||
const sendRequest = {
|
||||
to: 'fixture@chatroom',
|
||||
type: 'text' as const,
|
||||
text: '测试消息',
|
||||
isGroup: true
|
||||
}
|
||||
await api.sendPersonalWechatMessage(sendRequest)
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', sendRequest)
|
||||
await api.selectPersonalWechatImage()
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
|
||||
})
|
||||
|
||||
it('preserves key API return values without exposing ipcRenderer', async () => {
|
||||
|
||||
@@ -4,6 +4,34 @@ import { afterEach, vi } from 'vitest'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key) => {
|
||||
values.delete(key)
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
values.set(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: createMemoryStorage()
|
||||
})
|
||||
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
configurable: true,
|
||||
value: createMemoryStorage()
|
||||
})
|
||||
|
||||
Object.defineProperty(globalThis.URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:wxe-test-audio')
|
||||
|
||||
@@ -56,7 +56,18 @@ describe('message grouping and dates', () => {
|
||||
})
|
||||
|
||||
describe('search cache', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
setItem: (key: string, value: string) => values.set(key, String(value))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes the key and survives invalid persisted state', () => {
|
||||
const key = buildSearchCacheKey('global', '', '7d', ' Windows 性能 ')
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, sep } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: () => '/fixture/app',
|
||||
isPackaged: false
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
buildPersonalWechatOneBotRequest,
|
||||
findWechatImagePath,
|
||||
findPersonalWechatRuntime,
|
||||
parsePersonalWechatHookLog
|
||||
} from '../../src/main/services/personal-wechat-send-service'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'wechat-personal-runtime-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (temporaryDirectories.length > 0) {
|
||||
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('personal WeChat OneBot request', () => {
|
||||
it('builds a private text message request', () => {
|
||||
expect(
|
||||
buildPersonalWechatOneBotRequest({
|
||||
to: ' wxid_fixture ',
|
||||
type: 'text',
|
||||
text: ' 测试发送 ',
|
||||
isGroup: false
|
||||
})
|
||||
).toEqual({
|
||||
endpoint: '/send_private_msg',
|
||||
body: {
|
||||
user_id: 'wxid_fixture',
|
||||
message: [{ type: 'text', data: { text: '测试发送' } }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the group endpoint for chatroom targets', () => {
|
||||
expect(
|
||||
buildPersonalWechatOneBotRequest({
|
||||
to: 'fixture-room@chatroom',
|
||||
type: 'text',
|
||||
text: '群聊测试',
|
||||
isGroup: false
|
||||
})
|
||||
).toEqual({
|
||||
endpoint: '/send_group_msg',
|
||||
body: {
|
||||
group_id: 'fixture-room@chatroom',
|
||||
message: [{ type: 'text', data: { text: '群聊测试' } }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a base64 image request without mixing text content', () => {
|
||||
expect(
|
||||
buildPersonalWechatOneBotRequest(
|
||||
{ to: 'fixture-room@chatroom', type: 'image', filePath: '/tmp/test.png', isGroup: false },
|
||||
'aGVsbG8='
|
||||
)
|
||||
).toEqual({
|
||||
endpoint: '/send_group_msg',
|
||||
body: {
|
||||
group_id: 'fixture-room@chatroom',
|
||||
message: [{ type: 'image', data: { file: 'base64://aGVsbG8=' } }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a base64 voice record request', () => {
|
||||
expect(
|
||||
buildPersonalWechatOneBotRequest(
|
||||
{ to: 'filehelper', type: 'voice', filePath: '/tmp/test.silk', isGroup: false },
|
||||
'dm9pY2U='
|
||||
)
|
||||
).toEqual({
|
||||
endpoint: '/send_private_msg',
|
||||
body: {
|
||||
user_id: 'filehelper',
|
||||
message: [{ type: 'record', data: { file: 'base64://dm9pY2U=' } }]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('personal WeChat runtime discovery', () => {
|
||||
it('uses the newest current-month WeChat image directory', () => {
|
||||
const root = temporaryDirectory()
|
||||
const oldPath = join(root, 'account', 'temp', 'old', '2026-08', 'Img')
|
||||
const latestPath = join(root, 'account', 'temp', 'latest', '2026-08', 'Img')
|
||||
mkdirSync(oldPath, { recursive: true })
|
||||
mkdirSync(latestPath, { recursive: true })
|
||||
utimesSync(oldPath, new Date('2026-08-01'), new Date('2026-08-01'))
|
||||
utimesSync(latestPath, new Date('2026-08-05'), new Date('2026-08-05'))
|
||||
|
||||
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(`${latestPath}${sep}`)
|
||||
})
|
||||
|
||||
it('supports the current ImageTemp layout after a WeChat relogin', () => {
|
||||
const root = temporaryDirectory()
|
||||
const imageTempPath = join(root, 'account', 'temp', 'ImageTemp', '2026-08')
|
||||
mkdirSync(imageTempPath, { recursive: true })
|
||||
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(`${imageTempPath}${sep}`)
|
||||
})
|
||||
|
||||
it('derives the current ImageTemp directory before the month folder exists', () => {
|
||||
const root = temporaryDirectory()
|
||||
const tempRoot = join(root, 'account', 'temp')
|
||||
mkdirSync(tempRoot, { recursive: true })
|
||||
|
||||
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(
|
||||
`${join(tempRoot, 'ImageTemp', '2026-08')}${sep}`
|
||||
)
|
||||
})
|
||||
|
||||
it('finds the nested release archive layout', () => {
|
||||
const root = temporaryDirectory()
|
||||
mkdirSync(join(root, 'onebot'), { recursive: true })
|
||||
mkdirSync(join(root, 'wechat_version'), { recursive: true })
|
||||
writeFileSync(join(root, 'onebot', 'onebot'), 'fixture')
|
||||
writeFileSync(join(root, 'onebot', 'script.js'), 'fixture')
|
||||
|
||||
expect(findPersonalWechatRuntime([root])).toEqual({
|
||||
root,
|
||||
executable: join(root, 'onebot', 'onebot'),
|
||||
workingDirectory: join(root, 'onebot'),
|
||||
configDirectory: join(root, 'wechat_version'),
|
||||
logPath: join(root, 'onebot', 'log', 'macos.log')
|
||||
})
|
||||
})
|
||||
|
||||
it('finds a flat runtime layout and rejects incomplete directories', () => {
|
||||
const incomplete = temporaryDirectory()
|
||||
writeFileSync(join(incomplete, 'onebot'), 'fixture')
|
||||
|
||||
const root = temporaryDirectory()
|
||||
mkdirSync(join(root, 'wechat_version'), { recursive: true })
|
||||
writeFileSync(join(root, 'onebot'), 'fixture')
|
||||
writeFileSync(join(root, 'script.js'), 'fixture')
|
||||
|
||||
expect(findPersonalWechatRuntime([incomplete, root])).toEqual({
|
||||
root,
|
||||
executable: join(root, 'onebot'),
|
||||
workingDirectory: root,
|
||||
configDirectory: join(root, 'wechat_version'),
|
||||
logPath: join(root, 'log', 'macos.log')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('personal WeChat hook diagnostics', () => {
|
||||
it('does not treat a listening service as hook-ready after req2buf scan failure', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
[
|
||||
JSON.stringify({ time: '2026-08-06T14:08:10+08:00', payload: '[+] HTTP 服务启动在' }),
|
||||
JSON.stringify({
|
||||
time: '2026-08-06T14:08:11+08:00',
|
||||
err: "Error: [-] Cannot find 'req2buf' keyword in a range > 100MB"
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
).toMatchObject({ readiness: 'failed' })
|
||||
})
|
||||
|
||||
it('requires StartTask capture before reporting ready', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' })
|
||||
)
|
||||
).toMatchObject({ readiness: 'initializing', textHookInstalled: true })
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
JSON.stringify({ payload: '[+] 捕获到 StartTask 调用,X0:0x1, Payload: 0x2' })
|
||||
)
|
||||
).toMatchObject({ readiness: 'ready', textHookReady: true })
|
||||
})
|
||||
|
||||
it('resets stale hook results when a new WeChat process attach starts', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
[
|
||||
JSON.stringify({ payload: '[+] 捕获到 StartTask 调用,X0:0x1, Payload: 0x2' }),
|
||||
JSON.stringify({ message: '使用指定的微信进程 PID', PID: 123 }),
|
||||
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' })
|
||||
].join('\n')
|
||||
)
|
||||
).toMatchObject({
|
||||
readiness: 'initializing',
|
||||
boundWechatPid: 123,
|
||||
textHookInstalled: true,
|
||||
textHookReady: false
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks base, image Hook and message listener diagnostics', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
[
|
||||
JSON.stringify({ message: '使用指定的微信进程 PID', PID: 4668 }),
|
||||
JSON.stringify({ message: '成功 Attach 微信进程', PID: 4668 }),
|
||||
JSON.stringify({ payload: '[+] Base address from range: 0x114ef8000' }),
|
||||
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' }),
|
||||
JSON.stringify({ payload: '[+] 图片上传 Hook Setup Complete.' }),
|
||||
JSON.stringify({ payload: '[+] 捕获到图片上传上下文,uploadGlobalX0:0x1' }),
|
||||
JSON.stringify({ message: '发送数据' })
|
||||
].join('\n')
|
||||
)
|
||||
).toMatchObject({
|
||||
attached: true,
|
||||
baseAddress: '0x114ef8000',
|
||||
textHookInstalled: true,
|
||||
imageHookInstalled: true,
|
||||
imageHookReady: true,
|
||||
messageListenerReady: true,
|
||||
boundWechatPid: 4668
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes a successful legacy image send without the new Hook marker', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
JSON.stringify({ type: 'send_image', result: '1', message: '发送图片任务执行结果' })
|
||||
)
|
||||
).toMatchObject({ imageHookInstalled: true, imageHookReady: true })
|
||||
})
|
||||
|
||||
it('recognizes the real onebot two-record image result format', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
[
|
||||
JSON.stringify({ task_id: 536870915, type: 'send_image' }),
|
||||
JSON.stringify({
|
||||
result: '1',
|
||||
task_id: 536870915,
|
||||
message: '📩 发送图片任务执行结果'
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
).toMatchObject({ imageHookInstalled: true, imageHookReady: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SETTINGS_NAVIGATION } from '../../src/renderer/src/features/settings/model/settingsNavigation'
|
||||
|
||||
describe('settings navigation', () => {
|
||||
it('places text-to-speech under intelligent capabilities', () => {
|
||||
const intelligent = SETTINGS_NAVIGATION.find((group) => group.label === '智能能力')
|
||||
expect(intelligent?.items.map((item) => item.id)).toEqual([
|
||||
'voice-recognition',
|
||||
'text-to-speech',
|
||||
'ai-model'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
keyGet: vi.fn(),
|
||||
keySave: vi.fn(),
|
||||
keyClear: vi.fn(),
|
||||
loadSettings: vi.fn(),
|
||||
updateSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/ai-provider-key-store', () => ({
|
||||
AIProviderKeyStore: class {
|
||||
get = mocks.keyGet
|
||||
save = mocks.keySave
|
||||
clear = mocks.keyClear
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/settings-store', () => ({
|
||||
loadSettings: mocks.loadSettings,
|
||||
updateSettings: mocks.updateSettings
|
||||
}))
|
||||
|
||||
import { TextToSpeechSettingsService } from '../../src/main/services/text-to-speech-settings-service'
|
||||
|
||||
describe('TextToSpeechSettingsService', () => {
|
||||
beforeEach(() => {
|
||||
mocks.keyGet.mockReset().mockReturnValue({
|
||||
success: true,
|
||||
available: true,
|
||||
key: undefined
|
||||
})
|
||||
mocks.keySave.mockReset().mockReturnValue({ success: true })
|
||||
mocks.keyClear.mockReset().mockReturnValue({ success: true })
|
||||
mocks.loadSettings.mockReset().mockReturnValue({ ttsSelectedVoiceId: 'demo-warm-female' })
|
||||
mocks.updateSettings.mockReset()
|
||||
})
|
||||
|
||||
it('returns an empty dynamic voice list without exposing a missing key', () => {
|
||||
const result = new TextToSpeechSettingsService().get()
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.settings.hasApiKey).toBe(false)
|
||||
expect(result.settings.selectedVoiceId).toBe('')
|
||||
expect(result.voices).toEqual([])
|
||||
})
|
||||
|
||||
it('stores the key securely and persists the selected voice', () => {
|
||||
mocks.keyGet.mockReturnValue({ success: true, available: true, key: 'stored' })
|
||||
const result = new TextToSpeechSettingsService().save({
|
||||
apiKey: ' fish-key ',
|
||||
selectedVoiceId: 'demo-calm-male'
|
||||
})
|
||||
expect(mocks.keySave).toHaveBeenCalledWith('fish-audio-tts', 'fish-key')
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith({
|
||||
ttsSelectedVoiceId: 'demo-calm-male'
|
||||
})
|
||||
expect(result.settings.hasApiKey).toBe(true)
|
||||
})
|
||||
|
||||
it('persists a remotely loaded voice id without local demo validation', () => {
|
||||
const result = new TextToSpeechSettingsService().save({
|
||||
selectedVoiceId: 'fish-model-id'
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith({
|
||||
ttsSelectedVoiceId: 'fish-model-id'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,7 @@ describe('WechatDb normalized messages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => {
|
||||
it('passes time bounds, then filters, deduplicates and sorts in application code', async () => {
|
||||
const row = (id: string, createTime: number): WechatMessage => ({
|
||||
mesLocalID: id,
|
||||
serverId: `server-${id}`,
|
||||
@@ -49,13 +49,20 @@ describe('WechatDb normalized messages', () => {
|
||||
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
|
||||
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
|
||||
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
|
||||
const getMessagesAsync = vi.fn(async () => [
|
||||
const rows = [
|
||||
row('before-range', start - 1),
|
||||
row('newest', start + 48 * 60 * 60),
|
||||
shardBoundaryMessage,
|
||||
{ ...shardBoundaryMessage },
|
||||
row('after-range', end + 1)
|
||||
])
|
||||
]
|
||||
const getMessagesAsync = vi.fn(
|
||||
async (_username: string, startTime?: number, endTime?: number) =>
|
||||
rows.filter((message) => {
|
||||
const createTime = Number(message.msgCreateTime)
|
||||
return (!startTime || createTime >= startTime) && (!endTime || createTime <= endTime)
|
||||
})
|
||||
)
|
||||
const db = Object.assign(Object.create(WechatDb.prototype), {
|
||||
wcdb4Client: { getMessagesAsync },
|
||||
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
|
||||
@@ -65,7 +72,7 @@ describe('WechatDb normalized messages', () => {
|
||||
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
|
||||
|
||||
expect(getMessagesAsync).toHaveBeenCalledOnce()
|
||||
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user')
|
||||
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user', start, end)
|
||||
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user