Files
makelore/tests/unit/transcript-export-ipc.test.ts
2026-07-29 17:22:35 +08:00

191 lines
6.3 KiB
TypeScript

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<unknown>;
};
};
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' },
);
});
});