Makelore 2.0 initial clean snapshot
This commit is contained in:
190
tests/unit/transcript-export-ipc.test.ts
Normal file
190
tests/unit/transcript-export-ipc.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user