mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
Merge branch 'nanin/develop' into develop
This commit is contained in:
@@ -25,10 +25,16 @@ describe('export media', () => {
|
||||
const html = renderExportPage('脱敏导出')
|
||||
|
||||
expect(EXPORT_PAGE_SIZE).toBe(240)
|
||||
expect(html).toContain('<script src="data/messages.js"></script>')
|
||||
expect(html).toContain("dataScript.src = 'data/messages.js'")
|
||||
expect(html).toContain('id="archive-loading"')
|
||||
expect(html).toContain('正在加载聊天档案')
|
||||
expect(html).toContain('requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0))')
|
||||
expect(html).toContain('aria-label="聊天时间轴"')
|
||||
expect(html).toContain('aria-expanded="')
|
||||
expect(html).toContain('setExpandedTimelineYear')
|
||||
expect(html).toContain('data-kind="media"')
|
||||
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
|
||||
expect(html).toContain('font-size: 16px;')
|
||||
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
|
||||
expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)')
|
||||
expect(html).toContain('scheduleWindowSlide')
|
||||
@@ -68,7 +74,7 @@ describe('export media', () => {
|
||||
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(EXPORT_PAGE_SIZE)
|
||||
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
|
||||
'已显示 240 / 筛选结果 500 / 全部 500'
|
||||
'已显示 240 / 筛选 500 / 全部 500'
|
||||
)
|
||||
const list = dom.window.document.querySelector('#messages')!
|
||||
expect(list.querySelector('.message')?.getAttribute('data-index')).toBe('260')
|
||||
@@ -90,8 +96,227 @@ describe('export media', () => {
|
||||
search.value = 'needle'
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
|
||||
expect(dom.window.document.querySelector('#count')?.textContent).toBe('筛选结果 5 / 全部 500')
|
||||
expect(dom.window.document.querySelector('#count')?.textContent).toContain('筛选 5')
|
||||
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(5)
|
||||
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(5)
|
||||
expect(dom.window.document.querySelectorAll('.timeline-month').length).toBeGreaterThan(1)
|
||||
expect(
|
||||
dom.window.document.querySelectorAll('.timeline-year[aria-expanded="true"]')
|
||||
).toHaveLength(1)
|
||||
expect(dom.window.document.querySelectorAll('.timeline-months:not([hidden])')).toHaveLength(1)
|
||||
dom.window.close()
|
||||
})
|
||||
|
||||
it('does not match hidden sender ids when searching visible message text', () => {
|
||||
const html = renderExportPage('搜索测试')
|
||||
const dom = new JSDOM(html, { runScripts: 'outside-only' })
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: 'hidden-sender-id-match',
|
||||
from: 'user',
|
||||
type: '普通文本',
|
||||
datetime: '',
|
||||
content: '这条消息不应命中',
|
||||
name: 'Jamie',
|
||||
senderId: 'wxid_fixture_member',
|
||||
isSender: false,
|
||||
createTime: 1_767_225_600
|
||||
},
|
||||
{
|
||||
id: 'visible-content-match',
|
||||
from: 'user',
|
||||
type: '普通文本',
|
||||
datetime: '',
|
||||
content: 'https://example.com/xi',
|
||||
name: 'Cherry',
|
||||
senderId: 'wxid_fixture_self',
|
||||
isSender: true,
|
||||
createTime: 1_767_225_601
|
||||
}
|
||||
]
|
||||
Object.assign(dom.window, {
|
||||
__WECHAT_EXPORT__: {
|
||||
version: 1,
|
||||
sourceId: 'fixture',
|
||||
name: '搜索测试',
|
||||
exportedAt: '2026-08-05T00:00:00.000Z',
|
||||
messages
|
||||
}
|
||||
})
|
||||
|
||||
dom.window.eval(inlineScriptOf(html))
|
||||
const search = dom.window.document.querySelector('#query') as HTMLInputElement
|
||||
search.value = 'xi'
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
|
||||
expect(dom.window.document.querySelector('.message')?.textContent).toContain(
|
||||
'https://example.com/xi'
|
||||
)
|
||||
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(1)
|
||||
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
|
||||
'已显示 1 / 筛选 1 / 全部 2'
|
||||
)
|
||||
dom.window.close()
|
||||
})
|
||||
|
||||
it('filters a v2 merged archive by conversation before search and month counts', () => {
|
||||
const html = renderExportPage('合并档案')
|
||||
const dom = new JSDOM(html, { runScripts: 'outside-only' })
|
||||
Object.assign(dom.window, {
|
||||
__WECHAT_EXPORT__: {
|
||||
version: 2,
|
||||
name: '合并档案',
|
||||
exportedAt: '2026-08-04T00:00:00.000Z',
|
||||
conversations: [
|
||||
{ id: 'alpha', name: '聊天 A', type: 'user', messageCount: 2 },
|
||||
{ id: 'beta', name: '聊天 B', type: 'group', messageCount: 1 }
|
||||
],
|
||||
messages: [
|
||||
messageForArchive('alpha-1', 'alpha', '聊天 A', '共同关键词', 1_767_225_600),
|
||||
messageForArchive('beta-1', 'beta', '聊天 B', '共同关键词', 1_769_904_000),
|
||||
messageForArchive('alpha-2', 'alpha', '聊天 A', '仅 A 可见', 1_769_990_400)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
dom.window.eval(inlineScriptOf(html))
|
||||
|
||||
const filter = dom.window.document.querySelector('#conversation-filter')!
|
||||
const trigger = dom.window.document.querySelector('#conversation-trigger') as HTMLButtonElement
|
||||
const menu = dom.window.document.querySelector('#conversation-menu') as HTMLElement
|
||||
expect(filter.hasAttribute('hidden')).toBe(false)
|
||||
expect(filter.parentElement?.classList.contains('archive-heading')).toBe(true)
|
||||
expect((dom.window.document.querySelector('#archive-title') as HTMLElement).hidden).toBe(true)
|
||||
expect(trigger.textContent).toContain('全部聊天')
|
||||
expect(menu.querySelectorAll('[data-conversation-id]')).toHaveLength(3)
|
||||
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(3)
|
||||
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
|
||||
'已显示 3 / 筛选 3 / 全部 3'
|
||||
)
|
||||
trigger.click()
|
||||
;(menu.querySelector('[data-conversation-id="alpha"]') as HTMLButtonElement).click()
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(2)
|
||||
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(0)
|
||||
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
|
||||
'已显示 2 / 筛选 2 / 当前聊天 2'
|
||||
)
|
||||
expect(dom.window.document.querySelectorAll('.timeline-month')).toHaveLength(2)
|
||||
|
||||
const search = dom.window.document.querySelector('#query') as HTMLInputElement
|
||||
search.value = '共同关键词'
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
|
||||
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
|
||||
'已显示 1 / 筛选 1 / 当前聊天 2'
|
||||
)
|
||||
dom.window.close()
|
||||
})
|
||||
|
||||
it('locates every filtered message kind in all messages, including outside the latest window', () => {
|
||||
const html = renderExportPage('定位消息')
|
||||
const dom = new JSDOM(html, { runScripts: 'outside-only' })
|
||||
const categorized: Message[] = [
|
||||
{
|
||||
...messageForArchive('target-text', 'fixture', '定位消息', '目标文字', 1),
|
||||
type: '普通文本'
|
||||
},
|
||||
{
|
||||
...messageForArchive('target-media', 'fixture', '定位消息', '', 2),
|
||||
type: '图片',
|
||||
exportMediaType: 'image',
|
||||
exportMediaUrl: 'media/target.jpg'
|
||||
},
|
||||
{
|
||||
...messageForArchive('target-voice', 'fixture', '定位消息', '', 3),
|
||||
type: '语音',
|
||||
voiceDataUrl: 'voices/target.wav'
|
||||
},
|
||||
{
|
||||
...messageForArchive('target-file', 'fixture', '定位消息', '', 4),
|
||||
type: '文件',
|
||||
exportMediaType: 'file',
|
||||
exportMediaUrl: 'files/target.pdf'
|
||||
},
|
||||
{
|
||||
...messageForArchive('target-share', 'fixture', '定位消息', '', 5),
|
||||
type: '分享',
|
||||
contentData: { type: 'share', typeVal: '5', title: '目标分享' }
|
||||
},
|
||||
{
|
||||
...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6),
|
||||
from: 'system',
|
||||
type: '系统消息',
|
||||
contentData: { type: 'system', content: '目标系统消息' }
|
||||
}
|
||||
]
|
||||
const laterMessages = Array.from({ length: EXPORT_PAGE_SIZE }, (_, index) => ({
|
||||
...messageForArchive(
|
||||
`later-${index}`,
|
||||
'fixture',
|
||||
'定位消息',
|
||||
`稍后消息-${index}`,
|
||||
100 + index
|
||||
),
|
||||
type: '普通文本'
|
||||
}))
|
||||
Object.assign(dom.window, {
|
||||
__WECHAT_EXPORT__: {
|
||||
version: 1,
|
||||
sourceId: 'fixture',
|
||||
name: '定位消息',
|
||||
messages: [...categorized, ...laterMessages]
|
||||
}
|
||||
})
|
||||
|
||||
dom.window.eval(inlineScriptOf(html))
|
||||
|
||||
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
|
||||
for (const kind of ['media', 'voice', 'file', 'share', 'system']) {
|
||||
const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement
|
||||
filterButton.click()
|
||||
const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement
|
||||
expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置')
|
||||
expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖')
|
||||
expect(locateButton?.querySelector('.locate-label')?.textContent).toBe('定位到聊天位置')
|
||||
locateButton.click()
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
|
||||
).toBe(true)
|
||||
expect(
|
||||
dom.window.document.querySelector('.message.located')?.getAttribute('data-index')
|
||||
).toBe(String(categorized.findIndex((message) => kindOfFixture(message) === kind)))
|
||||
}
|
||||
|
||||
const textFilter = dom.window.document.querySelector('[data-kind="text"]') as HTMLElement
|
||||
textFilter.click()
|
||||
const search = dom.window.document.querySelector('#query') as HTMLInputElement
|
||||
search.value = '目标文字'
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('目标文字')
|
||||
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
|
||||
).toBe(true)
|
||||
expect(dom.window.document.querySelector('.message.located')?.getAttribute('data-index')).toBe(
|
||||
'0'
|
||||
)
|
||||
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain('目标文字')
|
||||
|
||||
search.value = '稍后消息-137'
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
expect(
|
||||
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
|
||||
).toBe(true)
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
|
||||
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('稍后消息-137')
|
||||
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
|
||||
expect(search.value).toBe('')
|
||||
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(0)
|
||||
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
|
||||
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain(
|
||||
'稍后消息-137'
|
||||
)
|
||||
dom.window.close()
|
||||
})
|
||||
|
||||
@@ -158,6 +383,36 @@ describe('export media', () => {
|
||||
expect(html).toContain('aria-label="关闭图片预览"')
|
||||
expect(html).toContain("closeButton.addEventListener('click', closeLightbox)")
|
||||
expect(html).toContain('if (event.target === box) closeLightbox()')
|
||||
expect(html).toContain("if (event.key === 'Escape') closeLightbox()")
|
||||
expect(html).toContain("if (event.key === 'Escape')")
|
||||
expect(html).toContain('closeLightbox()')
|
||||
})
|
||||
})
|
||||
|
||||
function messageForArchive(
|
||||
id: string,
|
||||
conversationId: string,
|
||||
conversationName: string,
|
||||
content: string,
|
||||
createTime: number
|
||||
): Message {
|
||||
return {
|
||||
id,
|
||||
from: 'user',
|
||||
type: '普通文本',
|
||||
datetime: '',
|
||||
content,
|
||||
isSender: false,
|
||||
createTime,
|
||||
exportConversationId: conversationId,
|
||||
exportConversationName: conversationName
|
||||
}
|
||||
}
|
||||
|
||||
function kindOfFixture(message: Message): string {
|
||||
if (message.exportMediaType === 'image') return 'media'
|
||||
if (message.voiceDataUrl) return 'voice'
|
||||
if (message.exportMediaType === 'file') return 'file'
|
||||
if (message.contentData?.type === 'share') return 'share'
|
||||
if (message.contentData?.type === 'system') return 'system'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
@@ -28,6 +28,34 @@ describe('message parser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['6', '测试附件.pdf'],
|
||||
['74', '发送中的附件.zip']
|
||||
])(
|
||||
'keeps file app message type %s when attachment metadata contains record tags',
|
||||
(typeVal, title) => {
|
||||
const parsed = parseMessageContent(
|
||||
`<appmsg><type>${typeVal}</type><title>${title}</title><des>1 MB</des><appattach><recorditem>legacy metadata</recorditem><dataitem datatype="8"><datatitle>${title}</datatitle></dataitem></appattach></appmsg>`,
|
||||
49
|
||||
)
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
type: 'share',
|
||||
title,
|
||||
typeVal
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('does not classify empty incidental record metadata as a merged forward', () => {
|
||||
const parsed = parseMessageContent(
|
||||
'<appmsg><type>5</type><title>普通分享</title><recorditem>legacy metadata</recorditem></appmsg>',
|
||||
49
|
||||
)
|
||||
|
||||
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
|
||||
})
|
||||
|
||||
it('uses the quoted group member id instead of the chatroom id', () => {
|
||||
const parsed = parseMessageContent(
|
||||
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import crypto from 'crypto'
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
|
||||
const state = vi.hoisted(() => ({ userData: '' }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => state.userData }
|
||||
}))
|
||||
|
||||
const message = (createTime: number, serverId?: string): Message => ({
|
||||
id: `message-${createTime}`,
|
||||
from: 'user',
|
||||
isSender: false,
|
||||
type: '普通文本',
|
||||
datetime: new Date(createTime * 1000).toISOString(),
|
||||
content: String(createTime),
|
||||
img: '',
|
||||
name: 'Jamie',
|
||||
sessionId: 'fixture-user',
|
||||
localId: 1,
|
||||
serverId,
|
||||
createTime
|
||||
})
|
||||
|
||||
describe('recall archive message identity', () => {
|
||||
beforeAll(() => {
|
||||
state.userData = mkdtempSync(join(tmpdir(), 'wxe-recall-identity-'))
|
||||
})
|
||||
|
||||
it('does not collapse messages whose local ids repeat across database shards', async () => {
|
||||
const accountRoot = '/fixture/account'
|
||||
const sessionMd5 = 'fixture-session'
|
||||
const archiveDir = join(state.userData, 'recall-archive')
|
||||
const archiveName = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${process.platform}:${accountRoot}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
mkdirSync(archiveDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(archiveDir, `${archiveName}.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
accountRoot,
|
||||
updatedAt: Date.now(),
|
||||
sessions: {
|
||||
[sessionMd5]: {
|
||||
username: 'fixture-user',
|
||||
updatedAt: Date.now(),
|
||||
messages: [],
|
||||
recalls: []
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
const { configureRecallArchive, mergeRecallArchiveMessages, messageIdentity } =
|
||||
await import('../../src/main/services/recall-archive-service')
|
||||
configureRecallArchive(accountRoot)
|
||||
const oldMessage = message(1_731_327_263)
|
||||
const newMessage = message(1_765_000_000)
|
||||
|
||||
expect(messageIdentity(oldMessage)).not.toBe(messageIdentity(newMessage))
|
||||
expect(mergeRecallArchiveMessages(sessionMd5, [oldMessage, newMessage])).toEqual([
|
||||
oldMessage,
|
||||
newMessage
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers the globally unique server id when one is available', async () => {
|
||||
const { messageIdentity } = await import('../../src/main/services/recall-archive-service')
|
||||
|
||||
expect(messageIdentity(message(1_731_327_263, 'server-2024'))).toBe('server:server-2024')
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,16 @@ import { dirname, join, resolve } from 'path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const { validateFfmpegRuntime, validateSherpaRuntime, validateSilkWasmRuntime } = nodeRequire(
|
||||
'../../scripts/after-pack.cjs'
|
||||
) as {
|
||||
const asar = nodeRequire('@electron/asar') as {
|
||||
createPackage: (source: string, destination: string) => Promise<void>
|
||||
}
|
||||
const {
|
||||
validateAsarRuntimeDependencies,
|
||||
validateFfmpegRuntime,
|
||||
validateSherpaRuntime,
|
||||
validateSilkWasmRuntime
|
||||
} = nodeRequire('../../scripts/after-pack.cjs') as {
|
||||
validateAsarRuntimeDependencies: (runtimeResources: string) => void
|
||||
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
|
||||
validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void
|
||||
validateSilkWasmRuntime: (runtimeResources: string) => void
|
||||
@@ -33,6 +40,19 @@ describe('production runtime packaging', () => {
|
||||
expect(config).toContain('node_modules/silk-wasm/**')
|
||||
})
|
||||
|
||||
it('rejects an app archive with missing runtime dependencies', async () => {
|
||||
const resources = join(root, 'asar-resources')
|
||||
const source = join(root, 'asar-source')
|
||||
mkdirSync(source, { recursive: true })
|
||||
writeFileSync(join(source, 'package.json'), '{}')
|
||||
mkdirSync(resources, { recursive: true })
|
||||
await asar.createPackage(source, join(resources, 'app.asar'))
|
||||
|
||||
expect(() => validateAsarRuntimeDependencies(resources)).toThrow(
|
||||
/Missing packaged runtime dependencies:.*@electron-toolkit\/utils/
|
||||
)
|
||||
})
|
||||
|
||||
it('requires and unpacks the bundled ffmpeg-static executable', () => {
|
||||
const resources = join(root, 'ffmpeg-resources')
|
||||
const ffmpegPath = join(
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { VideoAssetService } from '../../src/main/video-asset-service'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
const box = (type: string, payload: Buffer): Buffer => {
|
||||
const header = Buffer.alloc(8)
|
||||
header.writeUInt32BE(header.length + payload.length, 0)
|
||||
header.write(type, 4, 4, 'ascii')
|
||||
return Buffer.concat([header, payload])
|
||||
}
|
||||
|
||||
const mp4Fixture = (durationSeconds: number, marker: string): Buffer => {
|
||||
const movieHeader = Buffer.alloc(20)
|
||||
movieHeader.writeUInt32BE(1000, 12)
|
||||
movieHeader.writeUInt32BE(Math.round(durationSeconds * 1000), 16)
|
||||
return Buffer.concat([
|
||||
box('ftyp', Buffer.from('isom0000', 'ascii')),
|
||||
box('moov', box('mvhd', movieHeader)),
|
||||
box('mdat', Buffer.from(marker, 'utf8'))
|
||||
])
|
||||
}
|
||||
|
||||
const jpegFixture = (width: number, height: number): Buffer =>
|
||||
Buffer.from([
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xc0,
|
||||
0x00,
|
||||
0x11,
|
||||
0x08,
|
||||
(height >> 8) & 0xff,
|
||||
height & 0xff,
|
||||
(width >> 8) & 0xff,
|
||||
width & 0xff,
|
||||
0x03,
|
||||
0x01,
|
||||
0x11,
|
||||
0x00,
|
||||
0x02,
|
||||
0x11,
|
||||
0x00,
|
||||
0x03,
|
||||
0x11,
|
||||
0x00,
|
||||
0xff,
|
||||
0xd9
|
||||
])
|
||||
|
||||
const createService = (): {
|
||||
accountRoot: string
|
||||
service: VideoAssetService
|
||||
} => {
|
||||
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-video-assets-'))
|
||||
temporaryDirectories.push(accountRoot)
|
||||
return {
|
||||
accountRoot,
|
||||
service: new VideoAssetService({
|
||||
getAccountRoot: () => accountRoot,
|
||||
resolveVideoHardlink: () => null
|
||||
} as never)
|
||||
}
|
||||
}
|
||||
|
||||
const monthTimestamp = (year: number, month: number): number =>
|
||||
Math.floor(new Date(year, month - 1, 15, 12).getTime() / 1000)
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('VideoAssetService local fallback', () => {
|
||||
it('finds a video by its content MD5 when the hardlink mapping is absent', async () => {
|
||||
const { accountRoot, service } = createService()
|
||||
const month = join(accountRoot, 'msg', 'video', '2026-07')
|
||||
mkdirSync(month, { recursive: true })
|
||||
const content = mp4Fixture(22, 'content-md5-match')
|
||||
const filePath = join(month, `${'2'.repeat(32)}.mp4`)
|
||||
writeFileSync(filePath, content)
|
||||
const contentHash = createHash('md5').update(content).digest('hex')
|
||||
|
||||
const result = await service.resolve([contentHash], {
|
||||
createTime: monthTimestamp(2026, 7)
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(service.pathForUrl(result.url!)).toBe(filePath)
|
||||
})
|
||||
|
||||
it('finds a uniquely matching video by month, thumbnail size, and duration', async () => {
|
||||
const { accountRoot, service } = createService()
|
||||
const month = join(accountRoot, 'msg', 'video', '2025-11')
|
||||
mkdirSync(month, { recursive: true })
|
||||
const stem = '66cecd68e095d87175fb5ed138de3cef'
|
||||
const filePath = join(month, `${stem}.mp4`)
|
||||
const posterPath = join(month, `${stem}_thumb.jpg`)
|
||||
writeFileSync(filePath, mp4Fixture(68.441, 'metadata-match'))
|
||||
writeFileSync(posterPath, jpegFixture(279, 630))
|
||||
|
||||
const result = await service.resolve(
|
||||
['c92c54c8eae4471be9cc18396daf8015', '021e8a18a765ce14f4c54f40065db98e'],
|
||||
{
|
||||
createTime: monthTimestamp(2025, 11),
|
||||
duration: 68,
|
||||
width: 279,
|
||||
height: 630
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(service.pathForUrl(result.url!)).toBe(filePath)
|
||||
expect(service.pathForUrl(result.poster!)).toBe(posterPath)
|
||||
})
|
||||
|
||||
it('does not guess when multiple files match the same metadata', async () => {
|
||||
const { accountRoot, service } = createService()
|
||||
const month = join(accountRoot, 'msg', 'video', '2026-02')
|
||||
mkdirSync(month, { recursive: true })
|
||||
for (const stem of ['a'.repeat(32), 'b'.repeat(32)]) {
|
||||
writeFileSync(join(month, `${stem}.mp4`), mp4Fixture(12, stem))
|
||||
writeFileSync(join(month, `${stem}_thumb.jpg`), jpegFixture(224, 398))
|
||||
}
|
||||
|
||||
const result = await service.resolve(['c'.repeat(32)], {
|
||||
createTime: monthTimestamp(2026, 2),
|
||||
duration: 12,
|
||||
width: 224,
|
||||
height: 398
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: false, error: '本地未找到该视频文件' })
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
|
||||
|
||||
const message = (id: string, year: number): Wcdb4Message => ({
|
||||
const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({
|
||||
mesLocalID: id,
|
||||
serverId: `server-${id}`,
|
||||
serverId,
|
||||
mesDes: 0,
|
||||
messageType: '1',
|
||||
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
|
||||
@@ -12,6 +12,21 @@ const message = (id: string, year: number): Wcdb4Message => ({
|
||||
})
|
||||
|
||||
describe('WCDB message shard pagination', () => {
|
||||
it('keeps messages whose local ids repeat across database shards', async () => {
|
||||
const cursor = vi.fn(async () => [
|
||||
message('1', 2024, 'server-2024'),
|
||||
message('1', 2025, 'server-2025')
|
||||
])
|
||||
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
|
||||
getMessagesByCursorAsync: cursor
|
||||
}) as Wcdb4Client
|
||||
|
||||
const result = await client.getMessagesAsync('fixture@chatroom')
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((item) => item.serverId)).toEqual(['server-2024', 'server-2025'])
|
||||
})
|
||||
|
||||
it('merges cursor and all-store rows for a bounded cross-year page', async () => {
|
||||
const cursor = vi.fn(async () => [message('2025', 2025)])
|
||||
const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)])
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { WechatDb, type WechatMessage } from '../../src/main/wechat-db'
|
||||
|
||||
describe('WechatDb normalized messages', () => {
|
||||
it('keeps normalized identity fields when raw table columns conflict', async () => {
|
||||
const normalized = {
|
||||
mesLocalID: '1',
|
||||
serverId: 'server-1',
|
||||
mesDes: 0,
|
||||
messageType: '1',
|
||||
msgCreateTime: '1731327263',
|
||||
msgContent: 'fixture',
|
||||
sender: 'wxid_self',
|
||||
senderNickname: 'Nanin',
|
||||
raw: {
|
||||
mesDes: 1,
|
||||
sender: '',
|
||||
senderNickname: ''
|
||||
}
|
||||
}
|
||||
const client = { getMessagesAsync: vi.fn(async () => [normalized]) }
|
||||
const db = Object.assign(Object.create(WechatDb.prototype), {
|
||||
wcdb4Client: client,
|
||||
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
|
||||
ensureChatTableMapping: vi.fn()
|
||||
}) as WechatDb
|
||||
|
||||
const start = Math.floor(new Date(2024, 10, 11).getTime() / 1000)
|
||||
const end = Math.floor(new Date(2024, 11, 1).getTime() / 1000)
|
||||
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
mesDes: 0,
|
||||
sender: 'wxid_self',
|
||||
senderNickname: 'Nanin'
|
||||
})
|
||||
})
|
||||
|
||||
it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => {
|
||||
const row = (id: string, createTime: number): WechatMessage => ({
|
||||
mesLocalID: id,
|
||||
serverId: `server-${id}`,
|
||||
mesDes: 0,
|
||||
messageType: '1',
|
||||
msgCreateTime: String(createTime),
|
||||
msgContent: id,
|
||||
raw: {}
|
||||
})
|
||||
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
|
||||
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
|
||||
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
|
||||
const getMessagesAsync = vi.fn(async () => [
|
||||
row('before-range', start - 1),
|
||||
row('newest', start + 48 * 60 * 60),
|
||||
shardBoundaryMessage,
|
||||
{ ...shardBoundaryMessage },
|
||||
row('after-range', end + 1)
|
||||
])
|
||||
const db = Object.assign(Object.create(WechatDb.prototype), {
|
||||
wcdb4Client: { getMessagesAsync },
|
||||
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
|
||||
ensureChatTableMapping: vi.fn()
|
||||
}) as WechatDb
|
||||
|
||||
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
|
||||
|
||||
expect(getMessagesAsync).toHaveBeenCalledOnce()
|
||||
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user')
|
||||
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user