/** * @license * Copyright 2025 AionUi (aionui.com) * SPDX-License-Identifier: Apache-2.0 * * Node-environment tests for feedbackBridge's IPC handlers. * Covers the new feedback:capture-screenshot handler (main-process side). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { gunzipSync } from 'node:zlib'; import { app } from 'electron'; import { collectFeedbackLogAttachment } from '@/process/feedback/logs'; // Table of handlers registered via ipcMain.handle during module import. const handlers = new Map unknown>(); type FakeWebContents = { capturePage?: () => Promise<{ toPNG: () => Buffer }>; }; type FakeWindow = { isDestroyed: () => boolean; webContents: FakeWebContents; }; let currentWindow: FakeWindow | null = null; vi.mock('electron', () => ({ ipcMain: { handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => { handlers.set(channel, fn); }, on: vi.fn(), }, app: { getPath: vi.fn(() => '/tmp/aionui-test-logs-nonexistent'), getVersion: vi.fn(() => '0.0.0'), }, BrowserWindow: { fromWebContents: vi.fn(() => currentWindow), }, })); beforeEach(async () => { handlers.clear(); currentWindow = null; vi.resetModules(); // Importing registers the ipcMain.handle callbacks into our map. await import('@/process/bridge/feedbackBridge'); }); afterEach(() => { vi.clearAllMocks(); }); describe('feedbackBridge — capture-screenshot', () => { it('registers the feedback:capture-screenshot channel on import', () => { expect(handlers.has('feedback:capture-screenshot')).toBe(true); }); it('returns png bytes and a timestamped filename on success', async () => { const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02, 0x03]); currentWindow = { isDestroyed: () => false, webContents: { capturePage: vi.fn(async () => ({ toPNG: () => pngBytes })), }, }; const handler = handlers.get('feedback:capture-screenshot')!; const result = (await handler({ sender: {} })) as { filename: string; data: number[] } | null; expect(result).not.toBeNull(); expect(result!.filename).toMatch(/^screenshot-.*\.png$/); expect(result!.data).toEqual(Array.from(pngBytes)); }); it('returns null when no owning BrowserWindow is resolved', async () => { currentWindow = null; const handler = handlers.get('feedback:capture-screenshot')!; const result = await handler({ sender: {} }); expect(result).toBeNull(); }); it('returns null when the owning BrowserWindow is destroyed', async () => { currentWindow = { isDestroyed: () => true, webContents: { capturePage: vi.fn(), }, }; const handler = handlers.get('feedback:capture-screenshot')!; const result = await handler({ sender: {} }); expect(result).toBeNull(); expect(currentWindow.webContents.capturePage).not.toHaveBeenCalled(); }); it('returns null when capturePage yields an empty buffer', async () => { currentWindow = { isDestroyed: () => false, webContents: { capturePage: vi.fn(async () => ({ toPNG: () => Buffer.alloc(0) })), }, }; const handler = handlers.get('feedback:capture-screenshot')!; const result = await handler({ sender: {} }); expect(result).toBeNull(); }); it('returns null and does not throw when capturePage rejects', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); currentWindow = { isDestroyed: () => false, webContents: { capturePage: vi.fn(async () => { throw new Error('capture refused'); }), }, }; const handler = handlers.get('feedback:capture-screenshot')!; const result = await handler({ sender: {} }); expect(result).toBeNull(); expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); }); describe('feedback logs', () => { it('collects top-level frontend logs and nested backend logs through the IPC handler', async () => { const logsDir = mkdtempSync(path.join(tmpdir(), 'aionui-feedback-bridge-')); try { const backendLogsDir = path.join(logsDir, 'logs'); mkdirSync(backendLogsDir); writeFileSync(path.join(logsDir, '2026-05-25.log'), 'frontend renderer log\n'); writeFileSync(path.join(backendLogsDir, '2026-05-25.log'), 'backend process log\n'); writeFileSync(path.join(backendLogsDir, '2026-05-24.log'), 'second day backend log\n'); writeFileSync(path.join(backendLogsDir, '2026-05-23.log'), 'third day backend log\n'); writeFileSync(path.join(backendLogsDir, '2026-05-22.log'), 'too old backend log\n'); vi.mocked(app.getPath).mockImplementation((name: string) => { if (name === 'logs') return logsDir; return path.join(logsDir, 'userData'); }); const handler = handlers.get('feedback:collect-logs')!; const result = (await handler({})) as { filename: string; data: number[] } | null; expect(result).not.toBeNull(); const content = gunzipSync(Buffer.from(result!.data)).toString('utf8'); expect(content).toContain('frontend renderer log'); expect(content).toContain('backend process log'); expect(content).toContain('second day backend log'); expect(content).toContain('third day backend log'); expect(content).not.toContain('too old backend log'); } finally { rmSync(logsDir, { recursive: true, force: true }); } }); it('collects the same recent three log days used by user feedback reports', () => { const logsDir = mkdtempSync(path.join(tmpdir(), 'aionui-feedback-logs-')); try { writeFileSync(path.join(logsDir, '2026-05-25.log'), 'today frontend\n'); writeFileSync(path.join(logsDir, '2026-05-25.aioncore.log'), 'today backend\n'); writeFileSync(path.join(logsDir, '2026-05-24.aionrs.log'), 'yesterday rust\n'); writeFileSync(path.join(logsDir, '2026-05-23.log'), 'third day frontend\n'); writeFileSync(path.join(logsDir, '2026-05-22.log'), 'too old frontend\n'); writeFileSync(path.join(logsDir, '2026-05-25.txt'), 'not a log\n'); const attachment = collectFeedbackLogAttachment(logsDir); expect(attachment).not.toBeNull(); expect(attachment!.filename).toBe('logs.gz'); expect(attachment!.contentType).toBe('application/gzip'); const content = gunzipSync(attachment!.data).toString('utf8'); expect(content).toContain('today frontend'); expect(content).toContain('today backend'); expect(content).toContain('yesterday rust'); expect(content).toContain('third day frontend'); expect(content).not.toContain('too old frontend'); expect(content).not.toContain('not a log'); } finally { rmSync(logsDir, { recursive: true, force: true }); } }); it('collects recent logs from dated year/month/day directories', () => { const logsDir = mkdtempSync(path.join(tmpdir(), 'aionui-feedback-dated-logs-')); try { const recentDir = path.join(logsDir, '2026', '07', '02'); const previousDir = path.join(logsDir, '2026', '07', '01'); const oldDir = path.join(logsDir, '2026', '06', '30'); mkdirSync(recentDir, { recursive: true }); mkdirSync(previousDir, { recursive: true }); mkdirSync(oldDir, { recursive: true }); writeFileSync(path.join(recentDir, '2026-07-02.log'), 'today frontend nested\n'); writeFileSync(path.join(recentDir, '2026-07-02.aioncore.log'), 'today backend nested\n'); writeFileSync(path.join(previousDir, '2026-07-01.aionrs.log'), 'yesterday rust nested\n'); writeFileSync(path.join(oldDir, '2026-06-30.log'), 'third day frontend nested\n'); writeFileSync(path.join(logsDir, '2026-06-29.log'), 'too old flat\n'); const attachment = collectFeedbackLogAttachment(logsDir); expect(attachment).not.toBeNull(); const content = gunzipSync(attachment!.data).toString('utf8'); expect(content).toContain('today frontend nested'); expect(content).toContain('today backend nested'); expect(content).toContain('yesterday rust nested'); expect(content).toContain('third day frontend nested'); expect(content).not.toContain('too old flat'); expect(content).toContain('2026/07/02/2026-07-02.aioncore.log'); } finally { rmSync(logsDir, { recursive: true, force: true }); } }); });