mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 日报新增模板市场,支持社区模板安装与使用
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import { _electron as electron, expect, test } from '@playwright/test'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
test('REPORT-TEMPLATE-MARKET-E2E-01 reads, installs, restores, renders, and switches published templates', async () => {
|
||||
test.setTimeout(120_000)
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-market-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-market-output-'))
|
||||
const launch = (): ReturnType<typeof electron.launch> =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
|
||||
const report = {
|
||||
overview: '虚构远端模板日报概览',
|
||||
hero: {
|
||||
headline: '今日群聊重点',
|
||||
summary: '虚构的结构化日报内容',
|
||||
keyTakeaway: '保留结构化快照并切换版式',
|
||||
pendingNote: '',
|
||||
statusLine: ''
|
||||
},
|
||||
topics: [
|
||||
{
|
||||
title: '模板市场联调',
|
||||
timeRange: '09:00-10:00',
|
||||
heat: '高' as const,
|
||||
participants: ['小明'],
|
||||
summary: '验证安装、重启恢复和导出',
|
||||
keywords: ['模板', '日报'],
|
||||
messages: []
|
||||
}
|
||||
],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: {
|
||||
topicHeat: [{ topic: '模板市场联调', score: 1 }],
|
||||
activeTimeline: '09:00-10:00',
|
||||
topSpeakers: [{ name: '小明', count: 1 }],
|
||||
voiceLeaderboard: []
|
||||
},
|
||||
keywords: ['模板', '日报'],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
const metadata = {
|
||||
groupName: '虚构市场验收群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '09:00-10:00',
|
||||
generatedAt: '2026-09-07 10:00',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'market fixture',
|
||||
heroParticipants: ['小明'],
|
||||
avatars: {}
|
||||
}
|
||||
|
||||
let app: Awaited<ReturnType<typeof electron.launch>> | null = null
|
||||
try {
|
||||
app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
|
||||
const catalogResult = await page.evaluate(() => window.api.listReportTemplateCatalog())
|
||||
expect(catalogResult.success, catalogResult.error).toBe(true)
|
||||
const catalog = catalogResult.catalog
|
||||
expect(catalog?.status).toBe('published')
|
||||
expect(catalog?.templates).toHaveLength(3)
|
||||
const sourceCommit = catalog?.source?.commit
|
||||
expect(sourceCommit).toMatch(/^[a-f0-9]{40}$/)
|
||||
const entries = catalog!.templates
|
||||
expect(new Set(entries.map((entry) => entry.id))).toEqual(
|
||||
new Set([
|
||||
'community.github.tracememo.quickread',
|
||||
'community.github.tracememo.paperdaily',
|
||||
'community.github.tracememo.teamboard'
|
||||
])
|
||||
)
|
||||
for (const entry of entries) {
|
||||
expect(entry.interfaceVersion).toBe('1')
|
||||
expect(entry.status).toBe('published')
|
||||
expect(entry.download).toContain(`/Wxw-Gu/TraceMemo-Templates/${sourceCommit}/`)
|
||||
expect(entry.preview).toContain(`/Wxw-Gu/TraceMemo-Templates/${sourceCommit}/`)
|
||||
}
|
||||
|
||||
const exportsById = new Map<
|
||||
string,
|
||||
{ htmlPath: string; pngPath: string; imageDataUrl: string; version: string }
|
||||
>()
|
||||
for (const entry of entries) {
|
||||
const installed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.installReportTemplateFromCatalog(id, version),
|
||||
{ id: entry.id, version: entry.version }
|
||||
)
|
||||
expect(installed.success, installed.error).toBe(true)
|
||||
expect(installed.catalogEntry?.sha256).toBe(entry.sha256)
|
||||
expect(installed.template?.id).toBe(entry.id)
|
||||
expect(installed.template?.version).toBe(entry.version)
|
||||
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: entry.id, version: entry.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(exported.success, exported.error).toBe(true)
|
||||
expect(exported.htmlPath).toBeTruthy()
|
||||
expect(exported.pngPath).toBeTruthy()
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构的结构化日报内容')
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.length).toBeGreaterThan(1000)
|
||||
expect(png.readUInt32BE(16)).toBe(entry.platform === 'desktop' ? 1440 : 430)
|
||||
expect(png.readUInt32BE(20)).toBeGreaterThan(100)
|
||||
exportsById.set(entry.id, {
|
||||
htmlPath: exported.htmlPath!,
|
||||
pngPath: exported.pngPath!,
|
||||
imageDataUrl: exported.imageDataUrl!,
|
||||
version: entry.version
|
||||
})
|
||||
}
|
||||
|
||||
await app.close()
|
||||
app = await launch()
|
||||
page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const restored = await page.evaluate(() => window.api.listReportTemplates())
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
restored.some((template) => template.id === entry.id && template.version === entry.version)
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
const quickread = entries.find((entry) => entry.id.endsWith('.quickread'))!
|
||||
const paperdaily = entries.find((entry) => entry.id.endsWith('.paperdaily'))!
|
||||
const firstExport = exportsById.get(quickread.id)!
|
||||
const saved = await page.evaluate(
|
||||
async ({ firstExportValue, reportValue, metadataValue, templateId }) =>
|
||||
window.api.saveGeneratedReport({
|
||||
contactId: 'fixture-market-group',
|
||||
contactName: metadataValue.groupName,
|
||||
source: 'manual',
|
||||
dateRange: metadataValue.dateRange,
|
||||
messageCount: metadataValue.messageCount,
|
||||
generatedAt: '2026-09-07T10:00:00.000Z',
|
||||
reportDate: metadataValue.reportDate,
|
||||
generatedImage: firstExportValue.imageDataUrl,
|
||||
htmlPath: firstExportValue.htmlPath,
|
||||
pngPath: firstExportValue.pngPath,
|
||||
reportSnapshot: reportValue,
|
||||
reportMetadata: metadataValue,
|
||||
templateId
|
||||
}),
|
||||
{
|
||||
firstExportValue: firstExport,
|
||||
reportValue: report,
|
||||
metadataValue: metadata,
|
||||
templateId: `external:${quickread.id}@${quickread.version}`
|
||||
}
|
||||
)
|
||||
expect(saved.success, saved.error).toBe(true)
|
||||
const reportId = saved.record!.id
|
||||
|
||||
const switchedExport = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: paperdaily.id, version: paperdaily.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(switchedExport.success, switchedExport.error).toBe(true)
|
||||
const switched = await page.evaluate(
|
||||
async ({ reportIdValue, id, version, exported }) =>
|
||||
window.api.updateGeneratedReportTemplate({
|
||||
reportId: reportIdValue,
|
||||
templateId: `external:${id}@${version}`,
|
||||
templateRef: { id, version },
|
||||
generatedImage: exported.imageDataUrl,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath
|
||||
}),
|
||||
{
|
||||
reportIdValue: reportId,
|
||||
id: paperdaily.id,
|
||||
version: paperdaily.version,
|
||||
exported: {
|
||||
imageDataUrl: switchedExport.imageDataUrl!,
|
||||
htmlPath: switchedExport.htmlPath!,
|
||||
pngPath: switchedExport.pngPath!
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(switched.success, switched.error).toBe(true)
|
||||
expect(switched.record?.id).toBe(reportId)
|
||||
expect(switched.record?.templateId).toBe(`external:${paperdaily.id}@${paperdaily.version}`)
|
||||
expect(switched.record?.reportSnapshot).toEqual(report)
|
||||
expect(readFileSync(switched.record!.htmlPath!, 'utf8')).toContain('虚构的结构化日报内容')
|
||||
|
||||
const history = await page.evaluate(() => window.api.listGeneratedReports())
|
||||
expect(history.success).toBe(true)
|
||||
expect(history.reports?.find((record) => record.id === reportId)?.templateId).toBe(
|
||||
`external:${paperdaily.id}@${paperdaily.version}`
|
||||
)
|
||||
|
||||
const removed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.uninstallReportTemplate(id, version),
|
||||
{ id: paperdaily.id, version: paperdaily.version }
|
||||
)
|
||||
expect(removed.success, removed.error).toBe(true)
|
||||
const afterUninstall = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: paperdaily.id, version: paperdaily.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(afterUninstall.success).toBe(false)
|
||||
expect(afterUninstall.error).toContain('模板不存在')
|
||||
|
||||
const legacyHtml = join(outputDir, 'legacy-without-structured-data.html')
|
||||
writeFileSync(legacyHtml, '<!doctype html><html><body><h1>旧日报</h1></body></html>')
|
||||
const legacy = await page.evaluate(
|
||||
async ({ htmlPath, pngPath, imageDataUrl }) =>
|
||||
window.api.saveGeneratedReport({
|
||||
contactId: 'fixture-legacy-group',
|
||||
contactName: '虚构旧日报',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
generatedAt: '2026-09-07T11:00:00.000Z',
|
||||
htmlPath,
|
||||
pngPath,
|
||||
generatedImage: imageDataUrl
|
||||
}),
|
||||
{ htmlPath: legacyHtml, pngPath: firstExport.pngPath, imageDataUrl: firstExport.imageDataUrl }
|
||||
)
|
||||
expect(legacy.success).toBe(true)
|
||||
const legacySwitch = await page.evaluate(
|
||||
async ({ reportIdValue, id, version, imageDataUrl, htmlPath }) =>
|
||||
window.api.updateGeneratedReportTemplate({
|
||||
reportId: reportIdValue,
|
||||
templateId: `external:${id}@${version}`,
|
||||
templateRef: { id, version },
|
||||
generatedImage: imageDataUrl,
|
||||
htmlPath,
|
||||
pngPath: htmlPath
|
||||
}),
|
||||
{
|
||||
reportIdValue: legacy.record!.id,
|
||||
id: quickread.id,
|
||||
version: quickread.version,
|
||||
imageDataUrl: firstExport.imageDataUrl,
|
||||
htmlPath: legacyHtml
|
||||
}
|
||||
)
|
||||
expect(legacySwitch.success).toBe(false)
|
||||
expect(legacySwitch.error).toContain('旧报告未保存结构化数据')
|
||||
} finally {
|
||||
if (app) await app.close()
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,600 @@
|
||||
import { _electron as electron, expect, test } from '@playwright/test'
|
||||
import { createWriteStream, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { ZipArchive } from 'archiver'
|
||||
|
||||
const makeZip = async (
|
||||
directory: string,
|
||||
files: Record<string, string | Buffer>
|
||||
): Promise<string> => {
|
||||
const zipPath = join(directory, `${Math.random().toString(36).slice(2)}.zip`)
|
||||
const output = createWriteStream(zipPath)
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||||
archive.pipe(output)
|
||||
for (const [name, content] of Object.entries(files)) archive.append(content, { name })
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
output.on('close', resolvePromise)
|
||||
output.on('error', reject)
|
||||
archive.on('error', reject)
|
||||
void archive.finalize().catch(reject)
|
||||
})
|
||||
return zipPath
|
||||
}
|
||||
|
||||
const templateManifest = (patch: Record<string, unknown> = {}): string =>
|
||||
JSON.stringify({
|
||||
protocolVersion: '1.0',
|
||||
kind: 'daily-report',
|
||||
id: 'community.github.example.runtime-attack',
|
||||
name: '运行时安全测试模板',
|
||||
author: { name: 'fixture' },
|
||||
templateVersion: '1.0.0',
|
||||
interfaceVersion: '1',
|
||||
entry: 'template.html',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 20000 },
|
||||
license: { spdx: 'MIT' },
|
||||
...patch
|
||||
})
|
||||
|
||||
const runtimeAttackReport = (imageUrl: string) => ({
|
||||
overview: '虚构运行时安全测试',
|
||||
topics: [
|
||||
{
|
||||
title: '攻击 fixture',
|
||||
timeRange: '10:00-10:01',
|
||||
heat: '高' as const,
|
||||
participants: [],
|
||||
summary: '用于触发报告窗口资源请求',
|
||||
keywords: [],
|
||||
image: { imageUrl, note: 'fixture' }
|
||||
}
|
||||
],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
})
|
||||
|
||||
const runtimeAttackMetadata = {
|
||||
groupName: '虚构攻击测试群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '10:00-10:01',
|
||||
generatedAt: '2026-09-07 10:01',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'fixture security probe',
|
||||
heroParticipants: [],
|
||||
avatars: {}
|
||||
}
|
||||
|
||||
const startProbeServer = async (): Promise<{
|
||||
server: ReturnType<typeof createServer>
|
||||
port: number
|
||||
httpRequests: string[]
|
||||
wsRequests: string[]
|
||||
}> => {
|
||||
const httpRequests: string[] = []
|
||||
const wsRequests: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
httpRequests.push(request.url || '')
|
||||
response.statusCode = 200
|
||||
response.end('unexpected fixture response')
|
||||
})
|
||||
server.on('upgrade', (request, socket) => {
|
||||
wsRequests.push(request.url || '')
|
||||
socket.destroy()
|
||||
})
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const onError = (error: Error): void => {
|
||||
server.off('listening', onListening)
|
||||
reject(error)
|
||||
}
|
||||
const onListening = (): void => {
|
||||
server.off('error', onError)
|
||||
resolvePromise()
|
||||
}
|
||||
server.once('error', onError)
|
||||
server.once('listening', onListening)
|
||||
server.listen(0, '127.0.0.1')
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close()
|
||||
throw new Error('本地安全探针监听器未分配端口')
|
||||
}
|
||||
return { server, port: address.port, httpRequests, wsRequests }
|
||||
}
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-01 installs and renders a fixture without author code', async ({}, testInfo) => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-output-'))
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const installed = await page.evaluate(
|
||||
async (p) => window.api.installReportTemplate(p),
|
||||
resolve('examples/report-template-basic.zip')
|
||||
)
|
||||
expect(installed.success).toBe(true)
|
||||
expect(installed.template?.id).toBe('community.github.example.basic-feed')
|
||||
expect(installed.template?.version).toBe('1.0.0')
|
||||
await app.close()
|
||||
app = await launch()
|
||||
page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const reloaded = await page.evaluate(() => window.api.listReportTemplates())
|
||||
expect(
|
||||
reloaded.some(
|
||||
(item) => item.id === installed.template?.id && item.version === installed.template?.version
|
||||
)
|
||||
).toBe(true)
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata: {
|
||||
groupName: '虚构测试群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 3,
|
||||
activeUsers: 2,
|
||||
timeSpan: '09:00-10:00',
|
||||
generatedAt: '2026-09-07 10:00',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'fixture export',
|
||||
heroParticipants: [],
|
||||
avatars: []
|
||||
},
|
||||
report: {
|
||||
overview: '虚构日报概览',
|
||||
topics: [],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
}),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(exported.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('Content-Security-Policy')
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构日报概览')
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.length).toBeGreaterThan(1000)
|
||||
expect(png.readUInt32BE(16)).toBe(430)
|
||||
expect(png.readUInt32BE(20)).toBeGreaterThan(100)
|
||||
await testInfo.attach('report-png', { path: exported.pngPath!, contentType: 'image/png' })
|
||||
const removed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.uninstallReportTemplate(id, version),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(removed.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构日报概览')
|
||||
expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000)
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-CAPTURE-01 enforces manifest maxHeight in the production capture path', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-output-'))
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-fixture-'))
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const packagePath = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest({
|
||||
id: 'community.github.example.capture-cap',
|
||||
name: '截图高度限制测试',
|
||||
templateVersion: '1.0.0',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 800 }
|
||||
}),
|
||||
'template.html': `<!doctype html><html><head><style>html,body{margin:0;min-height:3000px}</style></head><body><h1>{{REPORT_TITLE}}</h1></body></html>`
|
||||
})
|
||||
const installed = await page.evaluate(
|
||||
async (path) => window.api.installReportTemplate(path),
|
||||
packagePath
|
||||
)
|
||||
expect(installed.success, installed.error).toBe(true)
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata: {
|
||||
groupName: '虚构截图限制群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '10:00-10:01',
|
||||
generatedAt: '2026-09-07 10:01',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'capture fixture',
|
||||
heroParticipants: [],
|
||||
avatars: {}
|
||||
},
|
||||
report: {
|
||||
overview: '虚构截图高度限制',
|
||||
topics: [],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
}),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(exported.success, exported.error).toBe(true)
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.readUInt32BE(16)).toBe(430)
|
||||
expect(png.readUInt32BE(20)).toBe(800)
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(fixtureDir, { recursive: true, force: true })
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-SECURITY-01 blocks runtime attacks in a real Electron report window', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-security-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-security-output-'))
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-template-security-fixture-'))
|
||||
const securityLog = join(outputDir, 'security-blocks.ndjson')
|
||||
const probeServer = await startProbeServer()
|
||||
const outsideImage = join(userData, 'outside.png')
|
||||
writeFileSync(
|
||||
outsideImage,
|
||||
Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir,
|
||||
TRACEMEMO_TEMPLATE_SECURITY_LOG: securityLog
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const runtimeZip = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest(),
|
||||
'template.html': `<!doctype html><html><head><style>body{font-family:sans-serif;padding:24px}</style></head><body><h1>{{REPORT_TITLE}}</h1><main>{{TOPIC_CARDS}}</main></body></html>`
|
||||
})
|
||||
const maliciousZip = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest({ templateVersion: '1.0.1' }),
|
||||
'template.html': '<script>window.__templateAuthorCode = true</script>'
|
||||
})
|
||||
|
||||
const rejected = await page.evaluate(
|
||||
async (packagePath) => window.api.installReportTemplate(packagePath),
|
||||
maliciousZip
|
||||
)
|
||||
expect(rejected.success).toBe(false)
|
||||
expect(rejected.code).toBe('unsafe_html')
|
||||
|
||||
const installed = await page.evaluate(
|
||||
async (packagePath) => window.api.installReportTemplate(packagePath),
|
||||
runtimeZip
|
||||
)
|
||||
expect(installed.success).toBe(true)
|
||||
expect(installed.template?.id).toBe('community.github.example.runtime-attack')
|
||||
|
||||
const outsideFileUrl = pathToFileURL(outsideImage).toString()
|
||||
const targets = {
|
||||
http: `http://127.0.0.1:${probeServer.port}/http-probe`,
|
||||
https: `https://127.0.0.1:${probeServer.port}/https-probe`,
|
||||
ws: `ws://127.0.0.1:${probeServer.port}/ws-probe`,
|
||||
wss: `wss://127.0.0.1:${probeServer.port}/wss-probe`
|
||||
}
|
||||
await app.evaluate(
|
||||
({ app: electronApp }, { fileUrl, targets }) => {
|
||||
const state = {
|
||||
hiddenWindows: 0,
|
||||
createdWindows: 0,
|
||||
navigations: [] as string[],
|
||||
childWindowUrls: [] as string[],
|
||||
probeStarted: false,
|
||||
probeDone: false,
|
||||
probe: undefined as
|
||||
| {
|
||||
http: boolean
|
||||
https: boolean
|
||||
ws: boolean
|
||||
wss: boolean
|
||||
localResource: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
targetBlank: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
;(
|
||||
globalThis as typeof globalThis & { __tmTemplateSecurity?: typeof state }
|
||||
).__tmTemplateSecurity = state
|
||||
electronApp.on('browser-window-created', (_event, browserWindow) => {
|
||||
state.createdWindows += 1
|
||||
if (browserWindow.isVisible()) return
|
||||
state.hiddenWindows += 1
|
||||
browserWindow.webContents.on('will-navigate', (_navigationEvent, url) => {
|
||||
state.navigations.push(url)
|
||||
})
|
||||
browserWindow.webContents.on('did-create-window', (_childWindow, details) => {
|
||||
state.childWindowUrls.push(details.url)
|
||||
})
|
||||
browserWindow.webContents.on('did-finish-load', () => {
|
||||
if (state.probeStarted) return
|
||||
state.probeStarted = true
|
||||
const serializedFileUrl = JSON.stringify(fileUrl)
|
||||
const serializedTargets = JSON.stringify(targets)
|
||||
const requestViaSession = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
await Promise.race([
|
||||
browserWindow.webContents.session.fetch(url, { cache: 'no-store' }),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('network probe timeout')), 2000)
|
||||
)
|
||||
])
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
const networkProbe = Promise.all([
|
||||
requestViaSession(targets.http),
|
||||
requestViaSession(targets.https),
|
||||
requestViaSession(targets.ws),
|
||||
requestViaSession(targets.wss),
|
||||
requestViaSession(fileUrl)
|
||||
]).then(([http, https, ws, wss, localResource]) => ({
|
||||
http,
|
||||
https,
|
||||
ws,
|
||||
wss,
|
||||
localResource
|
||||
}))
|
||||
const rendererProbe = browserWindow.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const targets = ${serializedTargets}
|
||||
const originalUrl = location.href
|
||||
const rejectedSocket = (url) => new Promise((resolve) => {
|
||||
let settled = false
|
||||
let socket
|
||||
const finish = (rejected) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
socket?.close()
|
||||
resolve(rejected)
|
||||
}
|
||||
try {
|
||||
socket = new WebSocket(url)
|
||||
socket.onerror = () => finish(true)
|
||||
socket.onopen = () => finish(false)
|
||||
setTimeout(() => finish(true), 1000)
|
||||
} catch {
|
||||
finish(true)
|
||||
}
|
||||
})
|
||||
const rejectedLocalImage = new Promise((resolve) => {
|
||||
const outside = new Image()
|
||||
outside.onload = () => resolve(outside.naturalWidth === 0)
|
||||
outside.onerror = () => resolve(true)
|
||||
setTimeout(() => resolve(false), 500)
|
||||
outside.src = ${serializedFileUrl}
|
||||
})
|
||||
const windowOpen = window.open(targets.https + '/new-window') === null
|
||||
const target = document.createElement('a')
|
||||
target.href = targets.https + '/target-blank'
|
||||
target.target = '_blank'
|
||||
target.textContent = 'open'
|
||||
document.body.append(target)
|
||||
const targetBlankClicked = target.target === '_blank' && target.href === targets.https + '/target-blank'
|
||||
target.click()
|
||||
target.remove()
|
||||
try { location.assign(targets.ws + '/renderer-navigation') } catch { /* will-navigate 事件会阻止导航 */ }
|
||||
try { location.assign(targets.https + '/navigation') } catch { /* will-navigate 事件会阻止导航 */ }
|
||||
return Promise.all([
|
||||
rejectedLocalImage,
|
||||
rejectedSocket(targets.ws + '/renderer-probe'),
|
||||
rejectedSocket(targets.wss + '/renderer-probe'),
|
||||
new Promise((resolve) => setTimeout(resolve, 100))
|
||||
]).then(([localResource, rendererWs, rendererWss]) => ({ localResource, rendererWs, rendererWss, windowOpen, targetBlankClicked, navigation: location.href === originalUrl }))
|
||||
})()`
|
||||
)
|
||||
void Promise.all([networkProbe, rendererProbe])
|
||||
.then(([network, result]) => {
|
||||
const values = result as {
|
||||
localResource: boolean
|
||||
rendererWs: boolean
|
||||
rendererWss: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
state.probe = {
|
||||
...network,
|
||||
localResource: values.localResource,
|
||||
windowOpen: values.windowOpen,
|
||||
navigation: values.navigation,
|
||||
ws: network.ws || values.rendererWs,
|
||||
wss: network.wss || values.rendererWss,
|
||||
targetBlankClicked: values.targetBlankClicked,
|
||||
targetBlank:
|
||||
values.targetBlankClicked &&
|
||||
state.createdWindows === 1 &&
|
||||
!state.childWindowUrls.includes(`${targets.https}/target-blank`)
|
||||
}
|
||||
state.probeDone = true
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
})
|
||||
},
|
||||
{ fileUrl: outsideFileUrl, targets }
|
||||
)
|
||||
|
||||
const reportWindowPromise = app.waitForEvent('window', {
|
||||
predicate: (candidate) => candidate !== page
|
||||
})
|
||||
const exportPromise = page.evaluate(
|
||||
async ({ id, version, metadata, report }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata,
|
||||
report
|
||||
}),
|
||||
{
|
||||
id: installed.template!.id,
|
||||
version: installed.template!.version,
|
||||
metadata: runtimeAttackMetadata,
|
||||
report: runtimeAttackReport(`${targets.https}/render-image`)
|
||||
}
|
||||
)
|
||||
await reportWindowPromise
|
||||
const exported = await exportPromise
|
||||
expect(exported.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构攻击测试群日报')
|
||||
expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
app.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & { __tmTemplateSecurity?: { probeDone: boolean } }
|
||||
).__tmTemplateSecurity
|
||||
return state?.probeDone ?? false
|
||||
}),
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const securityLogLines = readFileSync(securityLog, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as { url: string; reason: string })
|
||||
const blockedUrls = securityLogLines.map((entry) => entry.url)
|
||||
for (const url of [targets.http, targets.https, targets.ws, targets.wss, outsideFileUrl]) {
|
||||
expect(blockedUrls, `缺少运行时拦截记录: ${url}`).toContain(url)
|
||||
const block = securityLogLines.find((entry) => entry.url === url)
|
||||
expect(block?.reason, `运行时拦截 reason 缺失: ${url}`).toBe(
|
||||
url.startsWith('file:') ? 'file-path-not-allowed' : 'scheme-not-allowed'
|
||||
)
|
||||
}
|
||||
expect(probeServer.httpRequests).toEqual([])
|
||||
expect(probeServer.wsRequests).toEqual([])
|
||||
|
||||
const securityState = await app.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__tmTemplateSecurity?: {
|
||||
hiddenWindows: number
|
||||
navigations: string[]
|
||||
createdWindows: number
|
||||
childWindowUrls: string[]
|
||||
probeDone: boolean
|
||||
probe?: {
|
||||
http: boolean
|
||||
https: boolean
|
||||
ws: boolean
|
||||
wss: boolean
|
||||
localResource: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
targetBlank: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
).__tmTemplateSecurity
|
||||
return state
|
||||
})
|
||||
expect(securityState?.hiddenWindows).toBe(1)
|
||||
expect(securityState?.createdWindows).toBe(1)
|
||||
expect(securityState?.childWindowUrls).toEqual([])
|
||||
expect(securityState?.navigations).toContain(`${targets.https}/navigation`)
|
||||
expect(securityState?.probe, JSON.stringify(securityState)).toEqual({
|
||||
http: true,
|
||||
https: true,
|
||||
ws: true,
|
||||
wss: true,
|
||||
localResource: true,
|
||||
windowOpen: true,
|
||||
targetBlankClicked: true,
|
||||
targetBlank: true,
|
||||
navigation: true
|
||||
})
|
||||
expect(app.windows().length).toBe(1)
|
||||
} finally {
|
||||
await app.close()
|
||||
if (probeServer.server.listening) {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
probeServer.server.close((error) => (error ? reject(error) : resolvePromise()))
|
||||
})
|
||||
}
|
||||
rmSync(fixtureDir, { recursive: true, force: true })
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user