mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-02 01:29:32 +05:30
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
227 lines
8.5 KiB
JavaScript
227 lines
8.5 KiB
JavaScript
/**
|
|
* @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);
|
|
});
|
|
});
|
|
});
|