import { beforeEach, describe, expect, it, vi } from 'vitest'; describe('transcript export IPC', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); vi.doUnmock('electron'); vi.doUnmock('node:fs/promises'); }); it('lets Main choose the destination and write the markdown as UTF-8', async () => { const handle = vi.fn(); const showSaveDialog = vi.fn().mockResolvedValue({ canceled: false, filePath: 'D:\\Exports\\session.md', }); const writeFile = vi.fn().mockResolvedValue(undefined); vi.doMock('electron', () => ({ dialog: { showSaveDialog }, ipcMain: { handle }, })); vi.doMock('node:fs/promises', () => ({ default: { writeFile }, writeFile, })); const { registerTranscriptExportHandler } = await import( '../../electron/main/ipc/transcript-export' ); const mainWindow = { id: 1 } as Electron.BrowserWindow; registerTranscriptExportHandler(mainWindow); const handler = handle.mock.calls.find(([channel]) => channel === 'transcript:save')?.[1]; expect(handler).toBeTypeOf('function'); await expect(handler(undefined, { defaultPath: 'session.md', markdown: '# Visible transcript\n', })).resolves.toEqual({ status: 'saved' }); expect(showSaveDialog).toHaveBeenCalledWith(mainWindow, { defaultPath: 'session.md', filters: [{ name: 'Markdown', extensions: ['md'] }], }); expect(writeFile).toHaveBeenCalledWith( 'D:\\Exports\\session.md', '# Visible transcript\n', 'utf8', ); }); it('does not write when the Main-owned save dialog is cancelled', async () => { const handle = vi.fn(); const showSaveDialog = vi.fn().mockResolvedValue({ canceled: true }); const writeFile = vi.fn(); vi.doMock('electron', () => ({ dialog: { showSaveDialog }, ipcMain: { handle }, })); vi.doMock('node:fs/promises', () => ({ default: { writeFile }, writeFile, })); const { registerTranscriptExportHandler } = await import( '../../electron/main/ipc/transcript-export' ); registerTranscriptExportHandler({ id: 1 } as Electron.BrowserWindow); const handler = handle.mock.calls.find(([channel]) => channel === 'transcript:save')?.[1]; await expect(handler(undefined, { defaultPath: 'session.md', markdown: '# Visible transcript\n', })).resolves.toEqual({ status: 'cancelled' }); expect(writeFile).not.toHaveBeenCalled(); }); it('rejects an oversized transcript before opening the save dialog', async () => { const handle = vi.fn(); const showSaveDialog = vi.fn(); const writeFile = vi.fn(); vi.doMock('electron', () => ({ dialog: { showSaveDialog }, ipcMain: { handle }, })); vi.doMock('node:fs/promises', () => ({ default: { writeFile }, writeFile, })); const { registerTranscriptExportHandler } = await import( '../../electron/main/ipc/transcript-export' ); registerTranscriptExportHandler({ id: 1 } as Electron.BrowserWindow); const handler = handle.mock.calls.find(([channel]) => channel === 'transcript:save')?.[1]; await expect(handler(undefined, { defaultPath: 'session.md', markdown: 'a'.repeat((10 * 1024 * 1024) + 1), })).rejects.toThrow('Transcript is too large'); expect(showSaveDialog).not.toHaveBeenCalled(); expect(writeFile).not.toHaveBeenCalled(); }); it('does not expose the selected absolute path when writing fails', async () => { const handle = vi.fn(); const showSaveDialog = vi.fn().mockResolvedValue({ canceled: false, filePath: 'D:\\Secret\\session.md', }); const writeFile = vi.fn().mockRejectedValue( new Error('EACCES: D:\\Secret\\session.md'), ); vi.doMock('electron', () => ({ dialog: { showSaveDialog }, ipcMain: { handle }, })); vi.doMock('node:fs/promises', () => ({ default: { writeFile }, writeFile, })); const { registerTranscriptExportHandler } = await import( '../../electron/main/ipc/transcript-export' ); registerTranscriptExportHandler({ id: 1 } as Electron.BrowserWindow); const handler = handle.mock.calls.find(([channel]) => channel === 'transcript:save')?.[1]; await expect(handler(undefined, { defaultPath: 'session.md', markdown: '# Visible transcript\n', })).rejects.toThrow(/^Failed to save transcript$/); }); it('exposes only the dedicated transcript save request to the Renderer wrapper', async () => { const invoke = vi.fn().mockResolvedValue({ status: 'saved' }); window.electron.ipcRenderer.invoke = invoke; const { saveMarkdownTranscript } = await import('@/lib/api-client'); await expect(saveMarkdownTranscript( 'session.md', '# Visible transcript\n', )).resolves.toBe('saved'); expect(invoke).toHaveBeenCalledWith('transcript:save', { defaultPath: 'session.md', markdown: '# Visible transcript\n', }); expect(invoke).not.toHaveBeenCalledWith('dialog:save', expect.anything()); expect(invoke).not.toHaveBeenCalledWith('file:writeText', expect.anything()); }); it('allows transcript save through preload without exposing generic file writes', async () => { const exposeInMainWorld = vi.fn(); const invoke = vi.fn().mockResolvedValue({ status: 'saved' }); vi.doMock('electron', () => ({ contextBridge: { exposeInMainWorld }, ipcRenderer: { invoke, on: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), removeListener: vi.fn(), }, })); await import('../../electron/preload/index'); const exposedApi = exposeInMainWorld.mock.calls.find( ([name]) => name === 'electron', )?.[1] as { ipcRenderer: { invoke: (channel: string, ...args: unknown[]) => Promise; }; }; await expect(exposedApi.ipcRenderer.invoke( 'transcript:save', { defaultPath: 'session.md', markdown: '# Visible transcript\n' }, )).resolves.toEqual({ status: 'saved' }); expect(() => exposedApi.ipcRenderer.invoke( 'file:writeText', 'D:\\Secret\\owned.txt', 'payload', )).toThrow('Invalid IPC channel: file:writeText'); expect(invoke).toHaveBeenCalledOnce(); expect(invoke).toHaveBeenCalledWith( 'transcript:save', { defaultPath: 'session.md', markdown: '# Visible transcript\n' }, ); }); });