mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: mac发送能力重构为libtmwechat
- 发送改走本地host, 在微信登录时触发 - 删除旧OneBot及对应UI控制面
This commit is contained in:
@@ -12,16 +12,17 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
|
||||
}))
|
||||
|
||||
const getStatus = vi.fn()
|
||||
const getRuntimeStatus = vi.fn()
|
||||
const onRuntimeProgress = vi.fn(() => vi.fn())
|
||||
const rebind = vi.fn()
|
||||
const sendGeneratedTtsVoice = vi.fn()
|
||||
const sendPersonalWechatMessage = vi.fn()
|
||||
const getTextToSpeechSettings = vi.fn()
|
||||
const listTextToSpeechVoices = vi.fn()
|
||||
const synthesizeTextToSpeech = vi.fn()
|
||||
const removeGeneratedTextToSpeechAudio = vi.fn()
|
||||
const getPersonalWechatVoiceDiagnostic = vi.fn()
|
||||
const copyText = vi.fn()
|
||||
const getSettings = vi.fn()
|
||||
const setSettings = vi.fn()
|
||||
|
||||
const contact = {
|
||||
m_nsUsrName: 'fixture-room@chatroom',
|
||||
@@ -38,8 +39,7 @@ const readyStatus = {
|
||||
wechatRunning: true,
|
||||
wechatPid: 4668,
|
||||
boundWechatPid: 4668,
|
||||
oneBotPid: 5401,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpoint: '127.0.0.1:4290',
|
||||
endpointReady: true,
|
||||
wechatVersion: '4.1.11.53',
|
||||
runtimeReady: true,
|
||||
@@ -58,18 +58,6 @@ const readyStatus = {
|
||||
message: '个人微信已绑定'
|
||||
}
|
||||
|
||||
const readyRuntime = {
|
||||
version: 'v0.0.18',
|
||||
state: 'ready' as const,
|
||||
downloadedBytes: 1,
|
||||
totalBytes: 1,
|
||||
progress: 1,
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
architecture: 'arm64',
|
||||
supported: true,
|
||||
removable: true
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
props: Partial<React.ComponentProps<typeof PersonalWechatSendDialog>> = {}
|
||||
): React.ReactElement {
|
||||
@@ -85,13 +73,24 @@ async function startComposer(): Promise<void> {
|
||||
describe('PersonalWechatSendDialog', () => {
|
||||
beforeEach(() => {
|
||||
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
|
||||
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
||||
rebind.mockReset().mockResolvedValue(readyStatus)
|
||||
sendGeneratedTtsVoice.mockReset().mockResolvedValue({
|
||||
action: { status: 'sent' },
|
||||
status: readyStatus
|
||||
})
|
||||
sendPersonalWechatMessage.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
status: readyStatus,
|
||||
postfixSent: true
|
||||
})
|
||||
getSettings.mockReset().mockResolvedValue({
|
||||
settings: { reportImagePostfixText: '今日日报' },
|
||||
settingsPath: '/tmp/settings.json'
|
||||
})
|
||||
setSettings.mockReset().mockResolvedValue({
|
||||
settings: { reportImagePostfixText: '今日日报' },
|
||||
settingsPath: '/tmp/settings.json'
|
||||
})
|
||||
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
settings: {
|
||||
@@ -125,16 +124,17 @@ describe('PersonalWechatSendDialog', () => {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPersonalWechatSenderStatus: getStatus,
|
||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
||||
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
||||
rebindPersonalWechatSender: rebind,
|
||||
sendGeneratedTtsVoice,
|
||||
sendPersonalWechatMessage,
|
||||
getTextToSpeechSettings,
|
||||
listTextToSpeechVoices,
|
||||
synthesizeTextToSpeech,
|
||||
removeGeneratedTextToSpeechAudio,
|
||||
getPersonalWechatVoiceDiagnostic,
|
||||
copyText
|
||||
copyText,
|
||||
getSettings,
|
||||
setSettings
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -143,7 +143,6 @@ describe('PersonalWechatSendDialog', () => {
|
||||
renderDialog()
|
||||
await startComposer()
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('文字转语音')
|
||||
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('验证消息能力')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '生成语音' })).toBeDisabled()
|
||||
})
|
||||
@@ -167,6 +166,55 @@ describe('PersonalWechatSendDialog', () => {
|
||||
expect(screen.getByLabelText('消息列表')).toHaveTextContent('你好 TraceMemo')
|
||||
})
|
||||
|
||||
it('shows image send success feedback after the host confirms the send', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } })
|
||||
expect(await screen.findByRole('textbox', { name: '发送后置词' })).toHaveValue('今日日报')
|
||||
const sendButton = await screen.findByRole('button', { name: '发送日报图片' })
|
||||
await user.click(sendButton)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendPersonalWechatMessage).toHaveBeenCalledWith({
|
||||
type: 'image',
|
||||
to: 'fixture-room@chatroom',
|
||||
isGroup: true,
|
||||
filePath: '/tmp/report.png',
|
||||
postfixText: '今日日报'
|
||||
})
|
||||
)
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('日报图片和后置词发送成功')
|
||||
})
|
||||
|
||||
it('persists a customized report postfix and sends it with the image request', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } })
|
||||
const input = await screen.findByRole('textbox', { name: '发送后置词' })
|
||||
await user.clear(input)
|
||||
await user.type(input, '今日技术日报')
|
||||
await user.tab()
|
||||
await user.click(screen.getByRole('button', { name: '发送日报图片' }))
|
||||
|
||||
expect(setSettings).toHaveBeenCalledWith({ reportImagePostfixText: '今日技术日报' })
|
||||
await waitFor(() =>
|
||||
expect(sendPersonalWechatMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ postfixText: '今日技术日报' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('shows an error when the report postfix cannot be persisted', async () => {
|
||||
setSettings.mockRejectedValueOnce(new Error('设置保存失败'))
|
||||
const user = userEvent.setup()
|
||||
renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } })
|
||||
const input = await screen.findByRole('textbox', { name: '发送后置词' })
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '今日技术日报')
|
||||
await user.tab()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('设置保存失败')
|
||||
})
|
||||
|
||||
it('keeps the setup guide voice-only when voice capability is unavailable', async () => {
|
||||
getStatus.mockResolvedValue({ ...readyStatus, canSend: false, canSendVoice: false })
|
||||
renderDialog({ onOpenPersonalWechatSettings: vi.fn() })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
|
||||
|
||||
@@ -12,15 +11,7 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
|
||||
|
||||
const getCapability = vi.fn()
|
||||
const getSenderStatus = vi.fn()
|
||||
const getRuntimeStatus = vi.fn()
|
||||
const onRuntimeProgress = vi.fn(() => vi.fn())
|
||||
const downloadRuntime = vi.fn()
|
||||
const cancelRuntimeDownload = vi.fn()
|
||||
const removeRuntime = vi.fn()
|
||||
const openRuntimeDirectory = vi.fn()
|
||||
const rebindSender = vi.fn()
|
||||
const getKeepOneBotProcess = vi.fn()
|
||||
const setKeepOneBotProcess = vi.fn()
|
||||
|
||||
const senderStatus = {
|
||||
state: 'online' as const,
|
||||
@@ -30,8 +21,7 @@ const senderStatus = {
|
||||
wechatRunning: true,
|
||||
wechatPid: 4668,
|
||||
boundWechatPid: 4668,
|
||||
oneBotPid: 5401,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpoint: '127.0.0.1:4290',
|
||||
endpointReady: true,
|
||||
wechatVersion: '4.1.11.53',
|
||||
runtimeReady: true,
|
||||
@@ -50,19 +40,6 @@ const senderStatus = {
|
||||
message: '个人微信已绑定'
|
||||
}
|
||||
|
||||
const readyRuntime = {
|
||||
version: 'v0.0.18',
|
||||
state: 'ready' as const,
|
||||
downloadedBytes: 66_599_785,
|
||||
totalBytes: 66_599_785,
|
||||
progress: 1,
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
architecture: 'arm64',
|
||||
supported: true,
|
||||
removable: true,
|
||||
directory: '/tmp/personal-wechat-runtime'
|
||||
}
|
||||
|
||||
const capability = {
|
||||
supported: true,
|
||||
ready: true,
|
||||
@@ -76,85 +53,23 @@ describe('PersonalWechatSendPage on macOS', () => {
|
||||
beforeEach(() => {
|
||||
getCapability.mockReset().mockResolvedValue(capability)
|
||||
getSenderStatus.mockReset().mockResolvedValue(senderStatus)
|
||||
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
|
||||
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
||||
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
|
||||
cancelRuntimeDownload.mockReset().mockResolvedValue({ success: true })
|
||||
removeRuntime.mockReset().mockResolvedValue({
|
||||
...readyRuntime,
|
||||
state: 'missing',
|
||||
downloadedBytes: 0,
|
||||
progress: 0,
|
||||
removable: false,
|
||||
directory: undefined
|
||||
})
|
||||
openRuntimeDirectory.mockReset().mockResolvedValue({ success: true })
|
||||
rebindSender.mockReset().mockResolvedValue(senderStatus)
|
||||
getKeepOneBotProcess.mockReset().mockResolvedValue(false)
|
||||
setKeepOneBotProcess.mockReset().mockImplementation(async (keep: boolean) => keep)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPersonalWechatSendCapability: getCapability,
|
||||
getPersonalWechatSenderStatus: getSenderStatus,
|
||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
||||
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
||||
downloadPersonalWechatRuntime: downloadRuntime,
|
||||
cancelPersonalWechatRuntimeDownload: cancelRuntimeDownload,
|
||||
removePersonalWechatRuntime: removeRuntime,
|
||||
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
|
||||
rebindPersonalWechatSender: rebindSender,
|
||||
getPersonalWechatKeepOneBotProcess: getKeepOneBotProcess,
|
||||
setPersonalWechatKeepOneBotProcess: setKeepOneBotProcess
|
||||
rebindPersonalWechatSender: rebindSender
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps OneBot runtime management on the WeChat send page', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('shows the native runtime readiness', async () => {
|
||||
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('OneBot v0.0.18')).toBeVisible()
|
||||
expect(await screen.findByText('微信消息发送已配置完成')).toBeVisible()
|
||||
expect(screen.getAllByText('已就绪')).not.toHaveLength(0)
|
||||
await user.click(screen.getByRole('button', { name: '打开目录' }))
|
||||
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '卸载组件' }))
|
||||
await waitFor(() => expect(removeRuntime).toHaveBeenCalledOnce())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '支持版本' }))
|
||||
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
|
||||
expect(versionsDialog).toBeVisible()
|
||||
expect(versionsDialog).toHaveTextContent('4.1.11.53')
|
||||
})
|
||||
|
||||
it('keeps the OneBot process setting on the macOS WeChat send page', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
|
||||
|
||||
const keepProcess = await screen.findByRole('switch', { name: '保留 OneBot 进程' })
|
||||
expect(keepProcess).not.toBeChecked()
|
||||
await user.click(keepProcess)
|
||||
|
||||
await waitFor(() => expect(setKeepOneBotProcess).toHaveBeenCalledWith(true))
|
||||
expect(keepProcess).toBeChecked()
|
||||
})
|
||||
|
||||
it('downloads a missing macOS runtime from the send page', async () => {
|
||||
getRuntimeStatus.mockResolvedValue({
|
||||
...readyRuntime,
|
||||
state: 'missing',
|
||||
downloadedBytes: 0,
|
||||
progress: 0,
|
||||
removable: false,
|
||||
directory: undefined
|
||||
})
|
||||
const onNotice = vi.fn()
|
||||
render(<PersonalWechatSendPage onNotice={onNotice} />)
|
||||
|
||||
await userEvent.setup().click(await screen.findByRole('button', { name: '下载组件' }))
|
||||
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
|
||||
expect(onNotice).toHaveBeenCalledWith('微信发送组件已准备好')
|
||||
expect(screen.queryByText('查看支持的微信版本')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('高级诊断')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PersonalWechatSetupGuide } from '../../src/renderer/src/components/chat/PersonalWechatSetupGuide'
|
||||
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
|
||||
import type { PersonalWechatSenderStatus } from '../../src/shared/personal-wechat'
|
||||
|
||||
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
|
||||
isMac: true,
|
||||
isWindows: false,
|
||||
runtimePlatform: 'darwin',
|
||||
supportsPersonalWechatSend: true
|
||||
}))
|
||||
|
||||
const unavailableStatus: PersonalWechatSenderStatus = {
|
||||
state: 'runtime_missing',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sipDisabled: true,
|
||||
wechatRunning: false,
|
||||
endpoint: 'http://127.0.0.1:4290',
|
||||
endpointReady: false,
|
||||
runtimeReady: false,
|
||||
attachReady: false,
|
||||
baseAddressReady: false,
|
||||
textHookInstalled: false,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: false,
|
||||
imageHookReady: false,
|
||||
messageListenerReady: false,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false,
|
||||
message: '暂无发送能力',
|
||||
error: '暂无发送能力'
|
||||
}
|
||||
|
||||
describe('personal WeChat unavailable product state', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPersonalWechatSendCapability: vi.fn(async () => ({
|
||||
supported: true,
|
||||
ready: false,
|
||||
status: 'unconfigured',
|
||||
capabilities: { text: false, image: false, voice: false },
|
||||
senderStatus: unavailableStatus,
|
||||
message: '暂无发送能力',
|
||||
error: '暂无发送能力'
|
||||
})),
|
||||
getPersonalWechatSenderStatus: vi.fn(async () => unavailableStatus)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the setup flow visible and disables binding when the runtime is unavailable', () => {
|
||||
render(
|
||||
<PersonalWechatSetupGuide
|
||||
senderStatus={unavailableStatus}
|
||||
binding={false}
|
||||
detecting={false}
|
||||
sessionBound={false}
|
||||
onBind={vi.fn()}
|
||||
onStartSending={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { name: '先准备好微信消息功能' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '绑定微信' })).toBeDisabled()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('发送能力属授权制,需要联系群主')
|
||||
expect(screen.getByText(/发送能力属授权制,需要联系群主/)).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: '这里' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: '下载运行时' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('查看支持的微信版本')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('高级诊断')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/manifest|dylib|runtime missing|文件缺失/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the disabled binding flow on the settings page', async () => {
|
||||
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '先准备好微信消息功能' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '绑定微信' })).toBeDisabled()
|
||||
expect(screen.getByText(/发送能力属授权制,需要联系群主/)).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: '这里' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
|
||||
)
|
||||
expect(screen.queryByText(/manifest|dylib|runtime missing|文件缺失/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -54,7 +54,7 @@ describe('daily report controls', () => {
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
runtimeReady: true,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpoint: '127.0.0.1:4290',
|
||||
endpointReady: true,
|
||||
attachReady: true,
|
||||
baseAddressReady: true,
|
||||
@@ -618,6 +618,10 @@ describe('daily report controls', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
|
||||
expect(screen.getByRole('heading', { name: '发送日报图片' })).toBeVisible()
|
||||
expect(screen.queryByText('文字转语音')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: '发送后置词' })).toHaveValue('今日日报')
|
||||
const detect = screen.queryByRole('button', { name: '重新检测' })
|
||||
if (detect) fireEvent.click(detect)
|
||||
expect(screen.getByRole('region', { name: '日报图片发送' })).toBeVisible()
|
||||
|
||||
@@ -17,7 +17,6 @@ const openFishAudioApiKeys = vi.fn()
|
||||
const getSettings = vi.fn()
|
||||
const setSettings = vi.fn()
|
||||
const getPersonalWechatSenderStatus = vi.fn()
|
||||
const getPersonalWechatRuntimeStatus = vi.fn()
|
||||
const checkPersonalWechatSenderStatus = vi.fn()
|
||||
|
||||
const textToSpeechSettings = {
|
||||
@@ -70,7 +69,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
||||
settings: { windowsWechatPort: '4567' }
|
||||
})
|
||||
getPersonalWechatSenderStatus.mockReset()
|
||||
getPersonalWechatRuntimeStatus.mockReset().mockResolvedValue(null)
|
||||
checkPersonalWechatSenderStatus.mockReset().mockResolvedValue(windowsStatus)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
@@ -81,7 +79,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
||||
getSettings,
|
||||
setSettings,
|
||||
getPersonalWechatSenderStatus,
|
||||
getPersonalWechatRuntimeStatus,
|
||||
checkPersonalWechatSenderStatus
|
||||
}
|
||||
})
|
||||
@@ -128,7 +125,7 @@ describe('PersonalWechatSendPage on Windows', () => {
|
||||
expect(onNotice).toHaveBeenCalledWith('微信发送能力端口已清除')
|
||||
})
|
||||
|
||||
it('keeps the Windows send dialog free of macOS OneBot controls', async () => {
|
||||
it('keeps the Windows send dialog free of macOS-only controls', async () => {
|
||||
getPersonalWechatSenderStatus.mockResolvedValue({
|
||||
...windowsStatus,
|
||||
state: 'error',
|
||||
@@ -156,9 +153,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
||||
expect(
|
||||
await screen.findByText('未检测到 Windows 微信发送能力,请先启动并登录微信')
|
||||
).toBeVisible()
|
||||
expect(getPersonalWechatRuntimeStatus).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '文字转语音' })).toBeVisible()
|
||||
expect(screen.getByRole('region', { name: '文字转语音' })).toBeVisible()
|
||||
@@ -188,7 +182,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('API 设置')).toBeVisible()
|
||||
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('微信发送组件')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -196,7 +189,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
||||
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByRole('spinbutton', { name: '微信发送能力端口' })).toBeVisible()
|
||||
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the authorization notice with a README link on the Windows WeChat send page', async () => {
|
||||
|
||||
@@ -148,12 +148,11 @@ describe('TextToSpeechPage', () => {
|
||||
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true }))
|
||||
})
|
||||
|
||||
it('shows the macOS voice encoding environment entry without exposing OneBot controls', async () => {
|
||||
it('shows the macOS voice encoding environment entry', async () => {
|
||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('API 设置')).toBeVisible()
|
||||
expect(screen.getByText('语音编码环境')).toBeVisible()
|
||||
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('支持的微信版本')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -166,8 +165,8 @@ describe('TextToSpeechPage', () => {
|
||||
python: { ready: true, executable: '/usr/local/bin/python3', version: '3.12.11' },
|
||||
pilk: { ready: false, error: '未安装' },
|
||||
ffmpeg: { ready: true, executable: '/runtime/ffmpeg', version: '6.1' },
|
||||
encoder: 'go-silk' as const,
|
||||
message: '语音编码环境不完整,OneBot 可能回退到 go-silk'
|
||||
encoder: 'silk' as const,
|
||||
message: '语音编码环境不完整,语音将回退到内置 SILK 编码'
|
||||
}
|
||||
const readyEnvironment = {
|
||||
...incompleteEnvironment,
|
||||
@@ -187,7 +186,7 @@ describe('TextToSpeechPage', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安装 pilk' }))
|
||||
await waitFor(() => expect(installPilk).toHaveBeenCalledTimes(1))
|
||||
expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。重新启动语音发送服务后生效。')
|
||||
expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。')
|
||||
expect(await screen.findByText('✓ 编码环境正常')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
+7
-12
@@ -429,7 +429,7 @@ test('GUIDE-01 first-use welcome is keyboard-safe and fits the viewport', async
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the viewport', async () => {
|
||||
test('SETTINGS-01 macOS WeChat setup keeps the binding guidance focused', async () => {
|
||||
test.skip(process.platform !== 'darwin', 'The personal WeChat runtime is currently macOS-only')
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
@@ -441,22 +441,17 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '微信发送' }).click()
|
||||
|
||||
const trigger = fixture.page.getByRole('button', { name: '支持版本' })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '支持的微信版本' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText('4.1.6.12')).toBeVisible()
|
||||
await expect(dialog.getByText('4.1.11.53')).toBeVisible()
|
||||
await expect(
|
||||
fixture.page.getByText('请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。')
|
||||
).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '支持版本' })).toHaveCount(0)
|
||||
await expect(fixture.page.getByText('查看支持的微信版本')).toHaveCount(0)
|
||||
await expect(fixture.page.getByText('高级诊断')).toHaveCount(0)
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(trigger).toBeFocused()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
|
||||
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
|
||||
await modelSelect.scrollIntoViewIfNeeded()
|
||||
|
||||
@@ -21,7 +21,6 @@ const personalWechatSupported = process.platform === 'darwin' || process.platfor
|
||||
const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1'
|
||||
const fixtureNowMs =
|
||||
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
|
||||
let keepOneBotProcess = false
|
||||
|
||||
const formatFixtureDateTime = (timestampSeconds) => {
|
||||
const date = new Date(timestampSeconds * 1000)
|
||||
@@ -400,17 +399,6 @@ handle('tts:getSettings', () => ({
|
||||
},
|
||||
voices: []
|
||||
}))
|
||||
handle('wechat-personal:getRuntimeStatus', () => ({
|
||||
version: 'v0.0.18',
|
||||
state: 'ready',
|
||||
downloadedBytes: 100,
|
||||
totalBytes: 100,
|
||||
progress: 1,
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64',
|
||||
supported: true,
|
||||
removable: true
|
||||
}))
|
||||
handle('wechat-personal:getStatus', () => ({
|
||||
state: 'online',
|
||||
platform: process.platform,
|
||||
@@ -419,8 +407,7 @@ handle('wechat-personal:getStatus', () => ({
|
||||
wechatRunning: true,
|
||||
wechatPid: 4668,
|
||||
boundWechatPid: 4668,
|
||||
oneBotPid: 5401,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpoint: '127.0.0.1:4290',
|
||||
endpointReady: true,
|
||||
wechatVersion: '4.1.11.53',
|
||||
runtimeReady: true,
|
||||
@@ -454,7 +441,7 @@ handle('wechat-personal:getSendCapability', () => ({
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
boundWechatPid: 4668,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpoint: '127.0.0.1:4290',
|
||||
endpointReady: true,
|
||||
runtimeReady: true,
|
||||
attachReady: true,
|
||||
@@ -474,11 +461,6 @@ handle('wechat-personal:getSendCapability', () => ({
|
||||
? '个人微信已准备好发送日报'
|
||||
: '微信消息发送目前仅支持 macOS 和 Windows'
|
||||
}))
|
||||
handle('wechat-personal:getKeepProcess', () => keepOneBotProcess)
|
||||
handle('wechat-personal:setKeepProcess', (keep) => {
|
||||
keepOneBotProcess = Boolean(keep)
|
||||
return keepOneBotProcess
|
||||
})
|
||||
handle('scheduled-report:list', () => [...scheduledReportTasks])
|
||||
handle('scheduled-report:listExecutions', (taskId) =>
|
||||
taskId
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createRequire } from 'module'
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
bindingMatchesRuntimeManifest,
|
||||
readMacWechatRuntimeManifest
|
||||
} from '../../src/main/services/mac-wechat-runtime-artifact'
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const { readAndValidateArtifact, syncArtifact } = nodeRequire(
|
||||
'../../scripts/prepare-wechat-native-runtime.cjs'
|
||||
) as {
|
||||
readAndValidateArtifact: (sourceDir: string) => { manifest: { tmSendSourceSha256: string } }
|
||||
syncArtifact: (sourceDir: string, targetDir: string) => unknown
|
||||
}
|
||||
|
||||
const AGENT_HASH = 'a'.repeat(64)
|
||||
const ADDRESS_PROFILE_HASH = 'b'.repeat(64)
|
||||
|
||||
function writeMachOArm64(filePath: string, suffix = Buffer.alloc(0)): void {
|
||||
const header = Buffer.alloc(32)
|
||||
header.writeUInt32LE(0xfeedfacf, 0)
|
||||
header.writeUInt32LE(0x0100000c, 4)
|
||||
writeFileSync(filePath, Buffer.concat([header, suffix]))
|
||||
}
|
||||
|
||||
function createArtifact(root: string): void {
|
||||
writeMachOArm64(join(root, 'tm-wechat-host'))
|
||||
writeMachOArm64(
|
||||
join(root, 'libtmwechat.dylib'),
|
||||
Buffer.from(`${AGENT_HASH}${ADDRESS_PROFILE_HASH}`)
|
||||
)
|
||||
writeFileSync(
|
||||
join(root, 'runtime-manifest.json'),
|
||||
JSON.stringify({
|
||||
runtime: 'tm-wechat-native',
|
||||
version: '0.1.0',
|
||||
platform: 'darwin-arm64',
|
||||
architecture: 'arm64',
|
||||
protocolVersion: 1,
|
||||
buildCommit: 'test',
|
||||
tmSendSourceSha256: AGENT_HASH,
|
||||
addressProfileSha256: ADDRESS_PROFILE_HASH,
|
||||
supportedWechatVersions: ['4.1.11.53'],
|
||||
capabilities: ['text', 'image', 'voice']
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('macOS self-contained WeChat runtime artifact', () => {
|
||||
it('validates the manifest, architecture, capabilities and embedded agent identity', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'tm-native-runtime-'))
|
||||
createArtifact(root)
|
||||
|
||||
const manifest = readMacWechatRuntimeManifest(root, 'arm64')
|
||||
expect(manifest.tmSendSourceSha256).toBe(AGENT_HASH)
|
||||
expect(
|
||||
bindingMatchesRuntimeManifest(
|
||||
{
|
||||
hostPid: 123,
|
||||
runtimeVersion: 'tm-wechat-runtime/0.1.0',
|
||||
embeddedAgentSha256: AGENT_HASH,
|
||||
embeddedAddressProfileSha256: ADDRESS_PROFILE_HASH
|
||||
},
|
||||
manifest
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
bindingMatchesRuntimeManifest(
|
||||
{ hostPid: 123, runtimeVersion: 'tm-wechat-runtime/0.1.0' },
|
||||
manifest
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
bindingMatchesRuntimeManifest(
|
||||
{
|
||||
runtimeVersion: 'tm-wechat-runtime/0.1.0',
|
||||
embeddedAgentSha256: AGENT_HASH
|
||||
},
|
||||
manifest
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a manifest whose embedded address profile hash is absent from the dylib', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'tm-native-runtime-profile-mismatch-'))
|
||||
createArtifact(root)
|
||||
writeFileSync(
|
||||
join(root, 'runtime-manifest.json'),
|
||||
JSON.stringify({
|
||||
runtime: 'tm-wechat-native',
|
||||
version: '0.1.0',
|
||||
platform: 'darwin-arm64',
|
||||
architecture: 'arm64',
|
||||
protocolVersion: 1,
|
||||
buildCommit: 'test',
|
||||
tmSendSourceSha256: AGENT_HASH,
|
||||
addressProfileSha256: 'c'.repeat(64),
|
||||
supportedWechatVersions: ['4.1.11.53'],
|
||||
capabilities: ['text', 'image', 'voice']
|
||||
})
|
||||
)
|
||||
expect(() => readMacWechatRuntimeManifest(root, 'arm64')).toThrow(/address profile hash/)
|
||||
})
|
||||
|
||||
it('prepares an exact runtime tree and removes old external agent and table directories', () => {
|
||||
const source = mkdtempSync(join(tmpdir(), 'tm-native-source-'))
|
||||
const target = mkdtempSync(join(tmpdir(), 'tm-native-target-parent-'))
|
||||
const targetRuntime = join(target, 'darwin-arm64')
|
||||
createArtifact(source)
|
||||
mkdirSync(join(targetRuntime, 'scripts'), { recursive: true })
|
||||
writeFileSync(join(targetRuntime, 'scripts', 'tm_send.js'), 'old external agent')
|
||||
mkdirSync(join(targetRuntime, 'wechat_version'), { recursive: true })
|
||||
writeFileSync(join(targetRuntime, 'wechat_version', '4_1_11_53_mac.json'), '{}')
|
||||
|
||||
expect(readAndValidateArtifact(source).manifest.tmSendSourceSha256).toBe(AGENT_HASH)
|
||||
syncArtifact(source, targetRuntime)
|
||||
|
||||
expect(existsSync(join(targetRuntime, 'scripts'))).toBe(false)
|
||||
expect(existsSync(join(targetRuntime, 'wechat_version'))).toBe(false)
|
||||
expect(existsSync(join(targetRuntime, 'runtime-manifest.json'))).toBe(true)
|
||||
expect(readAndValidateArtifact(targetRuntime).manifest.tmSendSourceSha256).toBe(AGENT_HASH)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: () => '/fixture/app',
|
||||
isPackaged: false
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
deriveMacWechatSenderStatus,
|
||||
shouldClearWechatRestartRequired
|
||||
} from '../../src/main/services/mac-wechat-runtime-manager'
|
||||
import type {
|
||||
MacWechatBindingStatus,
|
||||
MacWechatRuntimeManifest,
|
||||
MacWechatRuntimeSnapshot
|
||||
} from '../../src/shared/personal-wechat-mac-runtime'
|
||||
|
||||
const AGENT_HASH = 'a'.repeat(64)
|
||||
const ADDRESS_PROFILE_HASH = 'b'.repeat(64)
|
||||
|
||||
const manifest: MacWechatRuntimeManifest = {
|
||||
runtime: 'tm-wechat-native',
|
||||
version: '0.1.0',
|
||||
platform: 'darwin-arm64',
|
||||
architecture: 'arm64',
|
||||
protocolVersion: 1,
|
||||
buildCommit: 'test',
|
||||
tmSendSourceSha256: AGENT_HASH,
|
||||
addressProfileSha256: ADDRESS_PROFILE_HASH,
|
||||
supportedWechatVersions: ['4.1.11.53'],
|
||||
capabilities: ['text', 'image', 'voice']
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<MacWechatRuntimeSnapshot> = {}): MacWechatRuntimeSnapshot {
|
||||
return {
|
||||
lifecycle: 'online',
|
||||
wechatRestartRequired: false,
|
||||
endpoint: 'http://127.0.0.1:4290',
|
||||
port: 4290,
|
||||
manifest,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function binding(overrides: Partial<MacWechatBindingStatus> = {}): MacWechatBindingStatus {
|
||||
return {
|
||||
runtime: true,
|
||||
hostPid: 123,
|
||||
runtimeState: 'ready',
|
||||
runtimeVersion: 'tm-wechat-runtime/0.1.0',
|
||||
embeddedAgentSha256: AGENT_HASH,
|
||||
embeddedAddressProfileSha256: ADDRESS_PROFILE_HASH,
|
||||
wechatRunning: true,
|
||||
wechatPid: 456,
|
||||
wechatVersion: '4.1.11.53',
|
||||
supported: true,
|
||||
bindingState: 'ready',
|
||||
bound: true,
|
||||
boundWechatPid: 456,
|
||||
sessionAttached: true,
|
||||
scriptLoaded: true,
|
||||
sendContextReady: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
canSendVoice: true,
|
||||
attachCount: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('macOS native runtime sender status mapping', () => {
|
||||
it('exposes capabilities only for a ready binding with matching runtime identity', () => {
|
||||
expect(
|
||||
deriveMacWechatSenderStatus({ snapshot: snapshot(), binding: binding(), manifest })
|
||||
).toMatchObject({
|
||||
state: 'online',
|
||||
runtimeReady: true,
|
||||
attachReady: true,
|
||||
baseAddressReady: true,
|
||||
messageListenerReady: true,
|
||||
canSend: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
canSendVoice: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a live host whose embedded runtime identity does not match the manifest', () => {
|
||||
const status = deriveMacWechatSenderStatus({
|
||||
snapshot: snapshot(),
|
||||
binding: binding({ embeddedAgentSha256: 'b'.repeat(64) }),
|
||||
manifest
|
||||
})
|
||||
|
||||
expect(status).toMatchObject({
|
||||
state: 'error',
|
||||
runtimeReady: false,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false
|
||||
})
|
||||
expect(status.error).toContain('Native Runtime 不一致')
|
||||
})
|
||||
|
||||
it('maps a stale binding to a rebind-required stopped state', () => {
|
||||
expect(
|
||||
deriveMacWechatSenderStatus({
|
||||
snapshot: snapshot(),
|
||||
binding: binding({ bindingState: 'stale', sendContextReady: false }),
|
||||
manifest
|
||||
})
|
||||
).toMatchObject({
|
||||
state: 'stopped',
|
||||
message: '检测到微信已重启,请重新绑定',
|
||||
canSend: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps restart-required errors above otherwise ready binding state', () => {
|
||||
expect(
|
||||
deriveMacWechatSenderStatus({
|
||||
snapshot: snapshot({ wechatRestartRequired: true }),
|
||||
binding: binding(),
|
||||
manifest
|
||||
})
|
||||
).toMatchObject({ state: 'error', canSend: false })
|
||||
})
|
||||
|
||||
it('clears restart-required only after WeChat has a different positive pid', () => {
|
||||
expect(shouldClearWechatRestartRequired(100, 200)).toBe(true)
|
||||
expect(shouldClearWechatRestartRequired(100, 100)).toBe(false)
|
||||
expect(shouldClearWechatRestartRequired(0, 200)).toBe(false)
|
||||
expect(shouldClearWechatRestartRequired(100, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ const status = (
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
wechatPid: 123,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpoint: '127.0.0.1:4290',
|
||||
endpointReady: true,
|
||||
runtimeReady: true,
|
||||
attachReady: true,
|
||||
@@ -31,6 +31,27 @@ const status = (
|
||||
})
|
||||
|
||||
describe('PersonalWechatCapabilityService', () => {
|
||||
it('uses only the native runtime status on macOS', async () => {
|
||||
const legacyGetStatus = vi.fn(async () => status({ state: 'error' }))
|
||||
const nativeStatus = status({
|
||||
state: 'online',
|
||||
boundWechatPid: 123,
|
||||
canSend: true,
|
||||
canSendText: true
|
||||
})
|
||||
const service = new PersonalWechatCapabilityService(
|
||||
{ getStatus: legacyGetStatus },
|
||||
'darwin',
|
||||
async () => nativeStatus
|
||||
)
|
||||
|
||||
await expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
capabilities: { text: true }
|
||||
})
|
||||
expect(legacyGetStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unsupported', status({ platform: 'linux' })],
|
||||
['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })],
|
||||
@@ -64,7 +85,11 @@ describe('PersonalWechatCapabilityService', () => {
|
||||
})
|
||||
]
|
||||
])('maps %s', (expected, senderStatus) => {
|
||||
const service = new PersonalWechatCapabilityService({ getStatus: async () => senderStatus })
|
||||
const service = new PersonalWechatCapabilityService(
|
||||
{ getStatus: async () => senderStatus },
|
||||
senderStatus.platform as NodeJS.Platform,
|
||||
async () => senderStatus
|
||||
)
|
||||
return expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({
|
||||
status: expected,
|
||||
ready: expected === 'ready',
|
||||
|
||||
@@ -22,14 +22,10 @@ vi.mock('electron', () => ({
|
||||
import {
|
||||
buildPersonalWechatVoiceDiagnostic,
|
||||
buildWindowsWechatRequest,
|
||||
buildPersonalWechatOneBotRequest,
|
||||
findWechatImagePath,
|
||||
findPersonalWechatRuntime,
|
||||
normalizeWindowsWechatPort,
|
||||
parseWindowsLoginStatus,
|
||||
parseWindowsHookResponse,
|
||||
parsePersonalWechatHookLog,
|
||||
deriveMacSendCapabilities,
|
||||
prepareWindowsImageFile,
|
||||
WindowsHookHttpError
|
||||
} from '../../src/main/services/personal-wechat-send-service'
|
||||
@@ -48,7 +44,7 @@ afterEach(() => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('personal WeChat OneBot request', () => {
|
||||
describe('personal WeChat voice diagnostics', () => {
|
||||
it('keeps voice diagnostics to the safe metadata allowlist', () => {
|
||||
const diagnostic = buildPersonalWechatVoiceDiagnostic('request-1', 'completed', {
|
||||
input_bytes: 10,
|
||||
@@ -79,70 +75,6 @@ describe('personal WeChat OneBot request', () => {
|
||||
expect(diagnostic.error).not.toContain('token-value')
|
||||
expect(diagnostic.error).not.toContain('json-secret')
|
||||
})
|
||||
|
||||
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('Windows request', () => {
|
||||
@@ -292,157 +224,4 @@ describe('personal WeChat runtime discovery', () => {
|
||||
`${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('marks media ready from initialized hooks without an upload capture event', () => {
|
||||
expect(
|
||||
deriveMacSendCapabilities({
|
||||
attached: true,
|
||||
baseAddressReady: true,
|
||||
textHookInstalled: true,
|
||||
textHookReady: true,
|
||||
imageHookInstalled: true,
|
||||
imagePathBound: true
|
||||
})
|
||||
).toEqual({ canSend: true, canSendText: true, canSendImage: true, canSendVoice: true })
|
||||
})
|
||||
|
||||
it('keeps sending unavailable until the native send context is captured', () => {
|
||||
expect(
|
||||
deriveMacSendCapabilities({
|
||||
attached: true,
|
||||
baseAddressReady: true,
|
||||
textHookInstalled: true,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: true,
|
||||
imagePathBound: true
|
||||
}).canSend
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { findRuntime, buildEnvironment, writeLog } = vi.hoisted(() => ({
|
||||
findRuntime: vi.fn(),
|
||||
const { isRuntimePresent, buildEnvironment, writeLog } = vi.hoisted(() => ({
|
||||
isRuntimePresent: vi.fn(),
|
||||
buildEnvironment: vi.fn(),
|
||||
writeLog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
||||
findPersonalWechatRuntime: findRuntime,
|
||||
buildPersonalWechatRuntimeEnvironment: buildEnvironment
|
||||
}))
|
||||
vi.mock('../../src/main/services/mac-wechat-runtime-manager', () => ({
|
||||
macWechatRuntimeManager: { isRuntimePresent: () => true }
|
||||
}))
|
||||
vi.mock('../../src/main/app-logger', () => ({ appLogger: { write: writeLog } }))
|
||||
|
||||
import { PersonalWechatVoiceEnvironmentService } from '../../src/main/services/personal-wechat-voice-environment-service'
|
||||
|
||||
const runtime = {
|
||||
root: '/runtime',
|
||||
executable: '/runtime/onebot/onebot',
|
||||
workingDirectory: '/runtime/onebot',
|
||||
configDirectory: '/runtime/wechat_version',
|
||||
logPath: '/runtime/onebot/log/macos.log'
|
||||
}
|
||||
|
||||
describe('PersonalWechatVoiceEnvironmentService', () => {
|
||||
let pilkAvailable = true
|
||||
const commands: Array<{ executable: string; args: string[]; env: NodeJS.ProcessEnv }> = []
|
||||
@@ -29,11 +23,8 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
||||
beforeEach(() => {
|
||||
pilkAvailable = true
|
||||
commands.length = 0
|
||||
findRuntime.mockReset().mockReturnValue(runtime)
|
||||
buildEnvironment.mockReset().mockReturnValue({
|
||||
PATH: '/runtime/bin',
|
||||
PYTHONPATH: '/runtime/python'
|
||||
})
|
||||
isRuntimePresent.mockReset().mockReturnValue(true)
|
||||
buildEnvironment.mockReset().mockReturnValue({ PATH: '/runtime/bin' })
|
||||
writeLog.mockReset()
|
||||
})
|
||||
|
||||
@@ -41,7 +32,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
||||
return new PersonalWechatVoiceEnvironmentService({
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64',
|
||||
findRuntime,
|
||||
isRuntimePresent,
|
||||
buildEnvironment,
|
||||
now: () => new Date('2026-08-31T00:00:00.000Z'),
|
||||
runCommand: async (executable, args, options) => {
|
||||
@@ -74,7 +65,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('checks pilk with the Python and environment used by OneBot', async () => {
|
||||
it('checks pilk with the environment used by the native runtime', async () => {
|
||||
const environment = await createService().check()
|
||||
|
||||
expect(environment).toMatchObject({
|
||||
@@ -87,8 +78,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
||||
ffmpeg: { ready: true, executable: '/runtime/bin/ffmpeg', version: '7.0' }
|
||||
})
|
||||
expect(commands.find((command) => command.executable === 'python3')?.env).toEqual({
|
||||
PATH: '/runtime/bin',
|
||||
PYTHONPATH: '/runtime/python'
|
||||
PATH: '/runtime/bin'
|
||||
})
|
||||
expect(commands.some((command) => command.executable === '/runtime/python3')).toBe(true)
|
||||
expect(writeLog).toHaveBeenCalledWith(
|
||||
@@ -99,12 +89,12 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports go-silk fallback and installs the exact pilk version with the detected Python', async () => {
|
||||
it('reports the built-in SILK fallback and installs the exact pilk version', async () => {
|
||||
pilkAvailable = false
|
||||
const service = createService()
|
||||
const before = await service.check()
|
||||
|
||||
expect(before).toMatchObject({ state: 'incomplete', ready: false, encoder: 'go-silk' })
|
||||
expect(before).toMatchObject({ state: 'incomplete', ready: false, encoder: 'silk' })
|
||||
expect(before.pilk.ready).toBe(false)
|
||||
|
||||
const result = await service.installPilk()
|
||||
@@ -123,7 +113,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
||||
const service = new PersonalWechatVoiceEnvironmentService({
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64',
|
||||
findRuntime,
|
||||
isRuntimePresent,
|
||||
buildEnvironment,
|
||||
runCommand: async () => {
|
||||
throw new Error('spawn python3 ENOENT')
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('sticker HTTP failures', () => {
|
||||
})
|
||||
|
||||
describe('personal WeChat runtime security invariants', () => {
|
||||
it('waits for sender termination during application shutdown', () => {
|
||||
it('waits for the macOS native runtime to detach during application shutdown', () => {
|
||||
const mainSource = readFileSync(resolve('src/main/index.ts'), 'utf8')
|
||||
const shutdownStart = mainSource.indexOf("app.on('before-quit'")
|
||||
const shutdownEnd = mainSource.indexOf('function showMainWindow', shutdownStart)
|
||||
@@ -62,39 +62,16 @@ describe('personal WeChat runtime security invariants', () => {
|
||||
expect(shutdownStart).toBeGreaterThanOrEqual(0)
|
||||
expect(shutdownEnd).toBeGreaterThan(shutdownStart)
|
||||
expect(shutdownSource).toContain('await Promise.all([')
|
||||
expect(shutdownSource).toContain('personalWechatSendService.terminate()')
|
||||
expect(shutdownSource).not.toContain('personalWechatSendService.stop()')
|
||||
|
||||
const senderSource = readFileSync(
|
||||
resolve('src/main/services/personal-wechat-send-service.ts'),
|
||||
'utf8'
|
||||
)
|
||||
expect(senderSource).toContain("process.kill(pid, 'SIGTERM')")
|
||||
expect(senderSource).toContain("process.kill(pid, 'SIGKILL')")
|
||||
expect(senderSource).toContain('const trackedPid = this.child?.pid')
|
||||
expect(shutdownSource).toContain('macWechatRuntimeManager.shutdown()')
|
||||
})
|
||||
|
||||
it('does not install Python packages while preparing the sender runtime', () => {
|
||||
const runtimeManagerSource = readFileSync(
|
||||
resolve('src/main/services/personal-wechat-runtime-manager.ts'),
|
||||
'utf8'
|
||||
)
|
||||
it('does not install Python packages while preparing the native runtime', () => {
|
||||
const preparationScriptSource = readFileSync(
|
||||
resolve('scripts/prepare-wechat-chatter-runtime.cjs'),
|
||||
resolve('scripts/prepare-wechat-native-runtime.cjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
for (const source of [runtimeManagerSource, preparationScriptSource]) {
|
||||
expect(source).not.toContain('pilk==')
|
||||
expect(source).not.toMatch(/['"]pip['"]/)
|
||||
expect(source).toContain('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')
|
||||
expect(source).toContain('上传前按语音长度重新分配')
|
||||
}
|
||||
|
||||
const senderSource = readFileSync(
|
||||
resolve('src/main/services/personal-wechat-send-service.ts'),
|
||||
'utf8'
|
||||
)
|
||||
expect(senderSource).toContain('buildPersonalWechatRuntimeEnvironment(preflight.runtime.root)')
|
||||
expect(preparationScriptSource).not.toContain('pilk==')
|
||||
expect(preparationScriptSource).not.toMatch(/['"]pip['"]/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,9 +9,9 @@ vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-settings-test
|
||||
* settings-store 在模块加载时确定 settings.json 路径并缓存设置对象,
|
||||
* 所以每个用例都要用独立的目录 + 重新加载模块。
|
||||
*/
|
||||
async function importStore(directory: string): Promise<
|
||||
typeof import('../../src/main/services/settings-store')
|
||||
> {
|
||||
async function importStore(
|
||||
directory: string
|
||||
): Promise<typeof import('../../src/main/services/settings-store')> {
|
||||
process.env.WE_SETTINGS_DIR = directory
|
||||
vi.resetModules()
|
||||
return import('../../src/main/services/settings-store')
|
||||
@@ -68,4 +68,18 @@ describe('recall protection retirement migration', () => {
|
||||
|
||||
expect(store.loadSettings().recallProtectionEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('removes the retired keep-process setting from old settings files', async () => {
|
||||
const directory = createDirectory()
|
||||
writeFileSync(
|
||||
join(directory, 'settings.json'),
|
||||
JSON.stringify({ keepPersonalWechatProcess: true }),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const store = await importStore(directory)
|
||||
|
||||
expect(store.loadSettings()).not.toHaveProperty('keepPersonalWechatProcess')
|
||||
expect(readSettingsFile(directory)).not.toHaveProperty('keepPersonalWechatProcess')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -307,6 +307,55 @@ describe('WechatActionGateway', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends a report postfix only after the image succeeds and keeps the 3s interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-23T09:00:00.000Z'))
|
||||
const gateway = createGateway()
|
||||
const sent: Array<{ type: string; at: number }> = []
|
||||
mocks.sender.send.mockImplementation(async (request: { type: string }) => {
|
||||
sent.push({ type: request.type, at: Date.now() })
|
||||
return { success: true, status: {} }
|
||||
})
|
||||
|
||||
const pending = gateway.executeReportImageSequence({
|
||||
recipient: { type: 'group', id: 'room@chatroom' },
|
||||
imagePath: '/tmp/report.png',
|
||||
postfixText: '今日日报'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sent.map((item) => item.type)).toEqual(['image'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS - 1)
|
||||
expect(sent).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
const result = await pending
|
||||
|
||||
expect(result.image.status).toBe('sent')
|
||||
expect(result.postfix?.status).toBe('sent')
|
||||
expect(sent.map((item) => item.type)).toEqual(['image', 'text'])
|
||||
expect(sent[1].at - sent[0].at).toBe(AUTOMATION_SEND_INTERVAL_MS)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not create the report postfix action when the image fails', async () => {
|
||||
const gateway = createGateway()
|
||||
mocks.sender.send.mockResolvedValueOnce({
|
||||
success: false,
|
||||
status: {},
|
||||
error: 'image failed'
|
||||
})
|
||||
|
||||
const result = await gateway.executeReportImageSequence({
|
||||
recipient: { type: 'group', id: 'room@chatroom' },
|
||||
imagePath: '/tmp/report.png',
|
||||
postfixText: '今日日报'
|
||||
})
|
||||
|
||||
expect(result.image.status).toBe('failed')
|
||||
expect(result.postfix).toBeUndefined()
|
||||
expect(mocks.sender.send).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets a user action send immediately while an automatic action is waiting', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-03T09:30:00.000Z'))
|
||||
|
||||
Reference in New Issue
Block a user