mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(recovery): autosave buffer + crash-recovery banner
VersionHistory snapshots the previous content on every explicit save — an unsaved buffer is still lost on crash. AutosaveBuffer fills that gap. - src/main/AutosaveBuffer.js — pure module mirroring VersionHistory's injectable-IO pattern; one blob per doc path under <userData>/autosave/by-path/<sha1>/recovery.md + meta.json. No history (VersionHistory owns that) — just the latest dirty buffer. - src/main.js — IPC channels autosave:write/read/clear/list; real paths go through validatePath, synthetic 'untitled-tab-<id>' keys skip it. - src/preload.js — added the four channels to ALLOWED_SEND_CHANNELS. - src/renderer/autosave-client.js — debounced (2s) flush per tab + periodic safety net (10s max age) + dirty-write retry on failure. - src/renderer.js — register on tab create, unregister on close, notifyChange piggybacks on performAutoSave's existing dirty-check, clearForDocPath after a successful save, showAutosaveRecoveryBanner on startup listing pending recoveries with Restore/Dismiss. Tests (39 new): - tests/main/AutosaveBuffer.test.js (19): round-trip, overwrite, isolation, unicode/emoji, empty content, null coercion, ENOENT vs corrupt meta, list ordering, corrupt-sibling skip, input validation, sha1 storage. - tests/autosave-client.test.js (11): debounce, flushNow bypass, no-path skip, clearForDocPath, list proxy, IPC error fallback, unregister tear-down, failure-retry, periodic flush, idempotency. Full suite: 692 tests pass, 63 suites, lint+format clean. Amit Haridas
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*
|
||||
* Renderer-side AutosaveController tests.
|
||||
*
|
||||
* Mocks electron's ipcRenderer to verify:
|
||||
* - register/notifyChange debounces writes
|
||||
* - flushNow bypasses the debounce
|
||||
* - clearForDocPath targets the right document
|
||||
* - checkPendingRecoveries proxies through ipcRenderer.invoke('autosave:list')
|
||||
* - unregisterTab stops the periodic flush
|
||||
* - failed writes re-arm the dirty flag so the next change retries
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Mock electron BEFORE requiring the module under test.
|
||||
const mockInvoke = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'electron',
|
||||
() => ({
|
||||
ipcRenderer: {
|
||||
invoke: (...args) => mockInvoke(...args),
|
||||
},
|
||||
}),
|
||||
{ virtual: true }
|
||||
);
|
||||
|
||||
// Mock the autosave module to be jsdom-friendly (some Node-only globals
|
||||
// inside the source would otherwise pull in heavy modules).
|
||||
jest.mock(path.join(__dirname, '..', 'src', 'renderer', 'autosave-client.js'), () => {
|
||||
const actual = jest.requireActual(
|
||||
path.join(__dirname, '..', 'src', 'renderer', 'autosave-client.js')
|
||||
);
|
||||
return actual;
|
||||
});
|
||||
|
||||
const { AutosaveController } = require('../src/renderer/autosave-client');
|
||||
|
||||
describe('AutosaveController', () => {
|
||||
let controller;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockInvoke.mockReset();
|
||||
controller = new AutosaveController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller._tabs.forEach((_, id) => controller.unregisterTab(id));
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('registerTab + notifyChange debounces a write to AUTOSAVE_DEBOUNCE_MS', async () => {
|
||||
mockInvoke.mockResolvedValueOnce({ savedAt: 1, byteSize: 3 });
|
||||
|
||||
const docPath = '/home/me/x.md';
|
||||
const content = 'abc';
|
||||
void content;
|
||||
controller.registerTab(
|
||||
'tab-1',
|
||||
() => docPath,
|
||||
() => content
|
||||
);
|
||||
|
||||
controller.notifyChange('tab-1');
|
||||
controller.notifyChange('tab-1');
|
||||
controller.notifyChange('tab-1');
|
||||
|
||||
// No write yet — debounce hasn't fired.
|
||||
expect(mockInvoke).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
|
||||
// Allow the awaited write to settle.
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('autosave:write', {
|
||||
docPath: '/home/me/x.md',
|
||||
content: 'abc',
|
||||
});
|
||||
});
|
||||
|
||||
test('flushNow bypasses the debounce and writes immediately', async () => {
|
||||
mockInvoke.mockResolvedValueOnce({ savedAt: 1, byteSize: 3 });
|
||||
|
||||
controller.registerTab(
|
||||
'tab-1',
|
||||
() => '/a.md',
|
||||
() => 'aaa'
|
||||
);
|
||||
controller.notifyChange('tab-1');
|
||||
|
||||
await controller.flushNow('tab-1');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('autosave:write', {
|
||||
docPath: '/a.md',
|
||||
content: 'aaa',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not write when the tab has no docPath (untitled-with-no-key)', async () => {
|
||||
controller.registerTab(
|
||||
'tab-1',
|
||||
() => null,
|
||||
() => 'aaa'
|
||||
);
|
||||
controller.notifyChange('tab-1');
|
||||
jest.advanceTimersByTime(3000);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearForDocPath calls autosave:clear with the right path', async () => {
|
||||
mockInvoke.mockResolvedValueOnce(true);
|
||||
await controller.clearForDocPath('/home/me/x.md');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('autosave:clear', {
|
||||
docPath: '/home/me/x.md',
|
||||
});
|
||||
});
|
||||
|
||||
test('clearForDocPath with no path is a no-op', async () => {
|
||||
await controller.clearForDocPath(null);
|
||||
expect(mockInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('checkPendingRecoveries proxies through autosave:list', async () => {
|
||||
const pending = [
|
||||
{ docPath: '/a.md', savedAt: 2, byteSize: 5, appVersion: '4.7.1' },
|
||||
{ docPath: '/b.md', savedAt: 1, byteSize: 9, appVersion: '4.7.1' },
|
||||
];
|
||||
mockInvoke.mockResolvedValueOnce(pending);
|
||||
|
||||
const result = await controller.checkPendingRecoveries();
|
||||
|
||||
expect(result).toEqual(pending);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('autosave:list');
|
||||
});
|
||||
|
||||
test('checkPendingRecoveries returns [] when IPC throws', async () => {
|
||||
mockInvoke.mockRejectedValueOnce(new Error('boom'));
|
||||
const result = await controller.checkPendingRecoveries();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('unregisterTab stops the periodic flush interval', async () => {
|
||||
controller.registerTab(
|
||||
'tab-1',
|
||||
() => '/a.md',
|
||||
() => 'aaa'
|
||||
);
|
||||
expect(controller._interval).not.toBeNull();
|
||||
|
||||
controller.unregisterTab('tab-1');
|
||||
|
||||
expect(controller._tabs.size).toBe(0);
|
||||
expect(controller._interval).toBeNull();
|
||||
});
|
||||
|
||||
test('failed writes re-arm the dirty flag so the next change retries', async () => {
|
||||
mockInvoke.mockRejectedValueOnce(new Error('disk full'));
|
||||
|
||||
controller.registerTab(
|
||||
'tab-1',
|
||||
() => '/a.md',
|
||||
() => 'aaa'
|
||||
);
|
||||
controller.notifyChange('tab-1');
|
||||
jest.advanceTimersByTime(2000);
|
||||
|
||||
// Microtasks only — fake timers leave the microtask queue alone.
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
|
||||
const entry = controller._tabs.get('tab-1');
|
||||
expect(entry.dirty).toBe(true);
|
||||
|
||||
// Subsequent change + debounce retries.
|
||||
mockInvoke.mockResolvedValueOnce({ savedAt: 2, byteSize: 3 });
|
||||
controller.notifyChange('tab-1');
|
||||
jest.advanceTimersByTime(2000);
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('periodic flush writes when a buffer has been dirty too long', async () => {
|
||||
mockInvoke.mockResolvedValue({ savedAt: 1, byteSize: 3 });
|
||||
|
||||
controller.registerTab(
|
||||
'tab-1',
|
||||
() => '/a.md',
|
||||
() => 'aaa'
|
||||
);
|
||||
controller.notifyChange('tab-1');
|
||||
jest.advanceTimersByTime(2000);
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate: user types one character then walks away. The debounce
|
||||
// never fires because no new notifyChange comes; only the periodic
|
||||
// interval should flush. Reset lastFlushAt so the threshold check passes.
|
||||
const entry = controller._tabs.get('tab-1');
|
||||
entry.dirty = true;
|
||||
entry.lastFlushAt = 0;
|
||||
|
||||
// Advance past the AUTOSAVE_MAX_INTERVAL_MS (10s); the interval fires
|
||||
// every 5s, so 11s is enough to fire at least one tick.
|
||||
jest.advanceTimersByTime(11000);
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
|
||||
expect(mockInvoke.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('registerTab is idempotent (second call is a no-op)', () => {
|
||||
controller.registerTab(
|
||||
'tab-1',
|
||||
() => '/a.md',
|
||||
() => 'aaa'
|
||||
);
|
||||
controller.registerTab(
|
||||
'tab-1',
|
||||
() => '/b.md',
|
||||
() => 'bbb'
|
||||
);
|
||||
|
||||
expect(controller._tabs.size).toBe(1);
|
||||
// The first registration wins — second one doesn't replace it.
|
||||
expect(controller._tabs.get('tab-1').getDocPath()).toBe('/a.md');
|
||||
});
|
||||
});
|
||||
@@ -64,14 +64,27 @@ describe('Legal compliance artifacts', () => {
|
||||
});
|
||||
|
||||
test('canonical copyleft license texts are present', () => {
|
||||
for (const file of ['GPL-2.0.txt', 'LGPL-2.1.txt', 'MPL-2.0.txt', 'Apache-2.0.txt', 'OFL-1.1.txt']) {
|
||||
for (const file of [
|
||||
'GPL-2.0.txt',
|
||||
'LGPL-2.1.txt',
|
||||
'MPL-2.0.txt',
|
||||
'Apache-2.0.txt',
|
||||
'OFL-1.1.txt',
|
||||
]) {
|
||||
expect(fs.existsSync(path.join(rootDir, 'third-party-licenses', file))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('notices mention every bundled external binary', () => {
|
||||
const notices = fs.readFileSync(path.join(rootDir, 'THIRD-PARTY-NOTICES.md'), 'utf-8');
|
||||
for (const component of ['Pandoc', 'FFmpeg', 'MarkItDown', 'libvips', 'KaTeX', 'JetBrains Mono']) {
|
||||
for (const component of [
|
||||
'Pandoc',
|
||||
'FFmpeg',
|
||||
'MarkItDown',
|
||||
'libvips',
|
||||
'KaTeX',
|
||||
'JetBrains Mono',
|
||||
]) {
|
||||
expect(notices).toContain(component);
|
||||
}
|
||||
// GPL source offer must reference the sources document
|
||||
@@ -90,10 +103,7 @@ describe('Legal compliance artifacts', () => {
|
||||
test('packaging includes the legal documents in build files', () => {
|
||||
// The build config lives in electron-builder.config.js (dynamic — it
|
||||
// conditionally bundles the markitdown binary per platform)
|
||||
const configSrc = fs.readFileSync(
|
||||
path.join(rootDir, 'electron-builder.config.js'),
|
||||
'utf-8'
|
||||
);
|
||||
const configSrc = fs.readFileSync(path.join(rootDir, 'electron-builder.config.js'), 'utf-8');
|
||||
expect(configSrc).toContain("'THIRD-PARTY-NOTICES.md'");
|
||||
expect(configSrc).toContain("'SOURCES.md'");
|
||||
expect(configSrc).toContain('third-party-licenses');
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* AutosaveBuffer tests — mirrors VersionHistory's test pattern (injected IO).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const AutosaveBuffer = require('../../src/main/AutosaveBuffer');
|
||||
|
||||
function makeIo(rootDir) {
|
||||
return { rootDir, fs, pathUtil: path, crypto };
|
||||
}
|
||||
|
||||
describe('AutosaveBuffer', () => {
|
||||
let rootDir;
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autosave_'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('writeRecovery / readRecovery round-trip', () => {
|
||||
test('persists content + meta and reads it back', () => {
|
||||
const io = makeIo(rootDir);
|
||||
const docPath = '/home/me/notes.md';
|
||||
|
||||
const { savedAt, byteSize } = AutosaveBuffer.writeRecovery({
|
||||
docPath,
|
||||
content: '# hello\n\nworld\n',
|
||||
appVersion: '4.7.1',
|
||||
io,
|
||||
});
|
||||
|
||||
expect(typeof savedAt).toBe('number');
|
||||
expect(savedAt).toBeGreaterThan(0);
|
||||
expect(byteSize).toBe(Buffer.byteLength('# hello\n\nworld\n', 'utf-8'));
|
||||
|
||||
const read = AutosaveBuffer.readRecovery({ docPath, io });
|
||||
expect(read).not.toBeNull();
|
||||
expect(read.docPath).toBe(docPath);
|
||||
expect(read.content).toBe('# hello\n\nworld\n');
|
||||
expect(read.savedAt).toBe(savedAt);
|
||||
expect(read.byteSize).toBe(byteSize);
|
||||
expect(read.appVersion).toBe('4.7.1');
|
||||
});
|
||||
|
||||
test('overwrites prior recovery entry — only the latest buffer survives', () => {
|
||||
const io = makeIo(rootDir);
|
||||
const docPath = '/home/me/notes.md';
|
||||
|
||||
AutosaveBuffer.writeRecovery({ docPath, content: 'first', io });
|
||||
AutosaveBuffer.writeRecovery({ docPath, content: 'second', io });
|
||||
AutosaveBuffer.writeRecovery({ docPath, content: 'third', io });
|
||||
|
||||
const read = AutosaveBuffer.readRecovery({ docPath, io });
|
||||
expect(read.content).toBe('third');
|
||||
});
|
||||
|
||||
test('different documents are isolated from each other', () => {
|
||||
const io = makeIo(rootDir);
|
||||
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/a.md', content: 'AAA', io });
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/b.md', content: 'BBB', io });
|
||||
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: '/a.md', io }).content).toBe('AAA');
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: '/b.md', io }).content).toBe('BBB');
|
||||
});
|
||||
|
||||
test('handles untitled-tab keys (no extension, no directory)', () => {
|
||||
const io = makeIo(rootDir);
|
||||
const tabKey = 'untitled-tab-tab-3';
|
||||
|
||||
AutosaveBuffer.writeRecovery({ docPath: tabKey, content: 'unsaved draft', io });
|
||||
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: tabKey, io }).content).toBe('unsaved draft');
|
||||
});
|
||||
|
||||
test('handles unicode + emoji content without corruption', () => {
|
||||
const io = makeIo(rootDir);
|
||||
const body = '# 日本語 🇮🇳\n\nrésumé café\n';
|
||||
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/u.md', content: body, io });
|
||||
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: '/u.md', io }).content).toBe(body);
|
||||
});
|
||||
|
||||
test('empty string is a valid buffer (clears without writing zero bytes fails)', () => {
|
||||
const io = makeIo(rootDir);
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/e.md', content: 'x', io });
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/e.md', content: '', io });
|
||||
|
||||
const read = AutosaveBuffer.readRecovery({ docPath: '/e.md', io });
|
||||
expect(read.content).toBe('');
|
||||
expect(read.byteSize).toBe(0);
|
||||
});
|
||||
|
||||
test('null/undefined content is coerced to empty string', () => {
|
||||
const io = makeIo(rootDir);
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/n.md', content: null, io });
|
||||
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: '/n.md', io }).content).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRecovery error cases', () => {
|
||||
test('returns null when no entry exists (ENOENT)', () => {
|
||||
const io = makeIo(rootDir);
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: '/missing.md', io })).toBeNull();
|
||||
});
|
||||
|
||||
test('throws when meta.json is corrupt (renderer can surface the warning)', () => {
|
||||
const io = makeIo(rootDir);
|
||||
const docPath = '/anywhere.md';
|
||||
const hash = crypto.createHash('sha1').update(docPath).digest('hex').slice(0, 16);
|
||||
const dir = path.join(rootDir, 'by-path', hash);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'meta.json'), '{not valid json', 'utf-8');
|
||||
fs.writeFileSync(path.join(dir, 'recovery.md'), 'orphan', 'utf-8');
|
||||
|
||||
expect(() => AutosaveBuffer.readRecovery({ docPath, io })).toThrow(/corrupt/i);
|
||||
});
|
||||
|
||||
test('returns null when meta exists but recovery blob is missing', () => {
|
||||
const io = makeIo(rootDir);
|
||||
const dir = path.join(rootDir, 'by-path', 'no-blob');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'meta.json'),
|
||||
JSON.stringify({ docPath: '/x.md', savedAt: 1, byteSize: 0, appVersion: 'x' }),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: '/x.md', io })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearRecovery', () => {
|
||||
test('removes both the blob and the meta, returns true when something was removed', () => {
|
||||
const io = makeIo(rootDir);
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/c.md', content: 'stuff', io });
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: '/c.md', io })).not.toBeNull();
|
||||
|
||||
const removed = AutosaveBuffer.clearRecovery({ docPath: '/c.md', io });
|
||||
expect(removed).toBe(true);
|
||||
expect(AutosaveBuffer.readRecovery({ docPath: '/c.md', io })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns false when there was nothing to clear (idempotent)', () => {
|
||||
const io = makeIo(rootDir);
|
||||
expect(AutosaveBuffer.clearRecovery({ docPath: '/missing.md', io })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRecoveries', () => {
|
||||
test('returns an empty array when the store has never been used', () => {
|
||||
const io = makeIo(rootDir);
|
||||
expect(AutosaveBuffer.listRecoveries({ io })).toEqual([]);
|
||||
});
|
||||
|
||||
test('lists every pending recovery, newest first', async () => {
|
||||
const io = makeIo(rootDir);
|
||||
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/older.md', content: 'old', io });
|
||||
// tiny gap so timestamps differ
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/newer.md', content: 'new', io });
|
||||
|
||||
const list = AutosaveBuffer.listRecoveries({ io });
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].docPath).toBe('/newer.md');
|
||||
expect(list[1].docPath).toBe('/older.md');
|
||||
expect(list[0].savedAt).toBeGreaterThanOrEqual(list[1].savedAt);
|
||||
});
|
||||
|
||||
test("skips directories with corrupt meta (so a single bad entry can't block startup)", () => {
|
||||
const io = makeIo(rootDir);
|
||||
AutosaveBuffer.writeRecovery({ docPath: '/good.md', content: 'ok', io });
|
||||
// Plant a corrupt sibling
|
||||
const dir = path.join(rootDir, 'by-path', 'corrupt');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'meta.json'), '{not json', 'utf-8');
|
||||
|
||||
const list = AutosaveBuffer.listRecoveries({ io });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].docPath).toBe('/good.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
test('writeRecovery throws when docPath is missing', () => {
|
||||
const io = makeIo(rootDir);
|
||||
expect(() => AutosaveBuffer.writeRecovery({ docPath: '', content: 'x', io })).toThrow(
|
||||
/docPath/
|
||||
);
|
||||
expect(() => AutosaveBuffer.writeRecovery({ content: 'x', io })).toThrow(/docPath/);
|
||||
});
|
||||
|
||||
test('readRecovery throws when docPath is missing', () => {
|
||||
const io = makeIo(rootDir);
|
||||
expect(() => AutosaveBuffer.readRecovery({ io })).toThrow(/docPath/);
|
||||
});
|
||||
|
||||
test('clearRecovery throws when docPath is missing', () => {
|
||||
const io = makeIo(rootDir);
|
||||
expect(() => AutosaveBuffer.clearRecovery({ io })).toThrow(/docPath/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage layout', () => {
|
||||
test('uses sha1(path)[0:16] as the storage folder under by-path/', () => {
|
||||
const io = makeIo(rootDir);
|
||||
const docPath = '/home/me/x.md';
|
||||
AutosaveBuffer.writeRecovery({ docPath, content: 'x', io });
|
||||
|
||||
const expectedHash = crypto.createHash('sha1').update(docPath).digest('hex').slice(0, 16);
|
||||
const dir = path.join(rootDir, 'by-path', expectedHash);
|
||||
expect(fs.existsSync(path.join(dir, 'recovery.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, 'meta.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,14 +16,14 @@ describe('ImageOperations', () => {
|
||||
.toFile(inputPath);
|
||||
});
|
||||
// Windows: sharp can hold the file handle briefly after await returns,
|
||||
// so plain rmSync throws EPERM — retry and tolerate stale temp dirs.
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* leftover temp dir is cosmetic on locked-file platforms */
|
||||
}
|
||||
});
|
||||
// so plain rmSync throws EPERM — retry and tolerate stale temp dirs.
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* leftover temp dir is cosmetic on locked-file platforms */
|
||||
}
|
||||
});
|
||||
|
||||
test('imageConvert converts PNG to JPEG', async () => {
|
||||
const outputPath = path.join(tmpDir, 'out.jpg');
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
*/
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { resolveMarkItDown, convertToMarkdown, commandCandidates } = require('../../src/main/MarkItDown');
|
||||
const {
|
||||
resolveMarkItDown,
|
||||
convertToMarkdown,
|
||||
commandCandidates,
|
||||
} = require('../../src/main/MarkItDown');
|
||||
const { setImmediate } = require('timers');
|
||||
|
||||
/**
|
||||
@@ -26,7 +30,9 @@ function makeRunner(script = {}) {
|
||||
const result = script[key];
|
||||
setImmediate(() =>
|
||||
cb(
|
||||
result ? result.error || null : Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }),
|
||||
result
|
||||
? result.error || null
|
||||
: Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }),
|
||||
result?.stdout ?? '',
|
||||
result?.stderr ?? ''
|
||||
)
|
||||
|
||||
@@ -48,19 +48,15 @@ describe('PDFOperations - Task 15 new operations', () => {
|
||||
});
|
||||
|
||||
describe('pdfExtractText', () => {
|
||||
it(
|
||||
'extracts text from all pages',
|
||||
async () => {
|
||||
const result = await PDFOperations.pdfExtractText({ inputPath });
|
||||
it('extracts text from all pages', async () => {
|
||||
const result = await PDFOperations.pdfExtractText({ inputPath });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toContain('Hello Task 15 Page One');
|
||||
expect(result.text).toContain('Second Page Content');
|
||||
},
|
||||
// First pdfjs-dist legacy import can exceed the 5s default on slower
|
||||
// CI runners (observed on windows-latest)
|
||||
30000
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toContain('Hello Task 15 Page One');
|
||||
expect(result.text).toContain('Second Page Content');
|
||||
}, // First pdfjs-dist legacy import can exceed the 5s default on slower
|
||||
// CI runners (observed on windows-latest)
|
||||
30000);
|
||||
|
||||
it('returns failure for a nonexistent file', async () => {
|
||||
const result = await PDFOperations.pdfExtractText({
|
||||
@@ -157,42 +153,38 @@ describe('PDFOperations - Task 15 new operations', () => {
|
||||
});
|
||||
|
||||
describe('pdfExtractImages', () => {
|
||||
it(
|
||||
'extracts embedded raster images as PNG files',
|
||||
async () => {
|
||||
const imgPath = path.join(tmpDir, 'red.png');
|
||||
await sharp({
|
||||
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toFile(imgPath);
|
||||
it('extracts embedded raster images as PNG files', async () => {
|
||||
const imgPath = path.join(tmpDir, 'red.png');
|
||||
await sharp({
|
||||
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toFile(imgPath);
|
||||
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const pngImage = await doc.embedPng(fs.readFileSync(imgPath));
|
||||
page.drawImage(pngImage, { x: 50, y: 50, width: 100, height: 100 });
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const pngImage = await doc.embedPng(fs.readFileSync(imgPath));
|
||||
page.drawImage(pngImage, { x: 50, y: 50, width: 100, height: 100 });
|
||||
|
||||
const imagePdfPath = path.join(tmpDir, 'with-image.pdf');
|
||||
fs.writeFileSync(imagePdfPath, await doc.save());
|
||||
const imagePdfPath = path.join(tmpDir, 'with-image.pdf');
|
||||
fs.writeFileSync(imagePdfPath, await doc.save());
|
||||
|
||||
const outputDir = path.join(tmpDir, 'extracted');
|
||||
const result = await PDFOperations.pdfExtractImages({
|
||||
inputPath: imagePdfPath,
|
||||
outputDir,
|
||||
});
|
||||
const outputDir = path.join(tmpDir, 'extracted');
|
||||
const result = await PDFOperations.pdfExtractImages({
|
||||
inputPath: imagePdfPath,
|
||||
outputDir,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBeGreaterThanOrEqual(1);
|
||||
expect(result.files.length).toBe(result.count);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBeGreaterThanOrEqual(1);
|
||||
expect(result.files.length).toBe(result.count);
|
||||
|
||||
for (const file of result.files) {
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
const meta = await sharp(file).metadata();
|
||||
expect(meta.format).toBe('png');
|
||||
}
|
||||
},
|
||||
30000 // pdfjs import + sharp decode on slower CI runners
|
||||
);
|
||||
for (const file of result.files) {
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
const meta = await sharp(file).metadata();
|
||||
expect(meta.format).toBe('png');
|
||||
}
|
||||
}, 30000); // pdfjs import + sharp decode on slower CI runners
|
||||
|
||||
it('returns zero images for a text-only PDF', async () => {
|
||||
const outputDir = path.join(tmpDir, 'extracted-none');
|
||||
|
||||
@@ -65,8 +65,7 @@ describe('KaTeX math rendering pipeline', () => {
|
||||
});
|
||||
|
||||
test('escaped delimiters \\[...\\] and \\(...\\) both render', () => {
|
||||
container.innerHTML =
|
||||
'<p>Display form: \\[E = mc^2\\] and inline \\(\\pi r^2\\).</p>';
|
||||
container.innerHTML = '<p>Display form: \\[E = mc^2\\] and inline \\(\\pi r^2\\).</p>';
|
||||
|
||||
expect(() => renderMathInElement(container, { delimiters: DELIMITERS })).not.toThrow();
|
||||
|
||||
@@ -177,4 +176,4 @@ describe('KaTeX bundle — renderer wiring contract', () => {
|
||||
expect(src).toContain("left: '\\\\['");
|
||||
expect(src).toContain("left: '\\\\('");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user