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 ): Promise => { 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((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 => 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 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((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': `

{{REPORT_TITLE}}

` }) 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': `

{{REPORT_TITLE}}

{{TOPIC_CARDS}}
` }) const maliciousZip = await makeZip(fixtureDir, { 'manifest.json': templateManifest({ templateVersion: '1.0.1' }), 'template.html': '' }) 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 => { try { await Promise.race([ browserWindow.webContents.session.fetch(url, { cache: 'no-store' }), new Promise((_, 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((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 }) } })