feat: mac发送能力重构为libtmwechat

- 发送改走本地host, 在微信登录时触发
- 删除旧OneBot及对应UI控制面
This commit is contained in:
Wxw-Gu
2026-09-24 03:01:03 +08:00
parent 069d836dea
commit 7268188e40
50 changed files with 2442 additions and 4141 deletions
@@ -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',
+1 -222
View File
@@ -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')
+6 -29
View File
@@ -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')
})
})
+49
View File
@@ -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'))