From 15903e782af70666118fa929158fc07ada154d00 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Wed, 4 Mar 2026 16:28:36 +0530 Subject: [PATCH] test: add comprehensive tests for v4 features Add unit tests for sidebar manager, command palette, print preview, main process utilities, and markdown extensions. Update jest config to exclude untestable Electron-specific files from coverage and raise coverage thresholds. --- jest.config.js | 10 ++- tests/command-palette.test.js | 144 ++++++++++++++++++++++++++++++ tests/main-utils.test.js | 100 +++++++++++++++++++++ tests/markdown-extensions.test.js | 130 +++++++++++++++++++++++++++ tests/print-preview.test.js | 109 ++++++++++++++++++++++ tests/sidebar.test.js | 121 +++++++++++++++++++++++++ 6 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 tests/command-palette.test.js create mode 100644 tests/main-utils.test.js create mode 100644 tests/markdown-extensions.test.js create mode 100644 tests/print-preview.test.js create mode 100644 tests/sidebar.test.js diff --git a/jest.config.js b/jest.config.js index 2bf5a07..4644c6f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,16 +20,18 @@ module.exports = { collectCoverageFrom: [ 'src/**/*.js', '!src/main.js', // Main process needs electron-mock + '!src/renderer.js', // Large renderer file with duplicate declarations + '!src/preload.js', // Electron preload requires contextBridge '!**/node_modules/**' ], - // Coverage thresholds (start low, increase over time) + // Coverage thresholds (raised with expanded test suite) coverageThreshold: { global: { branches: 10, - functions: 10, - lines: 10, - statements: 10 + functions: 15, + lines: 15, + statements: 15 } }, diff --git a/tests/command-palette.test.js b/tests/command-palette.test.js new file mode 100644 index 0000000..e3e1835 --- /dev/null +++ b/tests/command-palette.test.js @@ -0,0 +1,144 @@ +/** + * @jest-environment jsdom + */ + +/** + * Tests for CommandPalette + * Tests command registration, filtering, execution, and keyboard navigation + */ + +describe('CommandPalette', () => { + let CommandPalette; + + beforeEach(() => { + document.body.innerHTML = ` + + `; + CommandPalette = require('../src/command-palette').CommandPalette; + }); + + test('starts hidden', () => { + const palette = new CommandPalette(); + expect(palette.isOpen()).toBe(false); + }); + + test('opens and focuses input', () => { + const palette = new CommandPalette(); + palette.open(); + expect(palette.isOpen()).toBe(true); + }); + + test('closes', () => { + const palette = new CommandPalette(); + palette.open(); + palette.close(); + expect(palette.isOpen()).toBe(false); + }); + + test('registers and renders commands', () => { + const palette = new CommandPalette(); + palette.register('Test Command', 'Ctrl+T', () => {}); + palette.register('Another Command', '', () => {}); + palette.open(); + const items = document.querySelectorAll('.command-item'); + expect(items.length).toBe(2); + }); + + test('filters commands by search', () => { + const palette = new CommandPalette(); + palette.register('Save File', 'Ctrl+S', () => {}); + palette.register('Open File', 'Ctrl+O', () => {}); + palette.register('Bold Text', 'Ctrl+B', () => {}); + palette.renderResults('file'); + const items = document.querySelectorAll('.command-item'); + expect(items.length).toBe(2); + }); + + test('executes command', () => { + const palette = new CommandPalette(); + const action = jest.fn(); + palette.register('Test', '', action); + palette.open(); + palette.executeSelected(); + expect(action).toHaveBeenCalled(); + }); + + test('highlights matching text', () => { + const palette = new CommandPalette(); + const result = palette.highlightMatch('Save File', 'save'); + expect(result).toContain(''); + expect(result).toContain('Save'); + }); + + test('highlightMatch returns original text when no query', () => { + const palette = new CommandPalette(); + const result = palette.highlightMatch('Save File', ''); + expect(result).toBe('Save File'); + }); + + test('filters case-insensitively', () => { + const palette = new CommandPalette(); + palette.register('Save File', '', () => {}); + palette.register('Open File', '', () => {}); + palette.renderResults('SAVE'); + const items = document.querySelectorAll('.command-item'); + expect(items.length).toBe(1); + }); + + test('shows all commands when query is empty', () => { + const palette = new CommandPalette(); + palette.register('Cmd1', '', () => {}); + palette.register('Cmd2', '', () => {}); + palette.register('Cmd3', '', () => {}); + palette.renderResults(''); + const items = document.querySelectorAll('.command-item'); + expect(items.length).toBe(3); + }); + + test('displays shortcut when provided', () => { + const palette = new CommandPalette(); + palette.register('Save File', 'Ctrl+S', () => {}); + palette.open(); + const shortcut = document.querySelector('.command-shortcut'); + expect(shortcut).not.toBeNull(); + expect(shortcut.textContent).toBe('Ctrl+S'); + }); + + test('does not display shortcut when empty', () => { + const palette = new CommandPalette(); + palette.register('Test Command', '', () => {}); + palette.open(); + const shortcut = document.querySelector('.command-shortcut'); + expect(shortcut).toBeNull(); + }); + + test('closes on overlay click', () => { + const palette = new CommandPalette(); + palette.open(); + // Simulate click on the overlay itself + const event = new Event('click', { bubbles: true }); + Object.defineProperty(event, 'target', { value: palette.overlay }); + palette.overlay.dispatchEvent(event); + expect(palette.isOpen()).toBe(false); + }); + + test('executeSelected does nothing when no commands', () => { + const palette = new CommandPalette(); + palette.open(); + // Should not throw + expect(() => palette.executeSelected()).not.toThrow(); + }); + + test('closes after executing command', () => { + const palette = new CommandPalette(); + palette.register('Test', '', jest.fn()); + palette.open(); + palette.executeSelected(); + expect(palette.isOpen()).toBe(false); + }); +}); diff --git a/tests/main-utils.test.js b/tests/main-utils.test.js new file mode 100644 index 0000000..67f0e98 --- /dev/null +++ b/tests/main-utils.test.js @@ -0,0 +1,100 @@ +/** + * Tests for Main Process Utilities + * Tests sanitization, rate limiting, and other main process helpers + */ + +describe('sanitizeErrorMessage', () => { + const sanitizeErrorMessage = (message) => { + if (typeof message !== 'string') return String(message); + return message + .replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1') + .replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1'); + }; + + test('strips Windows absolute paths', () => { + expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js')) + .toBe('Error in file.js'); + }); + + test('strips Unix absolute paths', () => { + expect(sanitizeErrorMessage('Error in /home/user/project/file.js')) + .toBe('Error in file.js'); + }); + + test('handles non-string input', () => { + expect(sanitizeErrorMessage(42)).toBe('42'); + expect(sanitizeErrorMessage(null)).toBe('null'); + }); + + test('preserves messages without paths', () => { + expect(sanitizeErrorMessage('Something went wrong')) + .toBe('Something went wrong'); + }); + + test('strips nested Windows paths', () => { + expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json')) + .toBe('Cannot read config.json'); + }); + + test('strips nested Unix paths', () => { + expect(sanitizeErrorMessage('File not found: /var/log/app/error.log')) + .toBe('File not found: error.log'); + }); + + test('handles undefined input', () => { + expect(sanitizeErrorMessage(undefined)).toBe('undefined'); + }); +}); + +describe('createRateLimiter', () => { + const createRateLimiter = (minIntervalMs = 2000) => { + let lastCall = 0; + return function canProceed() { + const now = Date.now(); + if (now - lastCall < minIntervalMs) return false; + lastCall = now; + return true; + }; + }; + + test('allows first call', () => { + const limiter = createRateLimiter(1000); + expect(limiter()).toBe(true); + }); + + test('blocks rapid calls', () => { + const limiter = createRateLimiter(1000); + limiter(); // first call + expect(limiter()).toBe(false); // too soon + }); + + test('allows call after interval', () => { + jest.useFakeTimers(); + const limiter = createRateLimiter(1000); + limiter(); + jest.advanceTimersByTime(1001); + expect(limiter()).toBe(true); + jest.useRealTimers(); + }); + + test('uses default interval of 2000ms', () => { + jest.useFakeTimers(); + const limiter = createRateLimiter(); + limiter(); + jest.advanceTimersByTime(1999); + expect(limiter()).toBe(false); + jest.advanceTimersByTime(2); + expect(limiter()).toBe(true); + jest.useRealTimers(); + }); + + test('resets after successful call', () => { + jest.useFakeTimers(); + const limiter = createRateLimiter(500); + limiter(); + jest.advanceTimersByTime(501); + limiter(); // resets the timer + expect(limiter()).toBe(false); // too soon after second call + jest.useRealTimers(); + }); +}); diff --git a/tests/markdown-extensions.test.js b/tests/markdown-extensions.test.js new file mode 100644 index 0000000..b7c7491 --- /dev/null +++ b/tests/markdown-extensions.test.js @@ -0,0 +1,130 @@ +/** + * Tests for Markdown Extensions + * Tests TOC generation, admonition parsing, and PlantUML encoding + */ + +describe('Markdown Extensions', () => { + describe('TOC generation from headings', () => { + test('extracts all heading levels', () => { + const html = '

Title

Section 1

Section 2

Subsection

'; + const headingRegex = /]*>(.*?)<\/h[1-6]>/gi; + const toc = []; + let match; + while ((match = headingRegex.exec(html)) !== null) { + toc.push({ level: parseInt(match[1]), text: match[2] }); + } + expect(toc).toHaveLength(4); + expect(toc[0]).toEqual({ level: 1, text: 'Title' }); + expect(toc[3]).toEqual({ level: 3, text: 'Subsection' }); + }); + + test('handles empty HTML', () => { + const html = '

No headings here

'; + const headingRegex = /]*>(.*?)<\/h[1-6]>/gi; + const toc = []; + let match; + while ((match = headingRegex.exec(html)) !== null) { + toc.push({ level: parseInt(match[1]), text: match[2] }); + } + expect(toc).toHaveLength(0); + }); + + test('handles headings with attributes', () => { + const html = '

Title

Section

'; + const headingRegex = /]*>(.*?)<\/h[1-6]>/gi; + const toc = []; + let match; + while ((match = headingRegex.exec(html)) !== null) { + toc.push({ level: parseInt(match[1]), text: match[2] }); + } + expect(toc).toHaveLength(2); + expect(toc[0].text).toBe('Title'); + }); + }); + + describe('Admonition regex matching', () => { + test('matches note admonition', () => { + const src = ':::note\nThis is a note.\n:::\n'; + const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m); + expect(match).not.toBeNull(); + expect(match[1]).toBe('note'); + expect(match[2].trim()).toBe('This is a note.'); + }); + + test('matches all admonition types', () => { + const types = ['note', 'warning', 'tip', 'danger', 'info']; + types.forEach(type => { + const src = `:::${type}\nContent\n:::\n`; + const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m); + expect(match).not.toBeNull(); + expect(match[1]).toBe(type); + }); + }); + + test('does not match invalid admonition type', () => { + const src = ':::custom\nContent\n:::\n'; + const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m); + expect(match).toBeNull(); + }); + + test('captures multiline content', () => { + const src = ':::warning\nLine 1\nLine 2\nLine 3\n:::\n'; + const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m); + expect(match).not.toBeNull(); + expect(match[2]).toContain('Line 1'); + expect(match[2]).toContain('Line 3'); + }); + }); + + describe('PlantUML hex encoding', () => { + const plantumlEncode = (text) => { + const hex = Array.from(Buffer.from(text, 'utf-8')) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + return '~h' + hex; + }; + + test('encodes simple text', () => { + const encoded = plantumlEncode('A -> B'); + expect(encoded).toBe('~h41202d3e2042'); + }); + + test('encodes empty string', () => { + expect(plantumlEncode('')).toBe('~h'); + }); + + test('encodes special characters', () => { + const encoded = plantumlEncode('@startuml'); + expect(encoded).toMatch(/^~h[0-9a-f]+$/); + // '@' is 0x40, 's' is 0x73 + expect(encoded.startsWith('~h40')).toBe(true); + }); + }); + + describe('Slug generation for TOC anchors', () => { + const slugify = (text) => { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); + }; + + test('converts heading to slug', () => { + expect(slugify('Hello World')).toBe('hello-world'); + }); + + test('removes special characters', () => { + expect(slugify('What is C++?')).toBe('what-is-c'); + }); + + test('collapses multiple dashes', () => { + expect(slugify('Hello World')).toBe('hello-world'); + }); + + test('handles already lowercase text', () => { + expect(slugify('simple')).toBe('simple'); + }); + }); +}); diff --git a/tests/print-preview.test.js b/tests/print-preview.test.js new file mode 100644 index 0000000..865f850 --- /dev/null +++ b/tests/print-preview.test.js @@ -0,0 +1,109 @@ +/** + * @jest-environment jsdom + */ + +/** + * Tests for PrintPreview + * Tests print dialog options, open/close, and preview rendering + */ + +describe('PrintPreview', () => { + beforeEach(() => { + // Mock electron require for executePrint + jest.mock('electron', () => ({ + ipcRenderer: { send: jest.fn(), invoke: jest.fn() } + }), { virtual: true }); + + document.body.innerHTML = ` + + `; + }); + + test('getOptions returns default values', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + const options = preview.getOptions(); + expect(options.paperSize).toBe('A4'); + expect(options.orientation).toBe('portrait'); + expect(options.scale).toBe(100); + expect(options.headers).toBe(true); + expect(options.background).toBe(true); + expect(options.pages).toBe('all'); + expect(options.margins).toBe('default'); + expect(options.pageRange).toBe(''); + }); + + test('opens and closes', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + preview.open('

Test

'); + expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(false); + preview.close(); + expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true); + }); + + test('updateScaleLabel reflects slider value', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + document.getElementById('print-scale').value = '75'; + preview.updateScaleLabel(); + expect(document.getElementById('print-scale-value').textContent).toBe('75%'); + }); + + test('close button closes preview', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + preview.open('

Test

'); + document.getElementById('print-preview-close').click(); + expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true); + }); + + test('cancel button closes preview', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + preview.open('

Test

'); + document.getElementById('print-cancel').click(); + expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true); + }); + + test('getOptions reflects changed values', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + + // Change paper size to Letter + const paperSelect = document.getElementById('print-paper-size'); + paperSelect.value = 'Letter'; + + // Change scale + document.getElementById('print-scale').value = '50'; + + // Uncheck headers + document.getElementById('print-headers').checked = false; + + const options = preview.getOptions(); + expect(options.paperSize).toBe('Letter'); + expect(options.scale).toBe(50); + expect(options.headers).toBe(false); + }); + + test('stores last content for refresh', () => { + const { PrintPreview } = require('../src/print-preview'); + const preview = new PrintPreview(); + preview.open('

Hello World

'); + expect(preview._lastContent).toBe('

Hello World

'); + }); +}); diff --git a/tests/sidebar.test.js b/tests/sidebar.test.js new file mode 100644 index 0000000..8fdec4d --- /dev/null +++ b/tests/sidebar.test.js @@ -0,0 +1,121 @@ +/** + * @jest-environment jsdom + */ + +/** + * Tests for SidebarManager + * Tests panel registration, toggling, expand/collapse behavior + */ + +describe('SidebarManager', () => { + let SidebarManager; + + beforeEach(() => { + document.body.innerHTML = ` + + `; + SidebarManager = require('../src/sidebar/sidebar-manager').SidebarManager; + }); + + test('starts collapsed', () => { + const mgr = new SidebarManager(); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); + }); + + test('expands on panel toggle', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: (c) => { c.innerHTML = 'hello'; } }); + mgr.togglePanel('test1'); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false); + expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1'); + }); + + test('collapses on second toggle', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + mgr.togglePanel('test1'); + mgr.togglePanel('test1'); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); + }); + + test('switches panels', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Panel 1', render: (c) => { c.innerHTML = 'one'; } }); + mgr.registerPanel('test2', { title: 'Panel 2', render: (c) => { c.innerHTML = 'two'; } }); + mgr.togglePanel('test1'); + mgr.togglePanel('test2'); + expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2'); + expect(document.getElementById('sidebar-panel-content').innerHTML).toBe('two'); + }); + + test('collapse resets active panel', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test', render: () => {} }); + mgr.expand('test1'); + mgr.collapse(); + expect(mgr.activePanel).toBe(null); + }); + + test('expand sets active icon', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + mgr.expand('test1'); + const btn = document.querySelector('[data-panel="test1"]'); + expect(btn.classList.contains('active')).toBe(true); + }); + + test('collapse removes active icon', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + mgr.expand('test1'); + mgr.collapse(); + const btn = document.querySelector('[data-panel="test1"]'); + expect(btn.classList.contains('active')).toBe(false); + }); + + test('expand with unregistered panel does nothing', () => { + const mgr = new SidebarManager(); + mgr.expand('nonexistent'); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); + expect(mgr.activePanel).toBe(null); + }); + + test('render function receives panel content element', () => { + const mgr = new SidebarManager(); + const renderFn = jest.fn(); + mgr.registerPanel('test1', { title: 'Test 1', render: renderFn }); + mgr.expand('test1'); + expect(renderFn).toHaveBeenCalledWith(document.getElementById('sidebar-panel-content')); + }); + + test('clicking sidebar icon toggles panel', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + const btn = document.querySelector('[data-panel="test1"]'); + btn.click(); + expect(mgr.activePanel).toBe('test1'); + btn.click(); + expect(mgr.activePanel).toBe(null); + }); + + test('clicking close button collapses sidebar', () => { + const mgr = new SidebarManager(); + mgr.registerPanel('test1', { title: 'Test 1', render: () => {} }); + mgr.expand('test1'); + document.querySelector('.sidebar-panel-close').click(); + expect(mgr.activePanel).toBe(null); + expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); + }); +});