mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features
- 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
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* AI Assistant prompt builder + proofread parser tests (pure module, jsdom
|
||||
* not required but harmless under the default environment).
|
||||
*/
|
||||
const {
|
||||
buildTaskPrompt,
|
||||
buildProofreadPrompt,
|
||||
parseProofreadIssues,
|
||||
} = require('../src/plugins/built-in/ai-assistant/prompts');
|
||||
|
||||
describe('AI Assistant prompts', () => {
|
||||
describe('buildTaskPrompt', () => {
|
||||
it('builds a summarize prompt embedding the text', () => {
|
||||
const { system, user } = buildTaskPrompt('summarize', 'the text here');
|
||||
expect(system).toMatch(/summar/i);
|
||||
expect(user).toContain('the text here');
|
||||
});
|
||||
|
||||
it('passes the target language through for translate', () => {
|
||||
const { user } = buildTaskPrompt('translate', 'bonjour', 'English');
|
||||
expect(user).toContain('English');
|
||||
expect(user).toContain('bonjour');
|
||||
});
|
||||
|
||||
it('throws on unknown actions', () => {
|
||||
expect(() => buildTaskPrompt('nope', 'x')).toThrow(/Unknown AI task/);
|
||||
});
|
||||
|
||||
it('tolerates missing text', () => {
|
||||
expect(buildTaskPrompt('explain', undefined).user).toBe('Explain the following:\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProofreadPrompt', () => {
|
||||
it('asks for a strict JSON array', () => {
|
||||
const { system, user } = buildProofreadPrompt('Some text.');
|
||||
expect(system).toMatch(/JSON array/);
|
||||
expect(user).toContain('Some text.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseProofreadIssues', () => {
|
||||
it('parses a clean JSON array', () => {
|
||||
const issues = parseProofreadIssues(
|
||||
'[{"type":"Spelling","message":"teh","suggestion":"the"}]'
|
||||
);
|
||||
expect(issues).toEqual([{ type: 'spelling', message: 'teh', suggestion: 'the' }]);
|
||||
});
|
||||
|
||||
it('strips code fences and surrounding prose', () => {
|
||||
const issues = parseProofreadIssues(
|
||||
'Here you go:\n```json\n[{"type":"grammar","message":"run on","suggestion":"split"}]\n```\nDone!'
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].suggestion).toBe('split');
|
||||
});
|
||||
|
||||
it('tolerates trailing commas', () => {
|
||||
const issues = parseProofreadIssues(
|
||||
'[{"type":"style","message":"wordy","suggestion":"cut",},]'
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns [] for empty/garbage input instead of throwing', () => {
|
||||
expect(parseProofreadIssues('')).toEqual([]);
|
||||
expect(parseProofreadIssues('no json at all')).toEqual([]);
|
||||
expect(parseProofreadIssues('{"not":"an array"}')).toEqual([]);
|
||||
expect(parseProofreadIssues(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops malformed entries and caps the list length', () => {
|
||||
const many = Array.from({ length: 300 }, (_, i) => ({
|
||||
type: 'grammar',
|
||||
message: `m${i}`,
|
||||
suggestion: 's',
|
||||
}));
|
||||
expect(parseProofreadIssues(JSON.stringify(many))).toHaveLength(100);
|
||||
const mixed = parseProofreadIssues('[{"message":"ok","suggestion":"x"},null,42,{"foo":1}]');
|
||||
// null/number entries dropped; {foo:1} dropped for lacking message+ suggestion
|
||||
expect(mixed).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Collaboration comment-store tests — pure logic with in-memory IO fakes,
|
||||
* covering persistence, navigation ordering, and anchor drift detection.
|
||||
*/
|
||||
const path = require('path');
|
||||
const store = require('../src/plugins/built-in/collaboration/comment-store');
|
||||
|
||||
/** In-memory IO fake shaped like the plugin's IPC adapters. */
|
||||
function makeIo(initial = {}) {
|
||||
const files = new Map(Object.entries(initial));
|
||||
return {
|
||||
files,
|
||||
readFile: (p) => (files.has(p) ? files.get(p) : null),
|
||||
writeFile: (p, c) => {
|
||||
files.set(p, c);
|
||||
},
|
||||
fileExists: (p) => files.has(p),
|
||||
ensureDirectory: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('comment-store', () => {
|
||||
const doc = path.join('/docs', 'notes.md');
|
||||
const sidecar = path.join('/docs', '.comments', 'notes.md.json');
|
||||
|
||||
describe('commentsFilePathFor', () => {
|
||||
it('places the sidecar in .comments/ next to the document', () => {
|
||||
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
|
||||
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
|
||||
path.join('/a/b/.comments', 'c.md.json')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load/save round-trip', () => {
|
||||
it('returns [] for documents with no sidecar yet', () => {
|
||||
expect(store.loadComments(doc, makeIo(), path)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for corrupt JSON instead of throwing', () => {
|
||||
const io = makeIo({ [sidecar]: '{oops' });
|
||||
expect(store.loadComments(doc, io, path)).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists and reloads comments sorted by line', () => {
|
||||
const io = makeIo();
|
||||
const comments = [];
|
||||
store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' });
|
||||
store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' });
|
||||
store.saveComments(doc, comments, io, path);
|
||||
|
||||
const loaded = store.loadComments(doc, io, path);
|
||||
expect(loaded.map((c) => c.line)).toEqual([3, 12]);
|
||||
expect(loaded[0].text).toBe('strong opener');
|
||||
expect(loaded[0].resolved).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes malformed entries on load', () => {
|
||||
const io = makeIo({
|
||||
[sidecar]: JSON.stringify({
|
||||
comments: [
|
||||
{ line: '7', text: 42 },
|
||||
{ line: 2, text: 'ok', author: 'ana' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const loaded = store.loadComments(doc, io, path);
|
||||
// '7' (string line) is dropped; the valid one survives normalization
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutators', () => {
|
||||
it('addComment clamps lines and caps field lengths', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, {
|
||||
line: -5,
|
||||
anchorText: 'x'.repeat(500),
|
||||
text: 'y'.repeat(5000),
|
||||
});
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(c.line).toBe(1);
|
||||
expect(c.anchorText.length).toBeLessThanOrEqual(200);
|
||||
expect(c.text.length).toBeLessThanOrEqual(4000);
|
||||
expect(c.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggleResolved flips state and returns null for unknown ids', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||
expect(store.toggleResolved(comments, c.id)).toBe(true);
|
||||
expect(store.toggleResolved(comments, c.id)).toBe(false);
|
||||
expect(store.toggleResolved(comments, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteComment reports whether anything was removed', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||
expect(store.deleteComment(comments, c.id)).toBe(true);
|
||||
expect(comments).toHaveLength(0);
|
||||
expect(store.deleteComment(comments, c.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextUnresolved (F8 navigation)', () => {
|
||||
it('returns the first open comment below the cursor line', () => {
|
||||
const comments = [
|
||||
{ id: 'a', line: 5, resolved: false },
|
||||
{ id: 'b', line: 20, resolved: false },
|
||||
];
|
||||
expect(store.nextUnresolved(comments, 7).id).toBe('b');
|
||||
});
|
||||
|
||||
it('wraps to the top when no open comments are below', () => {
|
||||
const comments = [
|
||||
{ id: 'a', line: 5, resolved: false },
|
||||
{ id: 'b', line: 20, resolved: true },
|
||||
];
|
||||
expect(store.nextUnresolved(comments, 10).id).toBe('a');
|
||||
});
|
||||
|
||||
it('returns null when everything is resolved or empty', () => {
|
||||
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
|
||||
expect(store.nextUnresolved([], 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchorStatus (drift detection)', () => {
|
||||
const base = { line: 2, anchorText: 'original text' };
|
||||
|
||||
it('reports ok when the line still matches', () => {
|
||||
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
|
||||
});
|
||||
|
||||
it('reports changed when the line text differs', () => {
|
||||
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
|
||||
});
|
||||
|
||||
it('reports missing when the line is beyond the document', () => {
|
||||
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
|
||||
expect(store.anchorStatus(base, [])).toBe('missing');
|
||||
});
|
||||
|
||||
it('reports moved for comments without an anchor snippet', () => {
|
||||
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* AiProviders tests with a stubbed fetch — no network access. Verifies the
|
||||
* provider dispatch, auth handling, timeouts, and the user-safe error paths.
|
||||
*/
|
||||
const {
|
||||
complete,
|
||||
resolveSettings,
|
||||
AiProviderError,
|
||||
PROVIDER_DEFAULTS,
|
||||
MAX_PROMPT_CHARS,
|
||||
} = require('../../src/main/AiProviders');
|
||||
|
||||
/** Build a fetch stub returning the given JSON with status 200. */
|
||||
function okFetch(body) {
|
||||
return jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
});
|
||||
}
|
||||
|
||||
describe('AiProviders', () => {
|
||||
describe('resolveSettings', () => {
|
||||
it('applies provider defaults for base URL and model', () => {
|
||||
const s = resolveSettings({ provider: 'ollama' });
|
||||
expect(s.baseUrl).toBe(PROVIDER_DEFAULTS.ollama.baseUrl);
|
||||
expect(s.model).toBe(PROVIDER_DEFAULTS.ollama.defaultModel);
|
||||
expect(s.apiKey).toBe('');
|
||||
});
|
||||
|
||||
it('does not require an API key for local providers', () => {
|
||||
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
|
||||
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires an API key for remote providers', () => {
|
||||
expect(() => resolveSettings({ provider: 'openai' })).toThrow(AiProviderError);
|
||||
expect(() => resolveSettings({ provider: 'anthropic' })).toThrow(/API key/);
|
||||
});
|
||||
|
||||
it('rejects unknown providers and non-http(s) base URLs', () => {
|
||||
expect(() => resolveSettings({ provider: 'nope' })).toThrow(/Unknown AI provider/);
|
||||
expect(() =>
|
||||
resolveSettings({ provider: 'openai-compatible', apiKey: 'k', baseUrl: 'file:///etc' })
|
||||
).toThrow(/Invalid API base URL/);
|
||||
});
|
||||
|
||||
it('clamps temperature into [0, 2]', () => {
|
||||
expect(resolveSettings({ provider: 'ollama', temperature: 99 }).temperature).toBe(2);
|
||||
expect(resolveSettings({ provider: 'ollama', temperature: -5 }).temperature).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
const messages = [{ role: 'user', content: 'hello' }];
|
||||
|
||||
it('rejects requests with no messages', async () => {
|
||||
await expect(complete({ provider: 'ollama', messages: [] })).rejects.toThrow(/No messages/);
|
||||
});
|
||||
|
||||
it('rejects prompts over the size cap', async () => {
|
||||
await expect(
|
||||
complete({
|
||||
provider: 'ollama',
|
||||
messages: [{ role: 'user', content: 'x'.repeat(MAX_PROMPT_CHARS + 1) }],
|
||||
})
|
||||
).rejects.toThrow(/too large/i);
|
||||
});
|
||||
|
||||
it('sends the OpenAI chat-completions shape with a bearer key', async () => {
|
||||
const fetchImpl = okFetch({ choices: [{ message: { content: 'hi there' } }] });
|
||||
const result = await complete(
|
||||
{ provider: 'openai', apiKey: 'sk-test', messages },
|
||||
{ fetchImpl }
|
||||
);
|
||||
|
||||
expect(result.content).toBe('hi there');
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('https://api.openai.com/v1/chat/completions');
|
||||
expect(init.headers.Authorization).toBe('Bearer sk-test');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.messages[0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('prepends the system prompt as a system message for OpenAI-style providers', async () => {
|
||||
const fetchImpl = okFetch({ choices: [{ message: { content: 'ok' } }] });
|
||||
await complete({ provider: 'ollama', system: 'be brief', messages }, { fetchImpl });
|
||||
const body = JSON.parse(fetchImpl.mock.calls[0][1].body);
|
||||
expect(body.messages[0]).toEqual({ role: 'system', content: 'be brief' });
|
||||
});
|
||||
|
||||
it('sends the Anthropic messages shape with x-api-key header', async () => {
|
||||
const fetchImpl = okFetch({
|
||||
content: [{ type: 'text', text: 'claude says hi' }],
|
||||
});
|
||||
const result = await complete(
|
||||
{ provider: 'anthropic', apiKey: 'ak-test', system: 'be nice', messages },
|
||||
{ fetchImpl }
|
||||
);
|
||||
|
||||
expect(result.content).toBe('claude says hi');
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages');
|
||||
expect(init.headers['x-api-key']).toBe('ak-test');
|
||||
expect(init.headers['anthropic-version']).toBe('2023-06-01');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.system).toBe('be nice');
|
||||
});
|
||||
|
||||
it('surfaces HTTP failures as user-safe errors without the body', async () => {
|
||||
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||
await expect(
|
||||
complete({ provider: 'openai', apiKey: 'bad', messages }, { fetchImpl })
|
||||
).rejects.toThrow(/HTTP 401/);
|
||||
});
|
||||
|
||||
it('rejects unexpected response shapes', async () => {
|
||||
const fetchImpl = okFetch({ unexpected: true });
|
||||
await expect(
|
||||
complete({ provider: 'openai', apiKey: 'k', messages }, { fetchImpl })
|
||||
).rejects.toThrow(/unexpected response/i);
|
||||
});
|
||||
|
||||
it('surfaces aborts as timeouts', async () => {
|
||||
const fetchImpl = jest.fn(
|
||||
(_url, init) =>
|
||||
new Promise((_res, rej) => {
|
||||
init.signal.addEventListener('abort', () =>
|
||||
rej(new Error('The operation was aborted'))
|
||||
);
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
complete({ provider: 'ollama', messages }, { fetchImpl, timeoutMs: 30 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* OdtStyling.js tests: page-size patching and header/footer injection into a
|
||||
* Pandoc-style ODT package. Fixtures are minimal zips built with PizZip,
|
||||
* mirroring the structure Pandoc's ODT writer emits (styles.xml with a "pm1"
|
||||
* page layout and a "Standard" master page).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const PizZip = require('pizzip');
|
||||
const {
|
||||
setOdtPageSize,
|
||||
addHeaderFooterToOdt,
|
||||
parseDimensionsMm,
|
||||
escapeOdtText,
|
||||
} = require('../../src/main/OdtStyling');
|
||||
|
||||
// Minimal Pandoc-like styles.xml: page layout pm1 + Standard master page
|
||||
const STYLES_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
||||
office:version="1.2">
|
||||
<office:automatic-styles>
|
||||
<style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:margin-top="0.787in" fo:page-width="8.267in" fo:page-height="11.69in" style:print-orientation="portrait"/>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||||
</office:master-styles>
|
||||
</office:document-styles>`;
|
||||
|
||||
/**
|
||||
* Write a minimal .odt fixture containing just styles.xml.
|
||||
* @param {string} filePath destination
|
||||
* @param {string} [stylesXml] styles.xml content (defaults to STYLES_XML)
|
||||
*/
|
||||
function writeOdtFixture(filePath, stylesXml = STYLES_XML) {
|
||||
const zip = new PizZip();
|
||||
zip.file('mimetype', 'application/vnd.oasis.opendocument.text');
|
||||
zip.file('styles.xml', stylesXml);
|
||||
zip.file('content.xml', '<office:document-content/>');
|
||||
fs.writeFileSync(filePath, zip.generate({ type: 'nodebuffer' }));
|
||||
}
|
||||
|
||||
/** Read styles.xml back out of a written fixture. */
|
||||
function readStylesXml(filePath) {
|
||||
return new PizZip(fs.readFileSync(filePath)).file('styles.xml').asText();
|
||||
}
|
||||
|
||||
describe('OdtStyling', () => {
|
||||
let tmpDir, odtPath;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odtstyle_'));
|
||||
odtPath = path.join(tmpDir, 'doc.odt');
|
||||
writeOdtFixture(odtPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('parseDimensionsMm', () => {
|
||||
it('parses "210×297mm" (U+00D7 separator) into numbers', () => {
|
||||
expect(parseDimensionsMm('210×297mm')).toEqual({ widthMm: 210, heightMm: 297 });
|
||||
});
|
||||
|
||||
it('returns null for unparseable input', () => {
|
||||
expect(parseDimensionsMm('8.5in')).toBeNull();
|
||||
expect(parseDimensionsMm(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeOdtText', () => {
|
||||
it('escapes XML-significant characters', () => {
|
||||
expect(escapeOdtText('a<b>&"c"')).toBe('a<b>&"c"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOdtPageSize', () => {
|
||||
it('rewrites width/height/orientation on the existing page layout (A4 portrait)', async () => {
|
||||
await setOdtPageSize(odtPath, {
|
||||
size: 'a4',
|
||||
orientation: 'portrait',
|
||||
pageSizes: { a4: { dimensions: '210×297mm' } },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('fo:page-width="210mm"');
|
||||
expect(xml).toContain('fo:page-height="297mm"');
|
||||
expect(xml).toContain('style:print-orientation="portrait"');
|
||||
// Old inch-based attributes must be gone (no duplicate size attrs)
|
||||
expect(xml).not.toContain('fo:page-width="8.267in"');
|
||||
});
|
||||
|
||||
it('swaps dimensions for landscape', async () => {
|
||||
await setOdtPageSize(odtPath, {
|
||||
size: 'a4',
|
||||
orientation: 'landscape',
|
||||
pageSizes: { a4: { dimensions: '210×297mm' } },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('fo:page-width="297mm"');
|
||||
expect(xml).toContain('fo:page-height="210mm"');
|
||||
expect(xml).toContain('style:print-orientation="landscape"');
|
||||
});
|
||||
|
||||
it('falls back to custom mm dimensions and A4 default', async () => {
|
||||
await setOdtPageSize(odtPath, { customWidth: '148', customHeight: '210' });
|
||||
expect(readStylesXml(odtPath)).toContain('fo:page-width="148mm"');
|
||||
|
||||
writeOdtFixture(odtPath);
|
||||
await setOdtPageSize(odtPath, {});
|
||||
expect(readStylesXml(odtPath)).toContain('fo:page-width="210mm"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHeaderFooterToOdt', () => {
|
||||
it('is a no-op when disabled', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: false, header: { left: 'X' } });
|
||||
expect(readStylesXml(odtPath)).not.toContain('<style:header');
|
||||
});
|
||||
|
||||
it('injects header and footer regions into the Standard master page', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
header: { left: 'My Doc', right: 'Draft <v2>' },
|
||||
footer: { center: 'Page $PAGE$ of $TOTAL$' },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('<style:header>');
|
||||
expect(xml).toContain('<style:region-left');
|
||||
expect(xml).toContain('My Doc');
|
||||
// User text must be XML-escaped
|
||||
expect(xml).toContain('Draft <v2>');
|
||||
expect(xml).toContain('<text:page-number>');
|
||||
expect(xml).toContain('<text:page-count>');
|
||||
// The self-closing master page must have been expanded to hold children
|
||||
expect(xml).toMatch(/<style:master-page[^>]*>[\s\S]*<\/style:master-page>/);
|
||||
});
|
||||
|
||||
it('does nothing when all regions are empty', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
header: { left: '', center: '', right: '' },
|
||||
footer: {},
|
||||
});
|
||||
expect(readStylesXml(odtPath)).toBe(STYLES_XML);
|
||||
});
|
||||
|
||||
it('creates a master page when the package has none', async () => {
|
||||
const bareXml = STYLES_XML.replace(
|
||||
/ <office:master-styles>[\s\S]*<\/office:master-styles>/,
|
||||
''
|
||||
);
|
||||
writeOdtFixture(odtPath, bareXml);
|
||||
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
footer: { center: 'hi' },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('<office:master-styles>');
|
||||
expect(xml).toContain('style:name="Standard"');
|
||||
expect(xml).toContain('hi');
|
||||
});
|
||||
|
||||
it('replaces existing header/footer content on re-export', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'first' } });
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'second' } });
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('second');
|
||||
expect(xml).not.toContain('first');
|
||||
// Exactly one header element, not nested duplicates
|
||||
expect(xml.match(/<style:header>/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
|
||||
const { PDFDocument, StandardFonts, rgb } = require('@cantoo/pdf-lib');
|
||||
const PDFOperations = require('../../src/main/PDFOperations');
|
||||
const {
|
||||
runPDFBatchOperation,
|
||||
|
||||
@@ -14,7 +14,7 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
|
||||
const { PDFDocument, StandardFonts, rgb } = require('@cantoo/pdf-lib');
|
||||
const PDFOperations = require('../../src/main/PDFOperations');
|
||||
|
||||
describe('PDFOperations - Task 15 new operations', () => {
|
||||
@@ -386,7 +386,7 @@ describe('PDFOperations - Task 16 form field fill/flatten', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDFOperations - Task 27 honest encryption failure', () => {
|
||||
describe('PDFOperations - real encryption (@cantoo/pdf-lib)', () => {
|
||||
let tmpDir, inputPath;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -402,16 +402,16 @@ describe('PDFOperations - Task 27 honest encryption failure', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('detects the bundled pdf-lib as encryption-incapable via the module-load probe', async () => {
|
||||
// Pins the Task 27 premise: pdf-lib 1.17.1's save() ignores password
|
||||
// options (SaveOptions has no such fields), so the probe — which saves a
|
||||
// tiny document with a userPassword and checks the bytes for /Encrypt —
|
||||
// must report false. If this fails after a library swap, the probe
|
||||
// re-enabled the ops and the honest-failure tests below no longer apply.
|
||||
await expect(PDFOperations.pdfEncryptionSupported).resolves.toBe(false);
|
||||
it('detects the encryption-capable library via the module-load probe', async () => {
|
||||
// The @cantoo/pdf-lib fork adds doc.encrypt(); the probe — which encrypts
|
||||
// a tiny document and checks the bytes for /Encrypt — must report true so
|
||||
// the password ops are enabled in the UI. If this fails after a library
|
||||
// change, the ops fall back to honest failure and these tests no longer
|
||||
// apply.
|
||||
await expect(PDFOperations.pdfEncryptionSupported).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('pdfEncrypt fails honestly without writing an output file', async () => {
|
||||
it('pdfEncrypt writes an encrypted PDF that requires the password', async () => {
|
||||
const outputPath = path.join(tmpDir, 'encrypted.pdf');
|
||||
const result = await PDFOperations.pdfEncrypt({
|
||||
inputPath,
|
||||
@@ -421,60 +421,90 @@ describe('PDFOperations - Task 27 honest encryption failure', () => {
|
||||
permissions: { printing: true },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
|
||||
expect(fs.existsSync(outputPath)).toBe(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(outputPath)).toBe(true);
|
||||
const bytes = fs.readFileSync(outputPath);
|
||||
expect(bytes.includes('/Encrypt')).toBe(true);
|
||||
|
||||
// Correct password opens it; wrong password is rejected.
|
||||
const opened = await PDFDocument.load(bytes, { password: 'secret' });
|
||||
expect(opened.getPageCount()).toBe(1);
|
||||
await expect(PDFDocument.load(bytes, { password: 'wrong' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('pdfDecrypt fails honestly without writing an output file', async () => {
|
||||
it('pdfDecrypt removes password protection and writes a clean PDF', async () => {
|
||||
const encryptedPath = path.join(tmpDir, 'encrypted.pdf');
|
||||
const encryptResult = await PDFOperations.pdfEncrypt({
|
||||
inputPath,
|
||||
outputPath: encryptedPath,
|
||||
userPassword: 'secret',
|
||||
permissions: { printing: true },
|
||||
});
|
||||
expect(encryptResult.success).toBe(true);
|
||||
|
||||
const outputPath = path.join(tmpDir, 'decrypted.pdf');
|
||||
const result = await PDFOperations.pdfDecrypt({
|
||||
inputPath,
|
||||
inputPath: encryptedPath,
|
||||
outputPath,
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const bytes = fs.readFileSync(outputPath);
|
||||
expect(bytes.includes('/Encrypt')).toBe(false);
|
||||
const opened = await PDFDocument.load(bytes);
|
||||
expect(opened.getPageCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('pdfDecrypt rejects a wrong password without writing output', async () => {
|
||||
const encryptedPath = path.join(tmpDir, 'encrypted.pdf');
|
||||
await PDFOperations.pdfEncrypt({
|
||||
inputPath,
|
||||
outputPath: encryptedPath,
|
||||
userPassword: 'secret',
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const outputPath = path.join(tmpDir, 'decrypted.pdf');
|
||||
const result = await PDFOperations.pdfDecrypt({
|
||||
inputPath: encryptedPath,
|
||||
outputPath,
|
||||
password: 'wrong',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
|
||||
expect(result.error).toMatch(/password/i);
|
||||
expect(fs.existsSync(outputPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('pdfSetPermissions fails honestly without writing an output file', async () => {
|
||||
it('pdfSetPermissions writes an encrypted PDF with an owner password', async () => {
|
||||
const outputPath = path.join(tmpDir, 'permissions.pdf');
|
||||
const result = await PDFOperations.pdfSetPermissions({
|
||||
inputPath,
|
||||
outputPath,
|
||||
ownerPassword: 'owner-secret',
|
||||
permissions: { printing: true },
|
||||
permissions: { printing: true, copying: false },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
|
||||
expect(fs.existsSync(outputPath)).toBe(false);
|
||||
expect(result.success).toBe(true);
|
||||
const bytes = fs.readFileSync(outputPath);
|
||||
expect(bytes.includes('/Encrypt')).toBe(true);
|
||||
// Owner password grants full access, so loading with it must succeed.
|
||||
const opened = await PDFDocument.load(bytes, { password: 'owner-secret' });
|
||||
expect(opened.getPageCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('fails honestly even before reading the input, so a missing input reports unavailability', async () => {
|
||||
const result = await PDFOperations.pdfEncrypt({
|
||||
inputPath: path.join(tmpDir, 'missing.pdf'),
|
||||
outputPath: path.join(tmpDir, 'never-written.pdf'),
|
||||
userPassword: 'secret',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'never-written.pdf'))).toBe(false);
|
||||
});
|
||||
|
||||
it('executeOperation routes the password ops to the honest failure', async () => {
|
||||
it('executeOperation routes encrypt to the real implementation', async () => {
|
||||
const outputPath = path.join(tmpDir, 'exec-encrypted.pdf');
|
||||
const result = await PDFOperations.executeOperation('encrypt', {
|
||||
inputPath,
|
||||
outputPath: path.join(tmpDir, 'exec-encrypted.pdf'),
|
||||
outputPath,
|
||||
userPassword: 'secret',
|
||||
permissions: { printing: true },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(outputPath).includes('/Encrypt')).toBe(true);
|
||||
});
|
||||
|
||||
it('the module-load probe does not affect other operations', async () => {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* XlsxExporter tests: verify the generated OOXML package structure by
|
||||
* reopening the buffer with JSZip and checking the key parts (workbook sheet
|
||||
* list, per-sheet cell XML, content types). Uses only string assertions —
|
||||
* no Excel engine needed.
|
||||
*/
|
||||
const JSZip = require('jszip');
|
||||
const {
|
||||
buildXlsx,
|
||||
buildSheetXml,
|
||||
columnLetter,
|
||||
looksNumeric,
|
||||
} = require('../../src/main/XlsxExporter');
|
||||
|
||||
describe('XlsxExporter', () => {
|
||||
describe('columnLetter', () => {
|
||||
it('maps 0-based indexes to Excel letters', () => {
|
||||
expect(columnLetter(0)).toBe('A');
|
||||
expect(columnLetter(25)).toBe('Z');
|
||||
expect(columnLetter(26)).toBe('AA');
|
||||
expect(columnLetter(27)).toBe('AB');
|
||||
expect(columnLetter(52)).toBe('BA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksNumeric', () => {
|
||||
it('accepts integers and decimals, rejects text and leading zeros', () => {
|
||||
expect(looksNumeric('42')).toBe(true);
|
||||
expect(looksNumeric('-3.14')).toBe(true);
|
||||
expect(looksNumeric('007')).toBe(false); // keeps identifier-looking values as text
|
||||
expect(looksNumeric('1,000')).toBe(false);
|
||||
expect(looksNumeric('abc')).toBe(false);
|
||||
expect(looksNumeric('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSheetXml', () => {
|
||||
it('emits inline strings and numeric cells with row/column refs', () => {
|
||||
const xml = buildSheetXml([
|
||||
['Name', 'Score'],
|
||||
['Ada', '99'],
|
||||
]);
|
||||
expect(xml).toContain(
|
||||
'<c r="A1" t="inlineStr"><is><t xml:space="preserve">Name</t></is></c>'
|
||||
);
|
||||
expect(xml).toContain('<c r="B2" t="n"><v>99</v></c>');
|
||||
expect(xml).toContain('<row r="1">');
|
||||
});
|
||||
|
||||
it('escapes XML-significant characters in cells', () => {
|
||||
const xml = buildSheetXml([['<b>&"quotes"</b>']]);
|
||||
expect(xml).toContain('<b>&"quotes"</b>');
|
||||
expect(xml).not.toContain('<b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildXlsx', () => {
|
||||
it('creates a workbook with one sheet per table and all required parts', async () => {
|
||||
const buffer = await buildXlsx([
|
||||
[
|
||||
['h1', 'h2'],
|
||||
['a', '1'],
|
||||
],
|
||||
[['only', 'column']],
|
||||
]);
|
||||
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const parts = Object.keys(zip.files).sort();
|
||||
expect(parts).toContain('[Content_Types].xml');
|
||||
expect(parts).toContain('_rels/.rels');
|
||||
expect(parts).toContain('xl/workbook.xml');
|
||||
expect(parts).toContain('xl/_rels/workbook.xml.rels');
|
||||
expect(parts).toContain('xl/styles.xml');
|
||||
expect(parts).toContain('xl/worksheets/sheet1.xml');
|
||||
expect(parts).toContain('xl/worksheets/sheet2.xml');
|
||||
|
||||
const workbook = await zip.file('xl/workbook.xml').async('string');
|
||||
expect(workbook).toContain('name="Table 1"');
|
||||
expect(workbook).toContain('name="Table 2"');
|
||||
expect(workbook).toContain('r:id="rId2"');
|
||||
|
||||
const rels = await zip.file('xl/_rels/workbook.xml.rels').async('string');
|
||||
expect(rels).toContain('Target="worksheets/sheet1.xml"');
|
||||
expect(rels).toContain('Target="styles.xml"');
|
||||
|
||||
const sheet1 = await zip.file('xl/worksheets/sheet1.xml').async('string');
|
||||
expect(sheet1).toContain('inlineStr');
|
||||
});
|
||||
|
||||
it('rejects empty table lists', async () => {
|
||||
await expect(buildXlsx([])).rejects.toThrow(/No tables/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* SessionStore (crash recovery) unit tests. Uses an in-memory Storage stub so
|
||||
* the pure capture/save/load/clear logic can be tested without jsdom
|
||||
* localStorage behavior, plus a quota-failing stub for the error paths.
|
||||
*/
|
||||
const {
|
||||
captureSession,
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
STORAGE_KEY,
|
||||
} = require('../src/utils/session-store');
|
||||
|
||||
/** Minimal synchronous Storage stub. */
|
||||
function makeStorage({ quotaFails = false } = {}) {
|
||||
const map = new Map();
|
||||
return {
|
||||
setItem(k, v) {
|
||||
if (quotaFails) throw new Error('QuotaExceededError');
|
||||
map.set(k, v);
|
||||
},
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
removeItem(k) {
|
||||
map.delete(k);
|
||||
},
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: build a tab map like TabManager's
|
||||
function tabMapOf(...tabs) {
|
||||
const map = new Map();
|
||||
tabs.forEach((t) => map.set(t.id, t));
|
||||
return map;
|
||||
}
|
||||
|
||||
describe('SessionStore', () => {
|
||||
describe('captureSession', () => {
|
||||
it('captures full content for dirty tabs and paths only for clean file tabs', () => {
|
||||
const session = captureSession(
|
||||
tabMapOf(
|
||||
{ id: 1, title: 'notes.md', filePath: '/tmp/notes.md', isDirty: true, type: 'markdown' },
|
||||
{
|
||||
id: 2,
|
||||
title: 'readme.md',
|
||||
filePath: '/tmp/readme.md',
|
||||
isDirty: false,
|
||||
type: 'markdown',
|
||||
},
|
||||
{ id: 3, title: 'Untitled', filePath: null, isDirty: true, type: 'markdown' }
|
||||
),
|
||||
1,
|
||||
(id) => `content-of-${id}`
|
||||
);
|
||||
|
||||
expect(session.version).toBe(1);
|
||||
expect(session.activeTabId).toBe(1);
|
||||
expect(session.tabs).toHaveLength(3);
|
||||
|
||||
const dirty = session.tabs.find((t) => t.id === 1);
|
||||
expect(dirty.content).toBe('content-of-1');
|
||||
expect(dirty.isDirty).toBe(true);
|
||||
|
||||
const clean = session.tabs.find((t) => t.id === 2);
|
||||
expect(clean.content).toBeUndefined();
|
||||
expect(clean.filePath).toBe('/tmp/readme.md');
|
||||
|
||||
const untitled = session.tabs.find((t) => t.id === 3);
|
||||
expect(untitled.content).toBe('content-of-3');
|
||||
});
|
||||
|
||||
it('drops empty untitled tabs', () => {
|
||||
const session = captureSession(
|
||||
tabMapOf({ id: 1, title: 'Untitled', filePath: null, isDirty: false, type: 'markdown' }),
|
||||
1,
|
||||
() => ''
|
||||
);
|
||||
expect(session.tabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps PDF tabs as path-only entries', () => {
|
||||
const session = captureSession(
|
||||
tabMapOf({
|
||||
id: 1,
|
||||
title: 'doc.pdf',
|
||||
filePath: '/tmp/doc.pdf',
|
||||
isDirty: false,
|
||||
type: 'pdf',
|
||||
}),
|
||||
1,
|
||||
() => ''
|
||||
);
|
||||
expect(session.tabs).toHaveLength(1);
|
||||
expect(session.tabs[0].type).toBe('pdf');
|
||||
expect(session.tabs[0].filePath).toBe('/tmp/doc.pdf');
|
||||
expect('content' in session.tabs[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('stops capturing content once the size budget is exhausted', () => {
|
||||
const big = 'x'.repeat(1024 * 1024); // 1MB each
|
||||
const session = captureSession(
|
||||
tabMapOf(
|
||||
{ id: 1, title: 'a', filePath: null, isDirty: true, type: 'markdown' },
|
||||
{ id: 2, title: 'b', filePath: null, isDirty: true, type: 'markdown' },
|
||||
{ id: 3, title: 'c', filePath: null, isDirty: true, type: 'markdown' }
|
||||
),
|
||||
3,
|
||||
() => big
|
||||
);
|
||||
// 2MB budget holds exactly two 1MB buffers
|
||||
const captured = session.tabs.filter((t) => typeof t.content === 'string');
|
||||
expect(captured.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSession / loadSession', () => {
|
||||
it('round-trips a snapshot', () => {
|
||||
const storage = makeStorage();
|
||||
const session = captureSession(
|
||||
tabMapOf({ id: 1, title: 'x', filePath: null, isDirty: true, type: 'markdown' }),
|
||||
1,
|
||||
() => 'hello'
|
||||
);
|
||||
saveSession(storage, session);
|
||||
expect(loadSession(storage)).toEqual(session);
|
||||
});
|
||||
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(loadSession(makeStorage())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for corrupt JSON', () => {
|
||||
const storage = makeStorage();
|
||||
storage.setItem(STORAGE_KEY, '{not json');
|
||||
expect(loadSession(storage)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown versions or empty tab lists', () => {
|
||||
const storage = makeStorage();
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 99, tabs: [{}] }));
|
||||
expect(loadSession(storage)).toBeNull();
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, tabs: [] }));
|
||||
expect(loadSession(storage)).toBeNull();
|
||||
});
|
||||
|
||||
it('swallows quota errors and clears any stale snapshot', () => {
|
||||
const storage = makeStorage({ quotaFails: true });
|
||||
expect(() => saveSession(storage, { version: 1, tabs: [] })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSession', () => {
|
||||
it('removes the stored snapshot', () => {
|
||||
const storage = makeStorage();
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, tabs: [{}] }));
|
||||
clearSession(storage);
|
||||
expect(storage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* WikiLinks + Backlinks unit tests (pure string/graph logic, no IO).
|
||||
*/
|
||||
const path = require('path');
|
||||
const { renderWikiLinksInHtml, resolveTargetPath, docNameFor } = require('../src/utils/wiki-links');
|
||||
const {
|
||||
collectMarkdownFiles,
|
||||
extractOutgoingLinks,
|
||||
findBacklinks,
|
||||
} = require('../src/utils/backlinks');
|
||||
|
||||
describe('wiki-links', () => {
|
||||
describe('renderWikiLinksInHtml', () => {
|
||||
it('converts [[target]] into a wiki-link anchor', () => {
|
||||
const html = renderWikiLinksInHtml('<p>see [[My Note]] now</p>');
|
||||
expect(html).toContain('data-wiki-target="My Note"');
|
||||
expect(html).toContain('>My Note</a>');
|
||||
expect(html).not.toContain('[[');
|
||||
});
|
||||
|
||||
it('uses the alias as label when [[target|alias]]', () => {
|
||||
const html = renderWikiLinksInHtml('<p>[[My Note|the note]]</p>');
|
||||
expect(html).toContain('data-wiki-target="My Note"');
|
||||
expect(html).toContain('>the note</a>');
|
||||
});
|
||||
|
||||
it('ignores the heading fragment in [[target#section]]', () => {
|
||||
const html = renderWikiLinksInHtml('<p>[[Note#Details]]</p>');
|
||||
expect(html).toContain('data-wiki-target="Note"');
|
||||
});
|
||||
|
||||
it('leaves code blocks and inline code untouched', () => {
|
||||
const html = renderWikiLinksInHtml(
|
||||
'<p>real [[Link]]</p><pre><code>fake [[Code]]</code></pre><p><code>[[Inline]]</code></p>'
|
||||
);
|
||||
expect(html).toContain('data-wiki-target="Link"');
|
||||
expect(html).not.toContain('data-wiki-target="Code"');
|
||||
expect(html).not.toContain('data-wiki-target="Inline"');
|
||||
expect(html).toContain('[[Code]]');
|
||||
});
|
||||
|
||||
it('escapes HTML in targets and labels', () => {
|
||||
const html = renderWikiLinksInHtml('<p>[[A<b>&Note|lbl"&x]]</p>');
|
||||
expect(html).not.toContain('<b>&Note');
|
||||
expect(html).toContain('data-wiki-target=');
|
||||
});
|
||||
|
||||
it('passes through HTML without [[ unchanged (fast path)', () => {
|
||||
expect(renderWikiLinksInHtml('<p>plain</p>')).toBe('<p>plain</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTargetPath', () => {
|
||||
it('appends .md in the current directory', () => {
|
||||
expect(resolveTargetPath('Note', '/docs', path)).toBe(path.join('/docs', 'Note.md'));
|
||||
});
|
||||
|
||||
it('keeps an existing markdown extension', () => {
|
||||
expect(resolveTargetPath('Note.md', '/docs', path)).toBe(path.join('/docs', 'Note.md'));
|
||||
expect(resolveTargetPath('Note.markdown', '/docs', path)).toBe(
|
||||
path.join('/docs', 'Note.markdown')
|
||||
);
|
||||
});
|
||||
|
||||
it('supports relative subpaths', () => {
|
||||
expect(resolveTargetPath('sub/Note', '/docs', path)).toBe(
|
||||
path.join('/docs', 'sub', 'Note.md')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects absolute and traversing targets', () => {
|
||||
expect(resolveTargetPath('/etc/passwd', '/docs', path)).toBeNull();
|
||||
expect(resolveTargetPath('C:/win', '/docs', path)).toBeNull();
|
||||
expect(resolveTargetPath('../outside', '/docs', path)).toBeNull();
|
||||
expect(resolveTargetPath('', '/docs', path)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('docNameFor', () => {
|
||||
it('strips the markdown extension', () => {
|
||||
expect(docNameFor('/docs/My Note.md', path)).toBe('My Note');
|
||||
expect(docNameFor('/docs/My Note.markdown', path)).toBe('My Note');
|
||||
expect(docNameFor(null, path)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlinks', () => {
|
||||
describe('extractOutgoingLinks', () => {
|
||||
it('collects unique targets including aliased/sectioned links', () => {
|
||||
const links = extractOutgoingLinks('[[A]] and [[B|x]] and [[A#sec]] and [[C]]');
|
||||
expect(links.sort()).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectMarkdownFiles', () => {
|
||||
it('walks folders breadth-first, skipping hidden and vendor dirs, capped', async () => {
|
||||
const tree = {
|
||||
'/vault': [
|
||||
{ name: 'a.md', isDirectory: false, path: '/vault/a.md' },
|
||||
{ name: '.git', isDirectory: true, path: '/vault/.git' },
|
||||
{ name: 'node_modules', isDirectory: true, path: '/vault/node_modules' },
|
||||
{ name: 'sub', isDirectory: true, path: '/vault/sub' },
|
||||
],
|
||||
'/vault/sub': [{ name: 'b.markdown', isDirectory: false, path: '/vault/sub/b.markdown' }],
|
||||
// .git/node_modules never requested
|
||||
};
|
||||
const listDir = async (dir) => ({ entries: tree[dir] || [] });
|
||||
const files = await collectMarkdownFiles('/vault', listDir);
|
||||
expect(files.sort()).toEqual(['/vault/a.md', '/vault/sub/b.markdown']);
|
||||
});
|
||||
|
||||
it('respects the file cap', async () => {
|
||||
const entries = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `f${i}.md`,
|
||||
isDirectory: false,
|
||||
path: `/v/f${i}.md`,
|
||||
}));
|
||||
const files = await collectMarkdownFiles('/v', async () => ({ entries }), { maxFiles: 5 });
|
||||
expect(files).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBacklinks', () => {
|
||||
it('finds linking documents with line numbers and context', async () => {
|
||||
const files = ['/v/a.md', '/v/b.md', '/v/current.md'];
|
||||
const contents = {
|
||||
'/v/a.md': 'intro\nsee [[Current Note]] here',
|
||||
'/v/b.md': 'no links here',
|
||||
};
|
||||
const readFile = async (p) => contents[p] ?? null;
|
||||
const links = await findBacklinks({
|
||||
docName: 'Current Note',
|
||||
docPath: '/v/current.md',
|
||||
files,
|
||||
readFile,
|
||||
});
|
||||
expect(links).toEqual([
|
||||
expect.objectContaining({ path: '/v/a.md', line: 2, context: 'see [[Current Note]] here' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches .md-suffixed and case-insensitive references, misses self', async () => {
|
||||
const contents = {
|
||||
'/v/a.md': '[[current note.md]]',
|
||||
'/v/current.md': 'self ref [[Current Note]]',
|
||||
};
|
||||
const links = await findBacklinks({
|
||||
docName: 'Current Note',
|
||||
docPath: '/v/current.md',
|
||||
files: ['/v/a.md', '/v/current.md'],
|
||||
readFile: async (p) => contents[p] ?? null,
|
||||
});
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].path).toBe('/v/a.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user