= ({
API 默认仅监听本机,并通过 Bearer Token 保护数据接口。Token 请在 API Center
中显示或复制。关闭后 Claude / Codex 等客户端无法读取聊天数据。
- 配置文档(兼容路径):docs/skill/wechatexplorer-reader/SKILL.md
+ 配置文档:docs/skill/tracememo-reader/SKILL.md
diff --git a/src/renderer/src/features/api-center/components/SkillDetails.tsx b/src/renderer/src/features/api-center/components/SkillDetails.tsx
index 9bfbaa5..559b154 100644
--- a/src/renderer/src/features/api-center/components/SkillDetails.tsx
+++ b/src/renderer/src/features/api-center/components/SkillDetails.tsx
@@ -21,8 +21,8 @@ export function SkillDetails({
TraceMemo Reader
-
兼容标识
- wechatexplorer-reader
+ 标识
+ tracememo-reader
版本
diff --git a/src/renderer/src/features/api-center/model/skillDistribution.ts b/src/renderer/src/features/api-center/model/skillDistribution.ts
index c0bd55b..5e72d3b 100644
--- a/src/renderer/src/features/api-center/model/skillDistribution.ts
+++ b/src/renderer/src/features/api-center/model/skillDistribution.ts
@@ -12,6 +12,6 @@ export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string }
]
export const githubLatestUrl =
- 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader'
+ 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader'
export const githubVersionedInstallUrl: string | null = null
diff --git a/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts b/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts
index 4772e53..b2e2aac 100644
--- a/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts
+++ b/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts
@@ -33,7 +33,7 @@ export function buildSkillInstallInstruction({
source.type === 'local'
? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:`
: `请从以下地址安装 TraceMemo Reader Skill:\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:`
- return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 TraceMemo → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 WECHATEXPLORER_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $WECHATEXPLORER_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。`
+ return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 TraceMemo → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 TRACEMEMO_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $TRACEMEMO_API_TOKEN。若旧配置尚未升级,可兼容读取 WECHATEXPLORER_API_TOKEN,但新配置必须优先使用 TRACEMEMO_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。`
}
export function buildSkillVerificationPrompt(): string {
diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs
index 1ebaffe..af713c2 100644
--- a/tests/e2e/support/electron-main.cjs
+++ b/tests/e2e/support/electron-main.cjs
@@ -4,7 +4,7 @@ const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '../../..')
-const fixture = require(path.join(root, 'tests/fixtures/chat-data.json'))
+const fixture = structuredClone(require(path.join(root, 'tests/fixtures/chat-data.json')))
const userData = process.env.WXE_E2E_USER_DATA
if (!userData) throw new Error('WXE_E2E_USER_DATA is required')
app.setPath('userData', userData)
@@ -15,6 +15,151 @@ const VALID_KEY = 'a'.repeat(64)
const imageData =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
+
+const formatFixtureDateTime = (timestampSeconds) => {
+ const date = new Date(timestampSeconds * 1000)
+ const pad = (value) => String(value).padStart(2, '0')
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
+}
+
+const allFixtureMessages = Object.values(fixture.messages).flat()
+const latestFixtureTime = Math.max(...allFixtureMessages.map((message) => message.createTime || 0))
+const fixtureTimeOffset = Math.floor(Date.now() / 1000) - 3600 - latestFixtureTime
+for (const message of allFixtureMessages) {
+ message.createTime = (message.createTime || latestFixtureTime) + fixtureTimeOffset
+ message.datetime = formatFixtureDateTime(message.createTime)
+}
+
+const emptyTimings = () => ({
+ queryUnderstandingMs: 0,
+ contactResolutionMs: 0,
+ knowledgeSearchMs: 0,
+ workerIpcMs: 0,
+ workerBootMs: 0,
+ dispatchMs: 0,
+ workerSqlMs: 0,
+ responseSerializeMs: 0,
+ responseTransferMs: 0,
+ workerQueueMs: 0,
+ workerExecutionMs: 0,
+ globalCountMs: 0,
+ voiceCoverageMs: 0,
+ wcdbQueueMs: 0,
+ wcdbExecutionMs: 0,
+ senderEnrichmentMs: 0,
+ ipcMs: 0,
+ serializationMs: 0,
+ otherMs: 0,
+ ftsMs: 0,
+ chunkExpandMs: 0,
+ messageLoadMs: 0,
+ rankingMs: 0,
+ candidateRankingMs: 0,
+ evidenceBuildMs: 0,
+ aggregationMs: 0,
+ contextPreparationMs: 0,
+ agentDecisionMs: 0,
+ agentToolMs: 0,
+ aiGenerationMs: 0,
+ totalMs: 1
+})
+
+const aiSearchResult = (request) => {
+ const failure = process.env.WXE_E2E_AI_FAILURE
+ const evidence = [
+ {
+ id: 'E1',
+ chunkId: 'fixture-chunk',
+ conversationId: 'group-regular-md5',
+ conversationName: '产品测试群',
+ conversationType: 'group',
+ startTime: fixture.messages['group-regular-md5'][0].createTime * 1000,
+ endTime: fixture.messages['group-regular-md5'][0].createTime * 1000,
+ messageId: 'msg-text',
+ senderId: 'wxid_fixture_member',
+ sender: '测试成员',
+ timestamp: fixture.messages['group-regular-md5'][0].createTime * 1000,
+ messageIds: ['msg-text'],
+ sourceKind: 'text',
+ text: '这是一条脱敏测试消息',
+ score: 1
+ }
+ ]
+ return {
+ requestId: request.requestId,
+ status: failure ? 'ai_failed' : 'completed',
+ plan: {
+ intent: 'general',
+ keywords: ['测试'],
+ variants: [],
+ source: 'local',
+ scopeLabel: '全局搜索',
+ rangeLabel: '近 30 天',
+ timeRange: {
+ startTime: Math.floor(Date.now() / 1000) - 30 * 86400,
+ endTime: Math.floor(Date.now() / 1000),
+ label: '近 30 天',
+ reason: 'E2E fixture',
+ source: 'ui'
+ },
+ contactNames: []
+ },
+ knowledge: {
+ source: 'knowledge',
+ state: 'ready',
+ indexedMessageCount: allFixtureMessages.length,
+ indexedChunkCount: 1,
+ totalMessages: allFixtureMessages.length,
+ voiceCoverage: {
+ voiceMessageCount: 1,
+ transcribedVoiceCount: 1,
+ failedVoiceCount: 0,
+ voiceCoverageComplete: true
+ }
+ },
+ candidateEvidenceCount: 1,
+ retrieval: {
+ intent: 'general',
+ timeRange: {
+ startTime: Math.floor(Date.now() / 1000) - 30 * 86400,
+ endTime: Math.floor(Date.now() / 1000),
+ label: '近 30 天',
+ reason: 'E2E fixture',
+ source: 'ui'
+ },
+ retrievalMode: 'global_fts',
+ candidateCount: 1,
+ uniqueCandidateCount: 1,
+ sourceMessageCount: allFixtureMessages.length,
+ sourceCoverage: 'complete',
+ isComplete: true,
+ fallbackUsed: false,
+ suspicious: false
+ },
+ evidence,
+ contextEvidenceCount: 1,
+ aggregation: {
+ messageCount: 1,
+ peopleCount: 1,
+ conversationCount: 1,
+ people: [],
+ conversations: []
+ },
+ agent: { mode: 'fallback', toolCalls: 0, trace: [], fallbackReason: 'E2E fixture' },
+ citationValidation: { status: 'valid', invalidCitationIds: [] },
+ timings: emptyTimings(),
+ answer: failure ? undefined : '固定假回答:测试数据中的核心流程正常。',
+ ai: {
+ providerName: '本地假服务',
+ modelName: '固定响应模型',
+ inputTokens: 10,
+ inputTokensEstimated: false
+ },
+ error: failure ? `本地假服务错误 ${failure}` : undefined,
+ errorStage: failure ? 'ai_generating' : undefined,
+ elapsedMs: 1
+ }
+}
const reportJson = JSON.stringify({
overview: '固定脱敏日报',
hero: {
@@ -88,7 +233,7 @@ const handle = (channel, fn) => {
const startupCache = () => ({
self: fixture.self,
contacts,
- updatedAt: 1785553200000
+ updatedAt: Date.now()
})
handle('settings:get', () => ({ settings, settingsPath: path.join(userData, 'settings.json') }))
@@ -113,11 +258,11 @@ handle('key:clearSavedDbKey', () => {
handle('key:getEnvironment', () => ({
platform: process.platform,
osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture',
- appVersion: 'v2.1.6',
+ appVersion: 'v2.2.0',
wechatVersion: '4.1.9.57',
dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.x(WCDB)' : '未检测到',
dataDirectoryDetected: settings.dbRoot === 'fixture-account',
- diagnosticSummary: 'TraceMemo: v2.1.6\n数据目录: 已检测到',
+ diagnosticSummary: 'TraceMemo: v2.2.0\n数据目录: 已检测到',
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: connected,
@@ -287,6 +432,45 @@ handle('ai:chat', (messages) => {
}
return { success: true, data: '固定假回答:测试数据中的核心流程正常。' }
})
+handle('knowledge:getStatus', () => ({
+ accountId: fixture.self.wxid,
+ state: 'ready',
+ indexedMessageCount: allFixtureMessages.length,
+ indexedChunkCount: 1,
+ sourceMessageCount: allFixtureMessages.length,
+ processedMessages: allFixtureMessages.length,
+ totalMessages: allFixtureMessages.length,
+ estimatedRemainingMs: 0,
+ databaseBytes: 1024,
+ walBytes: 0,
+ shmBytes: 0
+}))
+handle('knowledge:startIndex', () => ({ success: true }))
+handle('knowledge:search', () => ({
+ source: 'knowledge',
+ state: 'ready',
+ evidence: [],
+ indexedMessageCount: allFixtureMessages.length,
+ indexedChunkCount: 1,
+ totalMessages: allFixtureMessages.length,
+ timings: {
+ workerIpcMs: 0,
+ workerBootMs: 0,
+ dispatchMs: 0,
+ workerSqlMs: 0,
+ responseTransferMs: 0,
+ responseSerializeMs: 0,
+ ftsMs: 0,
+ messageLoadMs: 0,
+ chunkExpandMs: 0,
+ rankingMs: 0,
+ totalMs: 0
+ }
+}))
+handle('ai-search:getProviderStatus', () => ({ configured: true, requiresConsent: false }))
+handle('ai-search:authorizeExternalProvider', () => ({ success: true }))
+handle('ai-search:run', (request) => aiSearchResult(request))
+handle('ai-search:cancel', () => ({ cancelled: true }))
handle('report:export', () => {
const htmlPath = path.join(userData, 'fixture-report.html')
@@ -424,7 +608,7 @@ handle('accounts:discover', (inputPath) =>
)
handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false }))
handle('agent-hub:getLogs', () => [])
-handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' }))
+handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.2.0' }))
for (const channel of [
'export:start',
diff --git a/tests/unit/api-token-store.test.ts b/tests/unit/api-token-store.test.ts
index 76a6b01..fdcf957 100644
--- a/tests/unit/api-token-store.test.ts
+++ b/tests/unit/api-token-store.test.ts
@@ -62,4 +62,19 @@ describe('ApiTokenStore', () => {
expect(fs.existsSync(filePath)).toBe(false)
expect(store.revealToken().token).toBeUndefined()
})
+
+ it('does not silently replace a legacy token when migration is deferred', () => {
+ const store = new ApiTokenStore(filePath)
+ store.setAutomaticGenerationBlocked('legacy token migration pending')
+
+ expect(store.ensureToken()).toMatchObject({
+ success: false,
+ hasToken: false,
+ error: 'legacy token migration pending'
+ })
+ expect(fs.existsSync(filePath)).toBe(false)
+
+ expect(store.rotateToken()).toMatchObject({ success: true, hasToken: true })
+ expect(fs.existsSync(filePath)).toBe(true)
+ })
})
diff --git a/tests/unit/app-data-migration.test.ts b/tests/unit/app-data-migration.test.ts
new file mode 100644
index 0000000..ed81ad2
--- /dev/null
+++ b/tests/unit/app-data-migration.test.ts
@@ -0,0 +1,183 @@
+import fs from 'fs-extra'
+import { DatabaseSync } from 'node:sqlite'
+import os from 'os'
+import path from 'path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const electronState = vi.hoisted(() => ({
+ paths: new Map([
+ ['appData', '/tmp/tracememo-migration-test-app-data'],
+ ['temp', '/tmp'],
+ ['home', '/tmp']
+ ]),
+ name: ''
+}))
+
+vi.mock('electron', () => ({
+ app: {
+ isPackaged: true,
+ setName: (name: string) => {
+ electronState.name = name
+ },
+ getPath: (name: string) => electronState.paths.get(name) || '/tmp',
+ setPath: (name: string, value: string) => electronState.paths.set(name, value)
+ },
+ dialog: { showMessageBox: vi.fn() },
+ safeStorage: {
+ isEncryptionAvailable: () => true,
+ encryptString: (value: string) => Buffer.from(value, 'utf8').reverse(),
+ decryptString: (value: Buffer) => Buffer.from(value).reverse().toString('utf8')
+ }
+}))
+
+import { assessMigration, executeMigration } from '../../src/main/app-data-migration'
+import { getUserDataRoots } from '../../src/main/app-data-paths'
+
+let root = ''
+
+beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'tracememo-migration-'))
+})
+
+afterEach(() => {
+ fs.removeSync(root)
+})
+
+function roots() {
+ return getUserDataRoots(path.join(root, 'Application Support'))
+}
+
+function writeFixture(filePath: string, content = 'fixture'): void {
+ fs.ensureDirSync(path.dirname(filePath))
+ fs.writeFileSync(filePath, content)
+}
+
+describe('TraceMemo app data migration', () => {
+ it('treats a new TraceMemo install as clean when no legacy directory exists', () => {
+ const assessment = assessMigration(roots())
+ expect(assessment).toMatchObject({
+ shouldPrompt: false,
+ reason: 'clean-install',
+ currentHasAssets: false
+ })
+ expect(assessment.selection.selected).toBe(roots().current)
+ })
+
+ it('ignores an empty WechatExplorer legacy directory', () => {
+ const fixture = roots()
+ fs.ensureDirSync(fixture.legacy)
+ expect(assessMigration(fixture)).toMatchObject({
+ shouldPrompt: false,
+ reason: 'legacy-empty'
+ })
+ })
+
+ it('detects legacy settings but never proposes overwriting valid TraceMemo data', () => {
+ const fixture = roots()
+ writeFixture(path.join(fixture.legacy, 'settings.json'), '{"dbRoot":"legacy"}')
+ expect(assessMigration(fixture)).toMatchObject({
+ shouldPrompt: true,
+ reason: 'legacy-assets-detected',
+ sourceRoot: fixture.legacy
+ })
+
+ writeFixture(path.join(fixture.current, 'settings.json'), '{"dbRoot":"current"}')
+ expect(assessMigration(fixture)).toMatchObject({
+ shouldPrompt: false,
+ reason: 'current-data-present'
+ })
+ })
+
+ it('copies Settings, Knowledge companions, Token, Provider keys and Agent credentials', async () => {
+ const fixture = roots()
+ const source = fixture.legacy
+ const target = fixture.current
+ writeFixture(path.join(source, 'settings.json'), '{"dbRoot":"legacy-db"}')
+ writeFixture(path.join(source, 'ai-providers.json'), '{"version":1,"providers":[]}')
+ writeFixture(path.join(source, 'local-api-token.bin'), 'legacy-encrypted-token')
+ writeFixture(path.join(source, 'ai-provider-keys.bin'), 'legacy-encrypted-provider')
+
+ const knowledgeDirectory = path.join(source, 'knowledge', 'account-hash')
+ fs.ensureDirSync(knowledgeDirectory)
+ const knowledgePath = path.join(knowledgeDirectory, 'knowledge.sqlite')
+ const database = new DatabaseSync(knowledgePath)
+ database.exec('PRAGMA journal_mode=WAL')
+ database.exec('PRAGMA wal_autocheckpoint=0')
+ database.exec('CREATE TABLE messages (id INTEGER PRIMARY KEY, text TEXT NOT NULL)')
+ database.exec("INSERT INTO messages(text) VALUES ('migration fixture')")
+ expect(fs.existsSync(`${knowledgePath}-wal`)).toBe(true)
+ expect(fs.existsSync(`${knowledgePath}-shm`)).toBe(true)
+
+ const agentLegacy = path.join(root, 'home', '.wechatexplorer', 'accounts')
+ const agentCurrent = path.join(root, 'home', '.tracememo', 'accounts')
+ writeFixture(path.join(agentLegacy, 'account.json'), '{"token":"credential"}')
+ writeFixture(path.join(agentLegacy, 'account.sync.json'), '{"cursor":"1"}')
+
+ try {
+ const result = await executeMigration(source, target, {
+ decryptLegacySecrets: async () => ({
+ token: 'A'.repeat(43),
+ aiProviderKeys: { version: 1, keys: { provider: 'provider-secret' } },
+ databaseKeys: {},
+ failures: []
+ }),
+ agentRoots: () => ({ legacy: agentLegacy, current: agentCurrent }),
+ now: () => new Date('2026-08-11T00:00:00.000Z')
+ })
+
+ expect(result.state.status).toBe('completed')
+ expect(fs.readFileSync(path.join(target, 'settings.json'), 'utf8')).toContain('legacy-db')
+ expect(
+ fs.existsSync(path.join(target, 'knowledge', 'account-hash', 'knowledge.sqlite'))
+ ).toBe(true)
+ expect(
+ fs.existsSync(path.join(target, 'knowledge', 'account-hash', 'knowledge.sqlite-wal'))
+ ).toBe(true)
+ expect(
+ fs.existsSync(path.join(target, 'knowledge', 'account-hash', 'knowledge.sqlite-shm'))
+ ).toBe(true)
+ expect(
+ Buffer.from(fs.readFileSync(path.join(target, 'local-api-token.bin')))
+ .reverse()
+ .toString('utf8')
+ ).toBe('A'.repeat(43))
+ expect(
+ JSON.parse(
+ Buffer.from(fs.readFileSync(path.join(target, 'ai-provider-keys.bin')))
+ .reverse()
+ .toString('utf8')
+ )
+ ).toEqual({ version: 1, keys: { provider: 'provider-secret' } })
+ expect(fs.readFileSync(path.join(agentCurrent, 'account.json'), 'utf8')).toContain(
+ 'credential'
+ )
+ expect(fs.existsSync(path.join(source, 'settings.json'))).toBe(true)
+ expect(
+ fs.existsSync(path.join(source, 'knowledge', 'account-hash', 'knowledge.sqlite'))
+ ).toBe(true)
+ } finally {
+ database.close()
+ }
+ })
+
+ it('is idempotent and does not overwrite existing TraceMemo assets', async () => {
+ const fixture = roots()
+ writeFixture(path.join(fixture.legacy, 'settings.json'), '{"dbRoot":"legacy"}')
+ writeFixture(path.join(fixture.current, 'settings.json'), '{"dbRoot":"current"}')
+
+ const result = await executeMigration(fixture.legacy, fixture.current, {
+ decryptLegacySecrets: async () => ({ databaseKeys: {}, failures: [] }),
+ agentRoots: () => ({
+ legacy: path.join(root, 'agent-legacy'),
+ current: path.join(root, 'agent-current')
+ }),
+ now: () => new Date('2026-08-11T00:00:00.000Z')
+ })
+
+ expect(result.state.items['settings.json']).toBe('skipped')
+ expect(fs.readFileSync(path.join(fixture.current, 'settings.json'), 'utf8')).toContain(
+ 'current'
+ )
+ expect(fs.readFileSync(path.join(fixture.legacy, 'settings.json'), 'utf8')).toContain('legacy')
+ })
+})
diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts
index 74a70df..c4d1967 100644
--- a/tests/unit/runtime-packaging.test.ts
+++ b/tests/unit/runtime-packaging.test.ts
@@ -39,11 +39,11 @@ describe('production runtime packaging', () => {
it('requires the bundled Reader Skill declared by extraResources', () => {
const resources = join(root, 'reader-skill-resources')
- const skillPath = join(resources, 'skill', 'wechatexplorer-reader', 'SKILL.md')
+ const skillPath = join(resources, 'skill', 'tracememo-reader', 'SKILL.md')
const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8')
- expect(config).toContain('docs/skill/wechatexplorer-reader')
- expect(config).toContain('to: skill/wechatexplorer-reader')
+ expect(config).toContain('docs/skill/tracememo-reader')
+ expect(config).toContain('to: skill/tracememo-reader')
expect(() => validateReaderSkillRuntime(resources)).toThrow(
/Missing bundled TraceMemo Reader Skill/
)
diff --git a/tests/unit/skill-resource-service.test.ts b/tests/unit/skill-resource-service.test.ts
index 28a7858..cb57dae 100644
--- a/tests/unit/skill-resource-service.test.ts
+++ b/tests/unit/skill-resource-service.test.ts
@@ -38,13 +38,13 @@ describe('Reader Skill resource resolution', () => {
it('finds the repository Skill from the current working directory in development', () => {
const root = fixtureRoot()
const runtime = environment(root, false)
- const skillPath = join(runtime.cwd, 'docs', 'skill', 'wechatexplorer-reader', 'SKILL.md')
+ const skillPath = join(runtime.cwd, 'docs', 'skill', 'tracememo-reader', 'SKILL.md')
writeSkill(skillPath)
expect(resolveSkillResourceStatus(runtime)).toMatchObject({
available: true,
source: 'development',
- version: 'v1.1',
+ version: 'v1.2',
filePath: skillPath,
directoryPath: dirname(skillPath)
})
@@ -63,12 +63,25 @@ describe('Reader Skill resource resolution', () => {
expect(status).toMatchObject({
available: true,
source: 'development',
- version: 'v1.1',
- filePath: join(workspace, 'docs', 'skill', 'wechatexplorer-reader', 'SKILL.md')
+ version: 'v1.2',
+ filePath: join(workspace, 'docs', 'skill', 'tracememo-reader', 'SKILL.md')
})
})
it('uses the extraResources Skill directory in a packaged runtime', () => {
+ const root = fixtureRoot()
+ const runtime = environment(root, true)
+ const skillPath = join(runtime.resourcesPath, 'skill', 'tracememo-reader', 'SKILL.md')
+ writeSkill(skillPath)
+
+ expect(resolveSkillResourceStatus(runtime)).toMatchObject({
+ available: true,
+ source: 'bundled',
+ filePath: skillPath
+ })
+ })
+
+ it('falls back to the legacy Skill directory for one compatibility release', () => {
const root = fixtureRoot()
const runtime = environment(root, true)
const skillPath = join(runtime.resourcesPath, 'skill', 'wechatexplorer-reader', 'SKILL.md')
diff --git a/tests/unit/wcdb-message-shards.test.ts b/tests/unit/wcdb-message-shards.test.ts
index 83e3036..44734e8 100644
--- a/tests/unit/wcdb-message-shards.test.ts
+++ b/tests/unit/wcdb-message-shards.test.ts
@@ -1,4 +1,5 @@
import fs from 'fs-extra'
+import crypto from 'crypto'
import os from 'os'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -114,6 +115,28 @@ describe('WCDB message shard pagination', () => {
expect(first).toBe(second)
expect(first).not.toContain('微信聊天记录')
+ expect(first).toContain(path.join('TraceMemo', 'path-bridges'))
expect(fs.realpathSync(first)).toBe(fs.realpathSync(accountRoot))
})
+
+ it('reuses an existing legacy Windows path bridge without creating a new one', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-legacy-path-bridge-'))
+ temporaryDirectories.push(root)
+ const publicRoot = path.join(root, 'Public')
+ const accountRoot = path.join(root, '微信聊天记录', 'wxid_legacy')
+ fs.ensureDirSync(path.join(accountRoot, 'db_storage'))
+ const bridgeId = crypto
+ .createHash('sha256')
+ .update(path.resolve(accountRoot).toLowerCase())
+ .digest('hex')
+ .slice(0, 24)
+ const legacyBridge = path.join(publicRoot, 'WechatExplorer', 'path-bridges', bridgeId)
+ fs.ensureDirSync(path.dirname(legacyBridge))
+ fs.symlinkSync(accountRoot, legacyBridge, 'junction')
+
+ expect(resolveWindowsNativeAccountRoot(accountRoot, { platform: 'win32', publicRoot })).toBe(
+ legacyBridge
+ )
+ expect(fs.existsSync(path.join(publicRoot, 'TraceMemo'))).toBe(false)
+ })
})