191 lines
6.3 KiB
TypeScript
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' },
|
|
);
|
|
});
|
|
});
|