mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
fix(batch): resolve pandoc path handling and include-subfolders option
- Normalize pandoc command parsing with path.basename() to support bundled binary paths - Use bundled pandoc binary in convertWithPandoc instead of relying on PATH - Forward includeSubfolders checkbox state from renderer to main process - Add pandoc availability check before batch conversion - Re-enable Start button when batch conversion completes - Clean up obsolete dist build artifact causing test snapshot warning - Bump version to 4.4.4
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Tests for bundled external tools
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('Bundled Pandoc binary', () => {
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const platform = process.platform;
|
||||
const binaryPath = path.join(rootDir, 'bin', platform, 'pandoc');
|
||||
const windowsBinaryPath = path.join(rootDir, 'bin', platform, 'pandoc.exe');
|
||||
|
||||
test('Pandoc binary exists for current platform', () => {
|
||||
const exists = fs.existsSync(binaryPath) || fs.existsSync(windowsBinaryPath);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
test('Pandoc binary is executable', () => {
|
||||
if (platform === 'win32') {
|
||||
expect(fs.existsSync(windowsBinaryPath)).toBe(true);
|
||||
} else {
|
||||
expect(fs.existsSync(binaryPath)).toBe(true);
|
||||
const stats = fs.statSync(binaryPath);
|
||||
expect(stats.mode & 0o111).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
+110
-110
@@ -8,10 +8,10 @@
|
||||
*/
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
let CommandPalette;
|
||||
let CommandPalette;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div class="command-palette-overlay hidden" id="command-palette-overlay">
|
||||
<div class="command-palette">
|
||||
<input type="text" id="command-palette-input">
|
||||
@@ -19,126 +19,126 @@ describe('CommandPalette', () => {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
CommandPalette = require('../src/command-palette').CommandPalette;
|
||||
});
|
||||
CommandPalette = require('../src/command-palette').CommandPalette;
|
||||
});
|
||||
|
||||
test('starts hidden', () => {
|
||||
const palette = new CommandPalette();
|
||||
expect(palette.isOpen()).toBe(false);
|
||||
});
|
||||
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('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('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('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('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('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('<strong>');
|
||||
expect(result).toContain('Save');
|
||||
});
|
||||
test('highlights matching text', () => {
|
||||
const palette = new CommandPalette();
|
||||
const result = palette.highlightMatch('Save File', 'save');
|
||||
expect(result).toContain('<strong>');
|
||||
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('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('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('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('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('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('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('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);
|
||||
});
|
||||
test('closes after executing command', () => {
|
||||
const palette = new CommandPalette();
|
||||
palette.register('Test', '', jest.fn());
|
||||
palette.open();
|
||||
palette.executeSelected();
|
||||
expect(palette.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,9 @@ describe('EventBus', () => {
|
||||
|
||||
test('handler errors are caught and logged, not thrown', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
bus.on('bad:event', () => { throw new Error('boom'); });
|
||||
bus.on('bad:event', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(() => bus.emit('bad:event', {})).not.toThrow();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
|
||||
@@ -8,14 +8,14 @@ describe('GitOperations Utilities', () => {
|
||||
describe('error handling patterns', () => {
|
||||
it('should handle git errors gracefully', () => {
|
||||
const errorResponse = { error: 'Not a git repository' };
|
||||
|
||||
|
||||
expect(errorResponse).toHaveProperty('error');
|
||||
expect(errorResponse.error).toContain('repository');
|
||||
});
|
||||
|
||||
it('should return error object on failure', () => {
|
||||
const failureResult = { error: 'Failed to commit' };
|
||||
|
||||
|
||||
expect(failureResult).toBeDefined();
|
||||
expect(failureResult.error).toBeTruthy();
|
||||
});
|
||||
@@ -56,24 +56,20 @@ describe('GitOperations Utilities', () => {
|
||||
'branch',
|
||||
'checkout',
|
||||
'push',
|
||||
'pull'
|
||||
'pull',
|
||||
];
|
||||
|
||||
expect(gitOps.length).toBeGreaterThan(0);
|
||||
gitOps.forEach(op => {
|
||||
gitOps.forEach((op) => {
|
||||
expect(typeof op).toBe('string');
|
||||
expect(op.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle directory paths', () => {
|
||||
const paths = [
|
||||
'/home/user/project',
|
||||
'./current/dir',
|
||||
'../parent/dir'
|
||||
];
|
||||
const paths = ['/home/user/project', './current/dir', '../parent/dir'];
|
||||
|
||||
paths.forEach(pathStr => {
|
||||
paths.forEach((pathStr) => {
|
||||
expect(typeof pathStr).toBe('string');
|
||||
expect(pathStr.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -83,10 +79,10 @@ describe('GitOperations Utilities', () => {
|
||||
const messages = [
|
||||
'fix: bug in git panel',
|
||||
'feat: add new feature',
|
||||
'refactor: clean up code'
|
||||
'refactor: clean up code',
|
||||
];
|
||||
|
||||
messages.forEach(msg => {
|
||||
messages.forEach((msg) => {
|
||||
expect(typeof msg).toBe('string');
|
||||
expect(msg.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -101,7 +97,7 @@ describe('GitOperations Utilities', () => {
|
||||
deleted: [],
|
||||
modified: [],
|
||||
renamed: [],
|
||||
staged: ['file.md']
|
||||
staged: ['file.md'],
|
||||
};
|
||||
|
||||
expect(statusResponse).toHaveProperty('staged');
|
||||
@@ -110,10 +106,8 @@ describe('GitOperations Utilities', () => {
|
||||
|
||||
it('should return log entries', () => {
|
||||
const logResponse = {
|
||||
all: [
|
||||
{ hash: 'abc123', message: 'fix: something' }
|
||||
],
|
||||
latest: { hash: 'abc123', message: 'fix: something' }
|
||||
all: [{ hash: 'abc123', message: 'fix: something' }],
|
||||
latest: { hash: 'abc123', message: 'fix: something' },
|
||||
};
|
||||
|
||||
expect(logResponse).toHaveProperty('all');
|
||||
|
||||
@@ -8,7 +8,9 @@ describe('GoalTracker', () => {
|
||||
store = {};
|
||||
tracker = new GoalTracker({
|
||||
get: (key) => store[key],
|
||||
set: (key, value) => { store[key] = value; }
|
||||
set: (key, value) => {
|
||||
store[key] = value;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Tests ensuring the codebase has no ESLint warnings.
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
describe('ESLint quality gate', () => {
|
||||
test('src and tests have no ESLint warnings', () => {
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const result = execSync('npm run lint', {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result).not.toMatch(/warning/);
|
||||
});
|
||||
});
|
||||
+76
-77
@@ -4,97 +4,96 @@
|
||||
*/
|
||||
|
||||
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');
|
||||
};
|
||||
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 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('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('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('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 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('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');
|
||||
});
|
||||
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;
|
||||
};
|
||||
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('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('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('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('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();
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+236
-222
@@ -4,245 +4,259 @@
|
||||
*/
|
||||
|
||||
describe('Markdown Extensions', () => {
|
||||
describe('TOC generation from headings', () => {
|
||||
test('extracts all heading levels', () => {
|
||||
const html = '<h1>Title</h1><h2>Section 1</h2><h2>Section 2</h2><h3>Subsection</h3>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/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 = '<p>No headings here</p>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/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 = '<h1 id="title" class="main">Title</h1><h2 id="sec">Section</h2>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/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('TOC generation from headings', () => {
|
||||
test('extracts all heading levels', () => {
|
||||
const html = '<h1>Title</h1><h2>Section 1</h2><h2>Section 2</h2><h3>Subsection</h3>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/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' });
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
test('handles empty HTML', () => {
|
||||
const html = '<p>No headings here</p>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/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);
|
||||
});
|
||||
|
||||
describe('Admonitions full parsing integration (mocked environment)', () => {
|
||||
const extension = {
|
||||
name: 'admonition',
|
||||
level: 'block',
|
||||
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
if (match && match.index === 0) {
|
||||
const admonitionType = match[1];
|
||||
const text = match[2].trim();
|
||||
const tokens = [];
|
||||
this.lexer.blockTokens(text, tokens);
|
||||
return {
|
||||
type: 'admonition',
|
||||
raw: match[0],
|
||||
admonitionType,
|
||||
text,
|
||||
tokens
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
const icons = { note: 'ℹ', warning: '⚠', tip: '💡', danger: '🔴', info: 'ℹ' };
|
||||
const icon = icons[token.admonitionType] || 'ℹ';
|
||||
const inner = this.parser.parse(token.tokens || []);
|
||||
return `<div class="admonition admonition-${token.admonitionType}">
|
||||
test('handles headings with attributes', () => {
|
||||
const html = '<h1 id="title" class="main">Title</h1><h2 id="sec">Section</h2>';
|
||||
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/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('Admonitions full parsing integration (mocked environment)', () => {
|
||||
const extension = {
|
||||
name: 'admonition',
|
||||
level: 'block',
|
||||
start(src) {
|
||||
return src.match(/^:::(note|warning|tip|danger|info)/m)?.index;
|
||||
},
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||
if (match && match.index === 0) {
|
||||
const admonitionType = match[1];
|
||||
const text = match[2].trim();
|
||||
const tokens = [];
|
||||
this.lexer.blockTokens(text, tokens);
|
||||
return {
|
||||
type: 'admonition',
|
||||
raw: match[0],
|
||||
admonitionType,
|
||||
text,
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
const icons = { note: 'ℹ', warning: '⚠', tip: '💡', danger: '🔴', info: 'ℹ' };
|
||||
const icon = icons[token.admonitionType] || 'ℹ';
|
||||
const inner = this.parser.parse(token.tokens || []);
|
||||
return `<div class="admonition admonition-${token.admonitionType}">
|
||||
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
|
||||
<div class="admonition-content">${inner}</div>
|
||||
</div>`;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
|
||||
const src = ':::note\nThis is a note.\n:::';
|
||||
const mockLexer = {
|
||||
blockTokens: jest.fn((text, tokens) => {
|
||||
tokens.push({ type: 'text', text });
|
||||
})
|
||||
};
|
||||
const context = { lexer: mockLexer };
|
||||
const result = extension.tokenizer.call(context, src);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.type).toBe('admonition');
|
||||
expect(result.admonitionType).toBe('note');
|
||||
expect(result.text).toBe('This is a note.');
|
||||
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
|
||||
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
|
||||
});
|
||||
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
|
||||
const src = ':::note\nThis is a note.\n:::';
|
||||
const mockLexer = {
|
||||
blockTokens: jest.fn((text, tokens) => {
|
||||
tokens.push({ type: 'text', text });
|
||||
}),
|
||||
};
|
||||
const context = { lexer: mockLexer };
|
||||
const result = extension.tokenizer.call(context, src);
|
||||
|
||||
test('renderer correctly translates tokens to HTML', () => {
|
||||
const token = {
|
||||
type: 'admonition',
|
||||
admonitionType: 'warning',
|
||||
tokens: [{ type: 'text', text: 'Be careful!' }]
|
||||
};
|
||||
const mockParser = {
|
||||
parse: jest.fn((tokens) => '<p>Be careful!</p>')
|
||||
};
|
||||
const context = { parser: mockParser };
|
||||
const html = extension.renderer.call(context, token);
|
||||
|
||||
expect(html).toContain('admonition admonition-warning');
|
||||
expect(html).toContain('⚠ Warning');
|
||||
expect(html).toContain('<p>Be careful!</p>');
|
||||
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result.type).toBe('admonition');
|
||||
expect(result.admonitionType).toBe('note');
|
||||
expect(result.text).toBe('This is a note.');
|
||||
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
|
||||
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
|
||||
});
|
||||
|
||||
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('renderer correctly translates tokens to HTML', () => {
|
||||
const token = {
|
||||
type: 'admonition',
|
||||
admonitionType: 'warning',
|
||||
tokens: [{ type: 'text', text: 'Be careful!' }],
|
||||
};
|
||||
const mockParser = {
|
||||
parse: jest.fn(() => '<p>Be careful!</p>'),
|
||||
};
|
||||
const context = { parser: mockParser };
|
||||
const html = extension.renderer.call(context, token);
|
||||
|
||||
test('encodes simple text', () => {
|
||||
const encoded = plantumlEncode('A -> B');
|
||||
expect(encoded).toBe('~h41202d3e2042');
|
||||
});
|
||||
expect(html).toContain('admonition admonition-warning');
|
||||
expect(html).toContain('⚠ Warning');
|
||||
expect(html).toContain('<p>Be careful!</p>');
|
||||
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
|
||||
});
|
||||
});
|
||||
|
||||
test('encodes empty string', () => {
|
||||
expect(plantumlEncode('')).toBe('~h');
|
||||
});
|
||||
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 special characters', () => {
|
||||
const encoded = plantumlEncode('@startuml');
|
||||
expect(encoded).toMatch(/^~h[0-9a-f]+$/);
|
||||
// '@' is 0x40, 's' is 0x73
|
||||
expect(encoded.startsWith('~h40')).toBe(true);
|
||||
});
|
||||
test('encodes simple text', () => {
|
||||
const encoded = plantumlEncode('A -> B');
|
||||
expect(encoded).toBe('~h41202d3e2042');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
test('encodes empty string', () => {
|
||||
expect(plantumlEncode('')).toBe('~h');
|
||||
});
|
||||
|
||||
describe('scopeCSS utility', () => {
|
||||
const scopeCSS = (cssText, scopeSelector) => {
|
||||
if (!cssText) return '';
|
||||
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
|
||||
return match;
|
||||
}
|
||||
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||
});
|
||||
};
|
||||
|
||||
test('scopes standard tag selector', () => {
|
||||
const css = 'h1 { color: red; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe('.preview-content h1{ color: red; }');
|
||||
});
|
||||
|
||||
test('scopes multiple class selectors', () => {
|
||||
const css = '.title, .content { font-family: sans-serif; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe('.preview-content .title,.preview-content .content{ font-family: sans-serif; }');
|
||||
});
|
||||
|
||||
test('ignores @rules like @media', () => {
|
||||
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toContain('@media (max-width: 600px)');
|
||||
});
|
||||
|
||||
test('ignores :root selector', () => {
|
||||
const css = ':root { --color: red; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe(':root { --color: red; }');
|
||||
});
|
||||
|
||||
test('ignores keyframe percentages', () => {
|
||||
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toContain('0%');
|
||||
expect(scoped).toContain('100%');
|
||||
});
|
||||
|
||||
test('handles empty input', () => {
|
||||
expect(scopeCSS('', '.preview-content')).toBe('');
|
||||
expect(scopeCSS(null, '.preview-content')).toBe('');
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCSS utility', () => {
|
||||
const scopeCSS = (cssText, scopeSelector) => {
|
||||
if (!cssText) return '';
|
||||
return cssText.replace(
|
||||
/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g,
|
||||
(match, selector, separator) => {
|
||||
const trimmed = selector.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('@') ||
|
||||
trimmed.startsWith(':root') ||
|
||||
trimmed.startsWith('from') ||
|
||||
trimmed.startsWith('to') ||
|
||||
/^\d+%$/.test(trimmed)
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
test('scopes standard tag selector', () => {
|
||||
const css = 'h1 { color: red; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe('.preview-content h1{ color: red; }');
|
||||
});
|
||||
|
||||
test('scopes multiple class selectors', () => {
|
||||
const css = '.title, .content { font-family: sans-serif; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe(
|
||||
'.preview-content .title,.preview-content .content{ font-family: sans-serif; }'
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores @rules like @media', () => {
|
||||
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toContain('@media (max-width: 600px)');
|
||||
});
|
||||
|
||||
test('ignores :root selector', () => {
|
||||
const css = ':root { --color: red; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toBe(':root { --color: red; }');
|
||||
});
|
||||
|
||||
test('ignores keyframe percentages', () => {
|
||||
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
|
||||
const scoped = scopeCSS(css, '.preview-content');
|
||||
expect(scoped).toContain('0%');
|
||||
expect(scoped).toContain('100%');
|
||||
});
|
||||
|
||||
test('handles empty input', () => {
|
||||
expect(scopeCSS('', '.preview-content')).toBe('');
|
||||
expect(scopeCSS(null, '.preview-content')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+234
-234
@@ -9,272 +9,272 @@
|
||||
const { ModalManager } = require('../src/utils/ModalManager');
|
||||
|
||||
function createModalElement(id = 'test-modal') {
|
||||
const modal = document.createElement('div');
|
||||
modal.id = id;
|
||||
modal.className = 'modal hidden';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const modal = document.createElement('div');
|
||||
modal.id = id;
|
||||
modal.className = 'modal hidden';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.setAttribute('data-close', '');
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.setAttribute('data-close', '');
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'modal-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'modal-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'modal-body';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'modal-body';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
|
||||
body.appendChild(input);
|
||||
header.appendChild(closeBtn);
|
||||
content.appendChild(header);
|
||||
content.appendChild(body);
|
||||
modal.appendChild(backdrop);
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
body.appendChild(input);
|
||||
header.appendChild(closeBtn);
|
||||
content.appendChild(header);
|
||||
content.appendChild(body);
|
||||
modal.appendChild(backdrop);
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
return modal;
|
||||
return modal;
|
||||
}
|
||||
|
||||
describe('ModalManager', () => {
|
||||
let modal;
|
||||
let manager;
|
||||
let modal;
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
modal = createModalElement();
|
||||
manager = new ModalManager(modal);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.destroy();
|
||||
while (document.body.firstChild) {
|
||||
document.body.removeChild(document.body.firstChild);
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// open()
|
||||
// =========================================================
|
||||
|
||||
describe('open()', () => {
|
||||
test('removes hidden class and adds open class', () => {
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
expect(modal.classList.contains('open')).toBe(false);
|
||||
|
||||
manager.open();
|
||||
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
});
|
||||
|
||||
test('sets isOpen to true', () => {
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
manager.open();
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test('prevents body scroll', () => {
|
||||
manager.open();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
test('does not open again if already open', () => {
|
||||
manager.open();
|
||||
manager.open(); // second call should be no-op
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test('calls onOpen callback', () => {
|
||||
const onOpen = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onOpen });
|
||||
manager.open();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches modal:open custom event', () => {
|
||||
const handler = jest.fn();
|
||||
modal.addEventListener('modal:open', handler);
|
||||
manager.open();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// close()
|
||||
// =========================================================
|
||||
|
||||
describe('close()', () => {
|
||||
beforeEach(() => {
|
||||
modal = createModalElement();
|
||||
manager = new ModalManager(modal);
|
||||
manager.open();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.destroy();
|
||||
while (document.body.firstChild) {
|
||||
document.body.removeChild(document.body.firstChild);
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
test('removes open class', () => {
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
manager.close();
|
||||
expect(modal.classList.contains('open')).toBe(false);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// open()
|
||||
// =========================================================
|
||||
|
||||
describe('open()', () => {
|
||||
test('removes hidden class and adds open class', () => {
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
expect(modal.classList.contains('open')).toBe(false);
|
||||
|
||||
manager.open();
|
||||
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
});
|
||||
|
||||
test('sets isOpen to true', () => {
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
manager.open();
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test('prevents body scroll', () => {
|
||||
manager.open();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
test('does not open again if already open', () => {
|
||||
manager.open();
|
||||
manager.open(); // second call should be no-op
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test('calls onOpen callback', () => {
|
||||
const onOpen = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onOpen });
|
||||
manager.open();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches modal:open custom event', () => {
|
||||
const handler = jest.fn();
|
||||
modal.addEventListener('modal:open', handler);
|
||||
manager.open();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('sets isOpen to false immediately', () => {
|
||||
manager.close();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// close()
|
||||
// =========================================================
|
||||
|
||||
describe('close()', () => {
|
||||
beforeEach(() => {
|
||||
manager.open();
|
||||
});
|
||||
|
||||
test('removes open class', () => {
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
manager.close();
|
||||
expect(modal.classList.contains('open')).toBe(false);
|
||||
});
|
||||
|
||||
test('sets isOpen to false immediately', () => {
|
||||
manager.close();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('restores body scroll when no modals remain open', () => {
|
||||
manager.close();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
test('adds hidden class after transitionend event fires', () => {
|
||||
manager.close();
|
||||
|
||||
// Immediately after close(): hidden should NOT yet be added —
|
||||
// the close animation is still in progress.
|
||||
// (This is the bug that existed before the fix.)
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
|
||||
// Simulate the CSS transition completing
|
||||
const event = new Event('transitionend');
|
||||
Object.defineProperty(event, 'target', { value: modal, writable: false });
|
||||
modal.dispatchEvent(event);
|
||||
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
test('adds hidden class via 250ms timeout fallback when transitionend never fires', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
manager.close();
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
|
||||
// Advance past the fallback timeout (250ms)
|
||||
jest.advanceTimersByTime(300);
|
||||
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('does not add hidden class if modal is reopened before timeout fires', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
manager.close();
|
||||
jest.advanceTimersByTime(100); // halfway through timeout
|
||||
|
||||
// Re-open the modal before the timeout fires
|
||||
manager.open();
|
||||
|
||||
jest.advanceTimersByTime(200); // past original timeout expiry
|
||||
|
||||
// Modal was reopened, so hidden must NOT have been added
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('calls onClose callback', () => {
|
||||
const onClose = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onClose });
|
||||
manager.open();
|
||||
manager.close();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches modal:close custom event', () => {
|
||||
const handler = jest.fn();
|
||||
modal.addEventListener('modal:close', handler);
|
||||
manager.close();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('is a no-op when modal is already closed', () => {
|
||||
manager.close(); // close from open
|
||||
const onClose = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onClose });
|
||||
manager.close(); // call close on an already-closed modal
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
test('restores body scroll when no modals remain open', () => {
|
||||
manager.close();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Keyboard interaction
|
||||
// =========================================================
|
||||
test('adds hidden class after transitionend event fires', () => {
|
||||
manager.close();
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
test('Escape key closes an open modal', () => {
|
||||
manager.open();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
// Immediately after close(): hidden should NOT yet be added —
|
||||
// the close animation is still in progress.
|
||||
// (This is the bug that existed before the fix.)
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
|
||||
test('Escape key does nothing when modal is already closed', () => {
|
||||
expect(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
// Simulate the CSS transition completing
|
||||
const event = new Event('transitionend');
|
||||
Object.defineProperty(event, 'target', { value: modal, writable: false });
|
||||
modal.dispatchEvent(event);
|
||||
|
||||
test('closeOnEscape: false prevents Escape from closing', () => {
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { closeOnEscape: false });
|
||||
manager.open();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Close triggers
|
||||
// =========================================================
|
||||
test('adds hidden class via 250ms timeout fallback when transitionend never fires', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('close triggers', () => {
|
||||
test('clicking the × close button closes the modal', () => {
|
||||
manager.open();
|
||||
modal.querySelector('.modal-close').click();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
manager.close();
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
|
||||
test('clicking backdrop (data-close) closes the modal', () => {
|
||||
manager.open();
|
||||
modal.querySelector('.modal-backdrop').click();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
// Advance past the fallback timeout (250ms)
|
||||
jest.advanceTimersByTime(300);
|
||||
|
||||
test('closeOnBackdrop: false prevents backdrop from closing', () => {
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { closeOnBackdrop: false });
|
||||
manager.open();
|
||||
modal.querySelector('.modal-backdrop').click();
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
expect(modal.classList.contains('hidden')).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// destroy()
|
||||
// =========================================================
|
||||
test('does not add hidden class if modal is reopened before timeout fires', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('destroy()', () => {
|
||||
test('closes modal if open', () => {
|
||||
manager.open();
|
||||
manager.destroy();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
manager.close();
|
||||
jest.advanceTimersByTime(100); // halfway through timeout
|
||||
|
||||
test('does not throw when destroying a closed modal', () => {
|
||||
expect(() => manager.destroy()).not.toThrow();
|
||||
});
|
||||
// Re-open the modal before the timeout fires
|
||||
manager.open();
|
||||
|
||||
jest.advanceTimersByTime(200); // past original timeout expiry
|
||||
|
||||
// Modal was reopened, so hidden must NOT have been added
|
||||
expect(modal.classList.contains('hidden')).toBe(false);
|
||||
expect(modal.classList.contains('open')).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('calls onClose callback', () => {
|
||||
const onClose = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onClose });
|
||||
manager.open();
|
||||
manager.close();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches modal:close custom event', () => {
|
||||
const handler = jest.fn();
|
||||
modal.addEventListener('modal:close', handler);
|
||||
manager.close();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('is a no-op when modal is already closed', () => {
|
||||
manager.close(); // close from open
|
||||
const onClose = jest.fn();
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { onClose });
|
||||
manager.close(); // call close on an already-closed modal
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Keyboard interaction
|
||||
// =========================================================
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
test('Escape key closes an open modal', () => {
|
||||
manager.open();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('Escape key does nothing when modal is already closed', () => {
|
||||
expect(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('closeOnEscape: false prevents Escape from closing', () => {
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { closeOnEscape: false });
|
||||
manager.open();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Close triggers
|
||||
// =========================================================
|
||||
|
||||
describe('close triggers', () => {
|
||||
test('clicking the × close button closes the modal', () => {
|
||||
manager.open();
|
||||
modal.querySelector('.modal-close').click();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('clicking backdrop (data-close) closes the modal', () => {
|
||||
manager.open();
|
||||
modal.querySelector('.modal-backdrop').click();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('closeOnBackdrop: false prevents backdrop from closing', () => {
|
||||
manager.destroy();
|
||||
manager = new ModalManager(modal, { closeOnBackdrop: false });
|
||||
manager.open();
|
||||
modal.querySelector('.modal-backdrop').click();
|
||||
expect(manager.isOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// destroy()
|
||||
// =========================================================
|
||||
|
||||
describe('destroy()', () => {
|
||||
test('closes modal if open', () => {
|
||||
manager.open();
|
||||
manager.destroy();
|
||||
expect(manager.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('does not throw when destroying a closed modal', () => {
|
||||
expect(() => manager.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Tests ensuring production code does not contain leftover diagnostic console.log statements.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('No leftover diagnostic console.log statements', () => {
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const filesToCheck = ['src/main.js', 'src/renderer.js'];
|
||||
|
||||
filesToCheck.forEach((relativePath) => {
|
||||
test(`${relativePath} has no console.log statements`, () => {
|
||||
const content = fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||
const matches = content.match(/console\.log\(/g) || [];
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,27 +8,24 @@ describe('PDFOperations Utilities', () => {
|
||||
describe('page range parsing', () => {
|
||||
it('should parse single page numbers', () => {
|
||||
// Test logic: parsing "1" should extract page index 0
|
||||
const input = '1';
|
||||
const pages = [0]; // Parsed result
|
||||
|
||||
|
||||
expect(pages.length).toBe(1);
|
||||
expect(pages[0]).toBe(0);
|
||||
});
|
||||
|
||||
it('should parse page ranges', () => {
|
||||
// Test logic: parsing "1-3" should extract pages 0, 1, 2
|
||||
const input = '1-3';
|
||||
const pages = [0, 1, 2]; // Expected result
|
||||
|
||||
|
||||
expect(pages.length).toBe(3);
|
||||
expect(pages).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('should handle multiple ranges', () => {
|
||||
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
|
||||
const input = '1-2,4-5';
|
||||
const pages = [0, 1, 3, 4]; // Expected result
|
||||
|
||||
|
||||
expect(pages.length).toBe(4);
|
||||
expect(pages).toEqual([0, 1, 3, 4]);
|
||||
});
|
||||
@@ -36,7 +33,7 @@ describe('PDFOperations Utilities', () => {
|
||||
it('should sort pages in ascending order', () => {
|
||||
const unsorted = [2, 0, 3, 1];
|
||||
const sorted = unsorted.sort((a, b) => a - b);
|
||||
|
||||
|
||||
expect(sorted).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -45,14 +42,14 @@ describe('PDFOperations Utilities', () => {
|
||||
it('should validate hex color format', () => {
|
||||
const validHex = '#FF5733';
|
||||
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(validHex);
|
||||
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect invalid hex colors', () => {
|
||||
const invalidColors = ['#GG5733', '#12345', 'notahex'];
|
||||
|
||||
invalidColors.forEach(color => {
|
||||
|
||||
invalidColors.forEach((color) => {
|
||||
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
@@ -73,12 +70,12 @@ describe('PDFOperations Utilities', () => {
|
||||
{ input: '-1', isValid: false },
|
||||
{ input: 'abc', isValid: false },
|
||||
{ input: '1', isValid: true },
|
||||
{ input: '5', isValid: true }
|
||||
{ input: '5', isValid: true },
|
||||
];
|
||||
|
||||
|
||||
testPages.forEach(({ input, isValid }) => {
|
||||
const num = parseInt(input);
|
||||
|
||||
|
||||
if (isNaN(num)) {
|
||||
// Non-numeric string
|
||||
expect(isValid).toBe(false);
|
||||
|
||||
@@ -13,9 +13,14 @@ describe('PluginContext', () => {
|
||||
statusBar: { registerIndicator: jest.fn() },
|
||||
eventBus: new EventBus(),
|
||||
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||
editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||
editor: {
|
||||
getContent: jest.fn(),
|
||||
getSelection: jest.fn(),
|
||||
insertAtCursor: jest.fn(),
|
||||
onContentChanged: jest.fn(),
|
||||
},
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||
exportHooks: { preHooks: [], postHooks: [] }
|
||||
exportHooks: { preHooks: [], postHooks: [] },
|
||||
};
|
||||
context = new PluginContext(mockDeps);
|
||||
});
|
||||
@@ -23,11 +28,17 @@ describe('PluginContext', () => {
|
||||
test('exposes sidebar.registerPanel with namespaced id', () => {
|
||||
const handler = jest.fn();
|
||||
context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', {
|
||||
icon: 'test',
|
||||
title: 'Test',
|
||||
render: handler,
|
||||
});
|
||||
});
|
||||
|
||||
test('exposes commands.register with crash-safe wrapper', () => {
|
||||
const badHandler = () => { throw new Error('boom'); };
|
||||
const badHandler = () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T');
|
||||
const registeredHandler = mockDeps.commands.register.mock.calls[0][2];
|
||||
expect(() => registeredHandler()).not.toThrow();
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('PluginLoader', () => {
|
||||
id: 'my-plugin',
|
||||
name: 'My Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Test'
|
||||
description: 'Test',
|
||||
});
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
@@ -46,7 +46,7 @@ describe('PluginLoader', () => {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin'
|
||||
description: 'A test plugin',
|
||||
};
|
||||
const loader = new PluginLoader([]);
|
||||
expect(loader.validateManifest(manifest)).toBe(true);
|
||||
@@ -67,18 +67,32 @@ describe('PluginLoader', () => {
|
||||
test('validateManifest — rejects manifest with duplicate id', () => {
|
||||
const loader = new PluginLoader([]);
|
||||
loader.loadedIds = new Set(['existing-plugin']);
|
||||
expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' }))
|
||||
.toThrow(/duplicate/i);
|
||||
expect(() =>
|
||||
loader.validateManifest({
|
||||
id: 'existing-plugin',
|
||||
name: 'Dup',
|
||||
version: '1.0.0',
|
||||
description: 'x',
|
||||
})
|
||||
).toThrow(/duplicate/i);
|
||||
});
|
||||
|
||||
test('discoverPlugins — loads index.js Plugin export if present', () => {
|
||||
const pluginDir = path.join(tempDir, 'with-index');
|
||||
writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' });
|
||||
writeManifest(pluginDir, {
|
||||
id: 'with-index',
|
||||
name: 'With Index',
|
||||
version: '1.0.0',
|
||||
description: 'Test',
|
||||
});
|
||||
// Write a simple index.js with a Plugin export
|
||||
fs.writeFileSync(path.join(pluginDir, 'index.js'), `
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'index.js'),
|
||||
`
|
||||
class SimplePlugin { init() {} }
|
||||
module.exports = { Plugin: SimplePlugin };
|
||||
`);
|
||||
`
|
||||
);
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
|
||||
@@ -3,9 +3,16 @@ const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
|
||||
class TestPlugin extends PluginAPI {
|
||||
init(context) { this.initialized = true; this.ctx = context; }
|
||||
activate() { this.activated = true; }
|
||||
deactivate() { this.deactivated = true; }
|
||||
init(context) {
|
||||
this.initialized = true;
|
||||
this.ctx = context;
|
||||
}
|
||||
activate() {
|
||||
this.activated = true;
|
||||
}
|
||||
deactivate() {
|
||||
this.deactivated = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('PluginRegistry', () => {
|
||||
@@ -19,16 +26,26 @@ describe('PluginRegistry', () => {
|
||||
statusBar: { registerIndicator: jest.fn() },
|
||||
eventBus: new EventBus(),
|
||||
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||
editor: { getContent: jest.fn(() => ''), getSelection: jest.fn(() => ''), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() }
|
||||
editor: {
|
||||
getContent: jest.fn(() => ''),
|
||||
getSelection: jest.fn(() => ''),
|
||||
insertAtCursor: jest.fn(),
|
||||
onContentChanged: jest.fn(),
|
||||
},
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||
};
|
||||
registry = new PluginRegistry(mockDeps);
|
||||
});
|
||||
|
||||
test('register — stores plugin and calls init', () => {
|
||||
registry.register({
|
||||
id: 'test', name: 'Test', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: TestPlugin, dir: '/tmp/test'
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
version: '1.0.0',
|
||||
description: 'desc',
|
||||
manifest: {},
|
||||
PluginClass: TestPlugin,
|
||||
dir: '/tmp/test',
|
||||
});
|
||||
const entry = registry.getPlugin('test');
|
||||
expect(entry).toBeDefined();
|
||||
@@ -37,20 +54,32 @@ describe('PluginRegistry', () => {
|
||||
|
||||
test('register — works without PluginClass', () => {
|
||||
registry.register({
|
||||
id: 'manifest-only', name: 'Manifest Only', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: null, dir: '/tmp/test'
|
||||
id: 'manifest-only',
|
||||
name: 'Manifest Only',
|
||||
version: '1.0.0',
|
||||
description: 'desc',
|
||||
manifest: {},
|
||||
PluginClass: null,
|
||||
dir: '/tmp/test',
|
||||
});
|
||||
expect(registry.getPlugin('manifest-only')).toBeDefined();
|
||||
});
|
||||
|
||||
test('register — init error does not crash, plugin not registered', () => {
|
||||
class BadPlugin extends PluginAPI {
|
||||
init() { throw new Error('init fail'); }
|
||||
init() {
|
||||
throw new Error('init fail');
|
||||
}
|
||||
}
|
||||
expect(() => {
|
||||
registry.register({
|
||||
id: 'bad', name: 'Bad', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: BadPlugin, dir: '/tmp/test'
|
||||
id: 'bad',
|
||||
name: 'Bad',
|
||||
version: '1.0.0',
|
||||
description: 'desc',
|
||||
manifest: {},
|
||||
PluginClass: BadPlugin,
|
||||
dir: '/tmp/test',
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(registry.getPlugin('bad')).toBeUndefined();
|
||||
@@ -61,25 +90,65 @@ describe('PluginRegistry', () => {
|
||||
});
|
||||
|
||||
test('getAll — returns all registered plugins', () => {
|
||||
registry.register({ id: 'a', name: 'A', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
|
||||
registry.register({ id: 'b', name: 'B', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
|
||||
registry.register({
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: null,
|
||||
dir: '',
|
||||
});
|
||||
registry.register({
|
||||
id: 'b',
|
||||
name: 'B',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: null,
|
||||
dir: '',
|
||||
});
|
||||
expect(registry.getAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('activate — calls activate on plugin instance', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.register({
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: TestPlugin,
|
||||
dir: '',
|
||||
});
|
||||
registry.activate('test');
|
||||
expect(registry.getPlugin('test').instance.activated).toBe(true);
|
||||
});
|
||||
|
||||
test('deactivate — calls deactivate on plugin instance', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.register({
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: TestPlugin,
|
||||
dir: '',
|
||||
});
|
||||
registry.deactivate('test');
|
||||
expect(registry.getPlugin('test').instance.deactivated).toBe(true);
|
||||
});
|
||||
|
||||
test('exportHooks are available and populated', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.register({
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
description: '',
|
||||
manifest: {},
|
||||
PluginClass: TestPlugin,
|
||||
dir: '',
|
||||
});
|
||||
const handler = jest.fn();
|
||||
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
|
||||
expect(registry.exportHooks.preHooks).toContain(handler);
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('Preload Security', () => {
|
||||
'execute-code',
|
||||
'show-pdf-editor-from-toolbar',
|
||||
'menu-open',
|
||||
'export'
|
||||
'export',
|
||||
];
|
||||
|
||||
const EXPECTED_RECEIVE_CHANNELS = [
|
||||
@@ -127,7 +127,7 @@ describe('Preload Security', () => {
|
||||
'load-template-menu',
|
||||
'toggle-command-palette',
|
||||
'toggle-sidebar-panel',
|
||||
'toggle-bottom-panel'
|
||||
'toggle-bottom-panel',
|
||||
];
|
||||
|
||||
test('should define all expected send channels', () => {
|
||||
|
||||
+82
-70
@@ -8,13 +8,17 @@
|
||||
*/
|
||||
|
||||
describe('PrintPreview', () => {
|
||||
beforeEach(() => {
|
||||
// Mock electron require for executePrint
|
||||
jest.mock('electron', () => ({
|
||||
ipcRenderer: { send: jest.fn(), invoke: jest.fn() }
|
||||
}), { virtual: true });
|
||||
beforeEach(() => {
|
||||
// Mock electron require for executePrint
|
||||
jest.mock(
|
||||
'electron',
|
||||
() => ({
|
||||
ipcRenderer: { send: jest.fn(), invoke: jest.fn() },
|
||||
}),
|
||||
{ virtual: true }
|
||||
);
|
||||
|
||||
document.body.innerHTML = `
|
||||
document.body.innerHTML = `
|
||||
<div class="dialog-overlay hidden" id="print-preview-overlay">
|
||||
<button id="print-preview-close"></button>
|
||||
<button id="print-cancel"></button>
|
||||
@@ -31,79 +35,87 @@ describe('PrintPreview', () => {
|
||||
<iframe id="print-preview-frame"></iframe>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
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('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('<p>Test</p>');
|
||||
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('opens and closes', () => {
|
||||
const { PrintPreview } = require('../src/print-preview');
|
||||
const preview = new PrintPreview();
|
||||
preview.open('<p>Test</p>');
|
||||
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('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('<p>Test</p>');
|
||||
document.getElementById('print-preview-close').click();
|
||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
test('close button closes preview', () => {
|
||||
const { PrintPreview } = require('../src/print-preview');
|
||||
const preview = new PrintPreview();
|
||||
preview.open('<p>Test</p>');
|
||||
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('<p>Test</p>');
|
||||
document.getElementById('print-cancel').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('<p>Test</p>');
|
||||
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();
|
||||
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 paper size to Letter
|
||||
const paperSelect = document.getElementById('print-paper-size');
|
||||
paperSelect.value = 'Letter';
|
||||
|
||||
// Change scale
|
||||
document.getElementById('print-scale').value = '50';
|
||||
// Change scale
|
||||
document.getElementById('print-scale').value = '50';
|
||||
|
||||
// Uncheck headers
|
||||
document.getElementById('print-headers').checked = false;
|
||||
// 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);
|
||||
});
|
||||
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('<p>Hello World</p>');
|
||||
expect(preview._lastContent).toBe('<p>Hello World</p>');
|
||||
});
|
||||
test('stores last content for refresh', () => {
|
||||
const { PrintPreview } = require('../src/print-preview');
|
||||
const preview = new PrintPreview();
|
||||
preview.open('<p>Hello World</p>');
|
||||
expect(preview._lastContent).toBe('<p>Hello World</p>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,15 +8,22 @@ describe('ProjectManager', () => {
|
||||
files = {};
|
||||
pm = new ProjectManager({
|
||||
readFile: (p) => files[p] || null,
|
||||
writeFile: (p, c) => { files[p] = c; },
|
||||
writeFile: (p, c) => {
|
||||
files[p] = c;
|
||||
},
|
||||
fileExists: (p) => p in files,
|
||||
listDir: (p) => Object.keys(files).filter(f => f.startsWith(p)).map(f => f.slice(p.length + 1))
|
||||
listDir: (p) =>
|
||||
Object.keys(files)
|
||||
.filter((f) => f.startsWith(p))
|
||||
.map((f) => f.slice(p.length + 1)),
|
||||
});
|
||||
});
|
||||
|
||||
test('createProject writes .project.json', () => {
|
||||
const project = pm.createProject('/manuscripts/novel', {
|
||||
title: 'My Novel', type: 'manuscript', targetWords: 80000
|
||||
title: 'My Novel',
|
||||
type: 'manuscript',
|
||||
targetWords: 80000,
|
||||
});
|
||||
expect(project.title).toBe('My Novel');
|
||||
expect(files['/manuscripts/novel/.project.json']).toBeDefined();
|
||||
@@ -26,8 +33,11 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('loadProject reads and returns project data', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [], metadata: {}
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [],
|
||||
metadata: {},
|
||||
});
|
||||
const project = pm.loadProject('/manuscripts/novel');
|
||||
expect(project.title).toBe('Test');
|
||||
@@ -39,10 +49,17 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('addChapter appends chapter and saves', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [], metadata: {}
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [],
|
||||
metadata: {},
|
||||
});
|
||||
pm.addChapter('/manuscripts/novel', {
|
||||
file: '01-chapter.md',
|
||||
title: 'Chapter One',
|
||||
status: 'draft',
|
||||
});
|
||||
pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.chapters.length).toBe(1);
|
||||
expect(parsed.chapters[0].title).toBe('Chapter One');
|
||||
@@ -50,11 +67,14 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('compileManuscript concatenates chapter files', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||
],
|
||||
metadata: {},
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
||||
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
||||
@@ -64,11 +84,14 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('compileManuscript skips missing files', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||
],
|
||||
metadata: {},
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
||||
const result = pm.compileManuscript('/manuscripts/novel');
|
||||
@@ -77,11 +100,14 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('getStats returns total word count across chapters', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||
],
|
||||
metadata: {},
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
||||
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
||||
@@ -94,9 +120,11 @@ describe('ProjectManager', () => {
|
||||
|
||||
test('updateChapter modifies a chapter by index', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
target: { words: 50000 },
|
||||
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
||||
metadata: {}
|
||||
metadata: {},
|
||||
});
|
||||
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Tests for project metadata consistency
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('Project version consistency', () => {
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
|
||||
const readme = fs.readFileSync(path.join(rootDir, 'README.md'), 'utf8');
|
||||
|
||||
test('README version matches package.json version', () => {
|
||||
const version = packageJson.version;
|
||||
expect(readme).toContain(`v${version}`);
|
||||
});
|
||||
|
||||
test('README contains current version string', () => {
|
||||
expect(readme).toContain('v4.4.4');
|
||||
});
|
||||
});
|
||||
@@ -8,25 +8,18 @@ const path = require('path');
|
||||
describe('Security: Path Handling', () => {
|
||||
describe('path traversal prevention', () => {
|
||||
it('should detect path traversal patterns', () => {
|
||||
const maliciousPaths = [
|
||||
'../etc/passwd',
|
||||
'../../sensitive',
|
||||
'./../outside'
|
||||
];
|
||||
const maliciousPaths = ['../etc/passwd', '../../sensitive', './../outside'];
|
||||
|
||||
maliciousPaths.forEach(pathStr => {
|
||||
maliciousPaths.forEach((pathStr) => {
|
||||
// Path traversal attempts contain .. patterns
|
||||
expect(pathStr).toMatch(/\.\./);
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize relative paths safely', () => {
|
||||
const safePaths = [
|
||||
'./documents/file.md',
|
||||
'relative/path/file.txt'
|
||||
];
|
||||
const safePaths = ['./documents/file.md', 'relative/path/file.txt'];
|
||||
|
||||
safePaths.forEach(pathStr => {
|
||||
safePaths.forEach((pathStr) => {
|
||||
const normalized = path.normalize(pathStr);
|
||||
// Safe relative paths should normalize cleanly
|
||||
expect(normalized).toBeDefined();
|
||||
@@ -37,7 +30,7 @@ describe('Security: Path Handling', () => {
|
||||
it('should detect absolute paths', () => {
|
||||
const absolutePath = '/etc/passwd';
|
||||
const isAbsolute = path.isAbsolute(absolutePath);
|
||||
|
||||
|
||||
// Linux/Mac: /path is absolute
|
||||
if (process.platform !== 'win32') {
|
||||
expect(isAbsolute).toBe(true);
|
||||
@@ -47,9 +40,9 @@ describe('Security: Path Handling', () => {
|
||||
it('should safely join paths with base directory', () => {
|
||||
const baseDir = '/safe/base/directory';
|
||||
const userInput = 'documents/file.md';
|
||||
|
||||
|
||||
const joined = path.join(baseDir, userInput);
|
||||
|
||||
|
||||
// Result should contain the safe base
|
||||
expect(joined).toContain('base');
|
||||
expect(joined).toContain('documents');
|
||||
@@ -58,14 +51,9 @@ describe('Security: Path Handling', () => {
|
||||
|
||||
describe('filename safety', () => {
|
||||
it('should identify safe filenames', () => {
|
||||
const safeNames = [
|
||||
'document.md',
|
||||
'my-file.txt',
|
||||
'file_name.pdf',
|
||||
'report_2026_04_24.xlsx'
|
||||
];
|
||||
const safeNames = ['document.md', 'my-file.txt', 'file_name.pdf', 'report_2026_04_24.xlsx'];
|
||||
|
||||
safeNames.forEach(name => {
|
||||
safeNames.forEach((name) => {
|
||||
// Safe names should not contain path separators or null bytes
|
||||
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
|
||||
expect(isSafe).toBe(true);
|
||||
@@ -73,12 +61,9 @@ describe('Security: Path Handling', () => {
|
||||
});
|
||||
|
||||
it('should flag filenames with path separators', () => {
|
||||
const problematicNames = [
|
||||
'file/with/slashes.txt',
|
||||
'file\\with\\backslashes.txt'
|
||||
];
|
||||
const problematicNames = ['file/with/slashes.txt', 'file\\with\\backslashes.txt'];
|
||||
|
||||
problematicNames.forEach(name => {
|
||||
problematicNames.forEach((name) => {
|
||||
// These contain path separators and should be flagged
|
||||
const hasPathSeparators = /[\\/]/.test(name);
|
||||
expect(hasPathSeparators).toBe(true);
|
||||
@@ -87,7 +72,7 @@ describe('Security: Path Handling', () => {
|
||||
|
||||
it('should enforce minimum filename length', () => {
|
||||
const emptyName = '';
|
||||
|
||||
|
||||
expect(emptyName.length).toBe(0);
|
||||
expect(emptyName.length > 0).toBe(false);
|
||||
});
|
||||
@@ -95,10 +80,8 @@ describe('Security: Path Handling', () => {
|
||||
|
||||
describe('validation patterns', () => {
|
||||
it('should validate path existence check pattern', () => {
|
||||
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
|
||||
|
||||
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
|
||||
validPaths.forEach(pathStr => {
|
||||
validPaths.forEach((pathStr) => {
|
||||
// These should match a reasonable filename pattern
|
||||
expect(typeof pathStr).toBe('string');
|
||||
});
|
||||
@@ -106,7 +89,7 @@ describe('Security: Path Handling', () => {
|
||||
|
||||
it('should prevent null byte injection', () => {
|
||||
const pathWithNullByte = 'file.txt\0.exe';
|
||||
|
||||
|
||||
const isSafe = !pathWithNullByte.includes('\0');
|
||||
expect(isSafe).toBe(false); // Has null byte, not safe
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ describe('SettingsStore', () => {
|
||||
data = {};
|
||||
store = new SettingsStore({
|
||||
get: (key) => data[key],
|
||||
set: (key, value) => { data[key] = value; }
|
||||
set: (key, value) => {
|
||||
data[key] = value;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-15
@@ -25,61 +25,61 @@ global.window.electronAPI = {
|
||||
exists: jest.fn(() => Promise.resolve(false)),
|
||||
isDirectory: jest.fn(() => Promise.resolve(false)),
|
||||
copy: jest.fn(() => Promise.resolve()),
|
||||
move: jest.fn(() => Promise.resolve())
|
||||
move: jest.fn(() => Promise.resolve()),
|
||||
},
|
||||
theme: {
|
||||
get: jest.fn()
|
||||
get: jest.fn(),
|
||||
},
|
||||
print: {
|
||||
doPrint: jest.fn()
|
||||
doPrint: jest.fn(),
|
||||
},
|
||||
export: {
|
||||
withOptions: jest.fn(),
|
||||
spreadsheet: jest.fn()
|
||||
spreadsheet: jest.fn(),
|
||||
},
|
||||
batch: {
|
||||
convert: jest.fn(),
|
||||
selectFolder: jest.fn()
|
||||
selectFolder: jest.fn(),
|
||||
},
|
||||
converter: {
|
||||
convert: jest.fn(),
|
||||
convertBatch: jest.fn()
|
||||
convertBatch: jest.fn(),
|
||||
},
|
||||
headerFooter: {
|
||||
getSettings: jest.fn(),
|
||||
saveSettings: jest.fn(),
|
||||
browseLogo: jest.fn(),
|
||||
saveLogo: jest.fn(),
|
||||
clearLogo: jest.fn()
|
||||
clearLogo: jest.fn(),
|
||||
},
|
||||
page: {
|
||||
getSettings: jest.fn(),
|
||||
updateSettings: jest.fn(),
|
||||
setCustomStartPage: jest.fn()
|
||||
setCustomStartPage: jest.fn(),
|
||||
},
|
||||
pdf: {
|
||||
processOperation: jest.fn(),
|
||||
getPageCount: jest.fn(),
|
||||
selectFolder: jest.fn()
|
||||
}
|
||||
selectFolder: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Mock marked library
|
||||
global.window.marked = {
|
||||
parse: jest.fn((text) => `<p>${text}</p>`),
|
||||
use: jest.fn()
|
||||
use: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock DOMPurify
|
||||
global.window.DOMPurify = {
|
||||
sanitize: jest.fn((html) => html)
|
||||
sanitize: jest.fn((html) => html),
|
||||
};
|
||||
|
||||
// Mock highlight.js
|
||||
global.window.hljs = {
|
||||
highlight: jest.fn((code, options) => ({ value: code })),
|
||||
highlight: jest.fn((code, _options) => ({ value: code })),
|
||||
highlightAuto: jest.fn((code) => ({ value: code })),
|
||||
getLanguage: jest.fn(() => true)
|
||||
getLanguage: jest.fn(() => true),
|
||||
};
|
||||
|
||||
// Mock localStorage
|
||||
@@ -87,7 +87,7 @@ const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn()
|
||||
clear: jest.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
|
||||
+97
-82
@@ -8,10 +8,10 @@
|
||||
*/
|
||||
|
||||
describe('SidebarManager', () => {
|
||||
let SidebarManager;
|
||||
let SidebarManager;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div class="sidebar collapsed" id="sidebar">
|
||||
<div class="sidebar-icons">
|
||||
<button class="sidebar-icon" data-panel="test1"></button>
|
||||
@@ -26,96 +26,111 @@ describe('SidebarManager', () => {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
SidebarManager = require('../src/sidebar/sidebar-manager').SidebarManager;
|
||||
});
|
||||
SidebarManager = require('../src/sidebar/sidebar-manager').SidebarManager;
|
||||
});
|
||||
|
||||
test('starts collapsed', () => {
|
||||
const mgr = new SidebarManager();
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
});
|
||||
test('starts collapsed', () => {
|
||||
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('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('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('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('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('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('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('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('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 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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ describe('SnapshotManager', () => {
|
||||
store = {};
|
||||
manager = new SnapshotManager({
|
||||
get: (key) => store[key],
|
||||
set: (key, value) => { store[key] = value; }
|
||||
set: (key, value) => {
|
||||
store[key] = value;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('SprintEngine', () => {
|
||||
beforeEach(() => {
|
||||
events = [];
|
||||
engine = new SprintEngine({
|
||||
onEvent: (name, data) => events.push({ name, data })
|
||||
onEvent: (name, data) => events.push({ name, data }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('SprintEngine', () => {
|
||||
test('tick auto-stops when time expires and emits sprint:complete', () => {
|
||||
engine.start(1, 100); // 1 minute
|
||||
engine.tick(60 * 1000 + 1); // just past
|
||||
expect(events.some(e => e.name === 'sprint:complete')).toBe(true);
|
||||
expect(events.some((e) => e.name === 'sprint:complete')).toBe(true);
|
||||
expect(engine.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
+11
-7
@@ -35,7 +35,7 @@ describe('Utility Functions', () => {
|
||||
|
||||
return {
|
||||
command: parts[0],
|
||||
args: parts.slice(1)
|
||||
args: parts.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +58,9 @@ describe('Utility Functions', () => {
|
||||
});
|
||||
|
||||
test('should handle multiple options', () => {
|
||||
const result = parseCommand('pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf');
|
||||
const result = parseCommand(
|
||||
'pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf'
|
||||
);
|
||||
expect(result.command).toBe('pandoc');
|
||||
expect(result.args).toContain('--pdf-engine=xelatex');
|
||||
expect(result.args).toContain('-V');
|
||||
@@ -75,11 +77,13 @@ describe('Utility Functions', () => {
|
||||
// This function converts hex colors to RGB
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255
|
||||
} : null;
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
test('should convert black hex to RGB', () => {
|
||||
|
||||
Reference in New Issue
Block a user