mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio), summarize/improve/translate commands, proofread via ai:analyze; calls proxied through main so API keys stay out of the renderer - Collaboration plugin: anchor-based comments in .comments/ sidecars with drift detection and F8 navigation - Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel - Crash recovery: debounced session snapshots with restore prompt on launch - Version history: pre-save snapshots, History panel with restore/diff/delete - Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI) - XLSX export (native workbooks via JSZip), ODT headers/footers + page size - Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering - Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter, writing heatmap, writing-studio panels wired with rail icons - Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links, REPL first-run confirmation - Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI dangling --css/--reference-doc flags, dead converter button 8 new test suites; 613 tests green; lint clean
103 lines
3.8 KiB
JavaScript
103 lines
3.8 KiB
JavaScript
/**
|
|
* @jest-environment node
|
|
*
|
|
* VersionHistory tests with a temp-dir-backed IO bundle: capture-before-save
|
|
* semantics, per-document isolation, pruning, and id validation.
|
|
*/
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const {
|
|
saveVersion,
|
|
listVersions,
|
|
readVersion,
|
|
deleteVersion,
|
|
isValidVersionId,
|
|
folderFor,
|
|
DEFAULT_MAX_KEEP,
|
|
} = require('../../src/main/VersionHistory');
|
|
|
|
describe('VersionHistory', () => {
|
|
let tmpDir, io;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vh_'));
|
|
io = { rootDir: path.join(tmpDir, 'versions'), fs, pathUtil: path, crypto };
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('saves and lists versions newest-first with metadata', () => {
|
|
const v1 = saveVersion({ docPath: '/docs/a.md', content: 'one', label: 'before save', io });
|
|
saveVersion({ docPath: '/docs/a.md', content: 'one two', label: 'before save', io });
|
|
|
|
const versions = listVersions({ docPath: '/docs/a.md', io });
|
|
expect(versions).toHaveLength(2);
|
|
expect(versions[0].createdAt).toBeGreaterThanOrEqual(v1.createdAt);
|
|
expect(versions[0].wordCount).toBe(2);
|
|
expect(versions.every((v) => v.label === 'before save')).toBe(true);
|
|
});
|
|
|
|
it('round-trips version content', () => {
|
|
const { id } = saveVersion({ docPath: '/docs/a.md', content: '# hello\n\nworld', io });
|
|
expect(readVersion({ docPath: '/docs/a.md', id, io })).toBe('# hello\n\nworld');
|
|
});
|
|
|
|
it('isolates documents by path', () => {
|
|
saveVersion({ docPath: '/docs/a.md', content: 'A', io });
|
|
saveVersion({ docPath: '/docs/b.md', content: 'B', io });
|
|
expect(listVersions({ docPath: '/docs/a.md', io })).toHaveLength(1);
|
|
expect(listVersions({ docPath: '/docs/b.md', io })).toHaveLength(1);
|
|
expect(listVersions({ docPath: '/docs/other.md', io })).toHaveLength(0);
|
|
});
|
|
|
|
it('prunes the oldest versions beyond maxKeep', () => {
|
|
const docPath = '/docs/many.md';
|
|
for (let i = 0; i < DEFAULT_MAX_KEEP + 5; i++) {
|
|
saveVersion({ docPath, content: `v${i}`, label: `v${i}`, io });
|
|
}
|
|
const versions = listVersions({ docPath, io });
|
|
expect(versions).toHaveLength(DEFAULT_MAX_KEEP);
|
|
// Newest survive; the oldest five labels are gone
|
|
expect(versions[0].label).toBe(`v${DEFAULT_MAX_KEEP + 4}`);
|
|
expect(versions.some((v) => v.label === 'v0')).toBe(false);
|
|
});
|
|
|
|
it('deleteVersion removes the entry and its blob', () => {
|
|
const docPath = '/docs/del.md';
|
|
const { id } = saveVersion({ docPath, content: 'x', io });
|
|
expect(deleteVersion({ docPath, id, io })).toBe(true);
|
|
expect(listVersions({ docPath, io })).toHaveLength(0);
|
|
expect(() => readVersion({ docPath, id, io })).toThrow();
|
|
expect(deleteVersion({ docPath, id, io })).toBe(false);
|
|
});
|
|
|
|
it('rejects malformed version ids on read (path traversal guard)', () => {
|
|
saveVersion({ docPath: '/docs/a.md', content: 'x', io });
|
|
expect(() => readVersion({ docPath: '/docs/a.md', id: '../../etc/passwd', io })).toThrow(
|
|
/Invalid version id/
|
|
);
|
|
expect(() => readVersion({ docPath: '/docs/a.md', id: 'UPPER!!', io })).toThrow();
|
|
});
|
|
|
|
it('isValidVersionId accepts generated ids and rejects hostile ones', () => {
|
|
expect(isValidVersionId('m3x1e2a-abc123')).toBe(true);
|
|
expect(isValidVersionId('../etc')).toBe(false);
|
|
expect(isValidVersionId('')).toBe(false);
|
|
expect(isValidVersionId(null)).toBe(false);
|
|
});
|
|
|
|
it('folderFor is deterministic and hex-safe', () => {
|
|
expect(folderFor('/docs/a.md', path, crypto)).toBe(
|
|
path.join(
|
|
'by-path',
|
|
crypto.createHash('sha1').update('/docs/a.md').digest('hex').slice(0, 16)
|
|
)
|
|
);
|
|
expect(folderFor('/docs/WEIRDname!!.md', path, crypto)).toMatch(/^[\\/a-z0-9-]+$/i);
|
|
});
|
|
});
|