diff --git a/js/mzta-utils.js b/js/mzta-utils.js index c5dbc59e..30f93f50 100644 --- a/js/mzta-utils.js +++ b/js/mzta-utils.js @@ -45,7 +45,7 @@ export function getMiczItUrl(path) { return `https://micz.it/${prefix}${path}`; } -function fixMsgHeader(msgHeader) { +export function fixMsgHeader(msgHeader) { if (!msgHeader.bccList) { msgHeader.bccList = []; } @@ -117,7 +117,7 @@ export async function getCurrentIdentity(msgHeader, getFull = false) { } -function extractEmail(text) { +export function extractEmail(text) { if((text=='')||(text==undefined)) return ''; const emailRegex = /[\w.-]+@[\w.-]+\.\w+/; const match = text.match(emailRegex); @@ -278,7 +278,7 @@ export function convertNewlinesToBr(text) { return text.replace(/\r\n/g, '\n').replace(/\n/g, '
'); } -function convertBrToNewlines(html) { +export function convertBrToNewlines(html) { return html.replace(//gi, '\n'); } @@ -374,7 +374,7 @@ export function i18nConditionalGet(str) { return str; // Return the original string if the conditions are not met } -function compareThunderbirdVersions(v1, v2) { +export function compareThunderbirdVersions(v1, v2) { const v1parts = v1.split('.').map(Number); const v2parts = v2.split('.').map(Number); @@ -472,7 +472,7 @@ export async function assignTagsToMessage(messageId, tags) { } } -function getTagsKeyFromLabel(tag_names, all_tags_list) { +export function getTagsKeyFromLabel(tag_names, all_tags_list) { const result = []; tag_names.forEach(name => { @@ -488,7 +488,7 @@ function getTagsKeyFromLabel(tag_names, all_tags_list) { return result; } -function sanitizeString(input) { +export function sanitizeString(input) { // Define the regex to match valid characters const validChar = /^[^ ()/{%*<>"]+$/; // Filter out invalid characters from the string @@ -799,7 +799,7 @@ export async function getLocalStorageUsedSpace(){ return formatBytes(customprompts_space); } -function formatBytes(bytes, decimals = 2) { +export function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const step = 1024; const suffixes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; diff --git a/package.json b/package.json new file mode 100644 index 00000000..6febc6a7 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "description": "Dev-only: unit tests for ThunderAI. Not used by the addon.", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "vitest": "^3.1.1", + "jsdom": "^26.1.0" + } +} diff --git a/tests/mzta-logger.test.js b/tests/mzta-logger.test.js new file mode 100644 index 00000000..bab18f22 --- /dev/null +++ b/tests/mzta-logger.test.js @@ -0,0 +1,59 @@ +import { taLogger } from '../js/mzta-logger.js'; + +describe('taLogger', () => { + let logSpy, errorSpy, warnSpy; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets prefix and debug flag in constructor', () => { + const logger = new taLogger('Test', true); + expect(logger.prefix).toBe('[ThunderAI Logger | Test] '); + expect(logger.do_debug).toBe(true); + }); + + it('log() outputs when debug is true', () => { + const logger = new taLogger('Test', true); + logger.log('hello'); + expect(logSpy).toHaveBeenCalledWith('[ThunderAI Logger | Test] hello'); + }); + + it('log() is silent when debug is false', () => { + const logger = new taLogger('Test', false); + logger.log('hello'); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('log() with do_debug parameter changes debug state', () => { + const logger = new taLogger('Test', false); + logger.log('hello', true); + expect(logSpy).toHaveBeenCalledWith('[ThunderAI Logger | Test] hello'); + expect(logger.do_debug).toBe(true); + }); + + it('error() always outputs regardless of debug flag', () => { + const logger = new taLogger('Test', false); + logger.error('something broke'); + expect(errorSpy).toHaveBeenCalledWith('[ThunderAI Logger | Test] something broke'); + }); + + it('warn() always outputs regardless of debug flag', () => { + const logger = new taLogger('Test', false); + logger.warn('watch out'); + expect(warnSpy).toHaveBeenCalledWith('[ThunderAI Logger | Test] watch out'); + }); + + it('changeDebug() updates the debug flag', () => { + const logger = new taLogger('Test', false); + expect(logger.do_debug).toBe(false); + logger.changeDebug(true); + expect(logger.do_debug).toBe(true); + }); +}); diff --git a/tests/mzta-placeholders.test.js b/tests/mzta-placeholders.test.js new file mode 100644 index 00000000..bfda1f43 --- /dev/null +++ b/tests/mzta-placeholders.test.js @@ -0,0 +1,206 @@ +import { + mapPlaceholderToSuggestion, + prepareCustomDataPHsForExport, + placeholdersUtils, +} from '../js/mzta-placeholders.js'; + +// ─── mapPlaceholderToSuggestion ────────────────────────────────── + +describe('mapPlaceholderToSuggestion', () => { + it('transforms non-dynamic placeholder', () => { + const p = { id: 'mail_subject', type: 'default', is_dynamic: 0 }; + expect(mapPlaceholderToSuggestion(p)).toEqual({ + command: '{%mail_subject%}', + type: 'default', + is_dynamic: 0, + }); + }); + + it('transforms dynamic placeholder (adds colon)', () => { + const p = { id: 'mail_headers', type: 'default', is_dynamic: 1 }; + expect(mapPlaceholderToSuggestion(p)).toEqual({ + command: '{%mail_headers:%}', + type: 'default', + is_dynamic: 1, + }); + }); +}); + +// ─── prepareCustomDataPHsForExport ─────────────────────────────── + +describe('prepareCustomDataPHsForExport', () => { + it('removes idnum from non-default placeholders', () => { + const placeholders = [ + { id: 'thunderai_custom_test', is_default: 0, idnum: 1, text: 'hello' }, + ]; + const result = prepareCustomDataPHsForExport(placeholders); + expect(result[0]).not.toHaveProperty('idnum'); + expect(result[0].text).toBe('hello'); + }); + + it('keeps idnum for default placeholders', () => { + const placeholders = [ + { id: 'mail_subject', is_default: 1, idnum: 1 }, + ]; + const result = prepareCustomDataPHsForExport(placeholders); + expect(result[0].idnum).toBe(1); + }); +}); + +// ─── placeholdersUtils ─────────────────────────────────────────── + +describe('placeholdersUtils.validateCustomDataPH_ID', () => { + it('prepends prefix if missing', () => { + expect(placeholdersUtils.validateCustomDataPH_ID('my_ph')).toBe('thunderai_custom_my_ph'); + }); + + it('returns unchanged if prefix already present', () => { + expect(placeholdersUtils.validateCustomDataPH_ID('thunderai_custom_my_ph')).toBe('thunderai_custom_my_ph'); + }); +}); + +describe('placeholdersUtils.stripCustomDataPH_ID_Prefix', () => { + it('removes prefix if present', () => { + expect(placeholdersUtils.stripCustomDataPH_ID_Prefix('thunderai_custom_my_ph')).toBe('my_ph'); + }); + + it('returns unchanged if prefix not present', () => { + expect(placeholdersUtils.stripCustomDataPH_ID_Prefix('other_ph')).toBe('other_ph'); + }); +}); + +describe('placeholdersUtils.hasPlaceholder', () => { + it('detects any placeholder when no specific placeholder given', () => { + expect(placeholdersUtils.hasPlaceholder('Hello {%mail_subject%}')).toBe(true); + }); + + it('returns false when no placeholder present', () => { + expect(placeholdersUtils.hasPlaceholder('Hello world')).toBe(false); + }); + + it('detects specific placeholder', () => { + expect(placeholdersUtils.hasPlaceholder('Text {%mail_subject%}', 'mail_subject')).toBe(true); + }); + + it('returns false for non-matching specific placeholder', () => { + expect(placeholdersUtils.hasPlaceholder('Text {%mail_subject%}', 'author')).toBe(false); + }); + + it('detects dynamic placeholder with value', () => { + expect(placeholdersUtils.hasPlaceholder('Header: {%mail_headers:X-Custom%}', 'mail_headers')).toBe(true); + }); +}); + +describe('placeholdersUtils.hasCustomPlaceholder', () => { + it('detects thunderai_custom_ placeholders', () => { + expect(placeholdersUtils.hasCustomPlaceholder('Text {%thunderai_custom_myph%}')).toBe(true); + }); + + it('returns false when none present', () => { + expect(placeholdersUtils.hasCustomPlaceholder('Text {%mail_subject%}')).toBe(false); + }); + + it('checks specific custom placeholder', () => { + expect(placeholdersUtils.hasCustomPlaceholder('{%thunderai_custom_test%}', 'thunderai_custom_test')).toBe(true); + expect(placeholdersUtils.hasCustomPlaceholder('{%thunderai_custom_other%}', 'thunderai_custom_test')).toBe(false); + }); +}); + +describe('placeholdersUtils.getPlaceholdersAdditionalTextArray', () => { + it('extracts additional_text placeholders', () => { + const result = placeholdersUtils.getPlaceholdersAdditionalTextArray('Text {%additional_text%}'); + expect(result).toEqual([{ placeholder: '{%additional_text%}', info: '' }]); + }); + + it('extracts additional_text with custom info', () => { + const result = placeholdersUtils.getPlaceholdersAdditionalTextArray('Text {%additional_text:Subject line%}'); + expect(result).toEqual([{ placeholder: '{%additional_text:Subject line%}', info: 'Subject line' }]); + }); + + it('deduplicates entries with same info', () => { + const text = '{%additional_text:#1%} and {%additional_text:#1%}'; + const result = placeholdersUtils.getPlaceholdersAdditionalTextArray(text); + expect(result).toHaveLength(1); + }); + + it('returns multiple entries with different info', () => { + const text = '{%additional_text:#1%} and {%additional_text:#2%}'; + const result = placeholdersUtils.getPlaceholdersAdditionalTextArray(text); + expect(result).toHaveLength(2); + }); + + it('returns empty array when none found', () => { + expect(placeholdersUtils.getPlaceholdersAdditionalTextArray('no placeholders')).toEqual([]); + }); +}); + +describe('placeholdersUtils.failSafePlaceholders', () => { + it('returns empty string for null', () => { + expect(placeholdersUtils.failSafePlaceholders(null)).toBe(''); + }); + + it('returns empty string for undefined', () => { + expect(placeholdersUtils.failSafePlaceholders(undefined)).toBe(''); + }); + + it('passes through strings unchanged', () => { + expect(placeholdersUtils.failSafePlaceholders('hello')).toBe('hello'); + }); + + it('passes through numbers unchanged', () => { + expect(placeholdersUtils.failSafePlaceholders(42)).toBe(42); + }); + + it('passes through empty string', () => { + expect(placeholdersUtils.failSafePlaceholders('')).toBe(''); + }); +}); + +describe('placeholdersUtils.replacePlaceholders', () => { + it('replaces known placeholders with provided values', () => { + const result = placeholdersUtils.replacePlaceholders({ + text: 'Subject: {%mail_subject%}', + replacements: { mail_subject: 'Test Email' }, + }); + expect(result).toBe('Subject: Test Email'); + }); + + it('replaces multiple placeholders', () => { + const result = placeholdersUtils.replacePlaceholders({ + text: 'From: {%author%}, Subject: {%mail_subject%}', + replacements: { author: 'John', mail_subject: 'Hello' }, + }); + expect(result).toBe('From: John, Subject: Hello'); + }); + + it('leaves unknown placeholders unchanged', () => { + const result = placeholdersUtils.replacePlaceholders({ + text: 'Value: {%nonexistent_thing%}', + replacements: {}, + }); + expect(result).toBe('Value: {%nonexistent_thing%}'); + }); + + it('skips additional_text when skip_additional_text is true', () => { + const result = placeholdersUtils.replacePlaceholders({ + text: 'Text: {%additional_text%}', + replacements: { additional_text: 'replaced' }, + skip_additional_text: true, + }); + expect(result).toBe('Text: {%additional_text%}'); + }); + + it('skips additional_text with suffix when skip_additional_text is true', () => { + const result = placeholdersUtils.replacePlaceholders({ + text: 'Text: {%additional_text:#1%}', + replacements: {}, + skip_additional_text: true, + }); + expect(result).toBe('Text: {%additional_text:#1%}'); + }); + + it('handles empty args gracefully', () => { + const result = placeholdersUtils.replacePlaceholders(); + expect(result).toBe(''); + }); +}); diff --git a/tests/mzta-prompts.test.js b/tests/mzta-prompts.test.js new file mode 100644 index 00000000..9824cb4b --- /dev/null +++ b/tests/mzta-prompts.test.js @@ -0,0 +1,98 @@ +import { preparePromptsForExport } from '../js/mzta-prompts.js'; + +describe('preparePromptsForExport', () => { + // Helper: create a custom prompt with integration keys + function makeCustomPrompt(overrides = {}) { + return { + id: 'custom_1', + is_default: 0, + idnum: 5, + text: 'Do something', + enabled: 1, + api_type: '', + chatgpt_api_key: 'key1', + chatgpt_model: 'gpt-5', + ollama_host: 'http://localhost', + ollama_model: 'llama3', + ...overrides, + }; + } + + // Helper: create a default prompt + function makeDefaultPrompt(overrides = {}) { + return { + id: 'prompt_reply', + is_default: 1, + idnum: 1, + text: 'Reply to this email', + enabled: 1, + position_compose: 1, + position_display: 2, + need_custom_text: 0, + api_type: '', + chatgpt_api_key: 'key1', + chatgpt_model: 'gpt-5', + ollama_host: 'http://localhost', + ...overrides, + }; + } + + it('removes api_type and integration keys when include_api_settings is false', () => { + const prompts = [makeCustomPrompt()]; + const result = preparePromptsForExport(prompts, false); + expect(result[0]).not.toHaveProperty('api_type'); + expect(result[0]).not.toHaveProperty('chatgpt_api_key'); + expect(result[0]).not.toHaveProperty('chatgpt_model'); + expect(result[0]).not.toHaveProperty('ollama_host'); + expect(result[0]).not.toHaveProperty('ollama_model'); + expect(result[0].text).toBe('Do something'); + }); + + it('removes idnum from custom prompts', () => { + const prompts = [makeCustomPrompt()]; + const result = preparePromptsForExport(prompts, false); + expect(result[0]).not.toHaveProperty('idnum'); + }); + + it('for default prompts, keeps only allowed keys when include_api_settings is false', () => { + const prompts = [makeDefaultPrompt()]; + const result = preparePromptsForExport(prompts, false); + const keys = Object.keys(result[0]); + expect(keys).toContain('id'); + expect(keys).toContain('enabled'); + expect(keys).toContain('position_compose'); + expect(keys).toContain('position_display'); + expect(keys).toContain('need_custom_text'); + expect(keys).not.toContain('text'); + expect(keys).not.toContain('api_type'); + expect(keys).not.toContain('chatgpt_api_key'); + }); + + it('keeps api_type when include_api_settings is true', () => { + const prompts = [makeCustomPrompt({ api_type: 'chatgpt_api' })]; + const result = preparePromptsForExport(prompts, true); + expect(result[0].api_type).toBe('chatgpt_api'); + }); + + it('keeps only active integration keys when api_type is set and include_api_settings is true', () => { + const prompts = [makeCustomPrompt({ api_type: 'chatgpt_api' })]; + const result = preparePromptsForExport(prompts, true); + // chatgpt keys should remain (active integration = "chatgpt" from "chatgpt_api") + expect(result[0]).toHaveProperty('chatgpt_api_key'); + expect(result[0]).toHaveProperty('chatgpt_model'); + // ollama keys should be removed + expect(result[0]).not.toHaveProperty('ollama_host'); + expect(result[0]).not.toHaveProperty('ollama_model'); + }); + + it('does not mutate the original array', () => { + const original = [makeCustomPrompt()]; + const originalCopy = JSON.parse(JSON.stringify(original)); + preparePromptsForExport(original, false); + expect(original).toEqual(originalCopy); + }); + + it('handles empty prompts array', () => { + expect(preparePromptsForExport([], false)).toEqual([]); + }); +}); diff --git a/tests/mzta-utils-prompt.test.js b/tests/mzta-utils-prompt.test.js new file mode 100644 index 00000000..105b07bb --- /dev/null +++ b/tests/mzta-utils-prompt.test.js @@ -0,0 +1,73 @@ +import { taPromptUtils } from '../js/mzta-utils-prompt.js'; + +// ─── finalizePrompt_get_calendar_event ─────────────────────────── + +describe('taPromptUtils.finalizePrompt_get_calendar_event', () => { + it('removes {%cc_list%} placeholder', () => { + const result = taPromptUtils.finalizePrompt_get_calendar_event('Send to {%cc_list%} please'); + expect(result).toBe('Send to please'); + }); + + it('removes {%recipients%} placeholder', () => { + const result = taPromptUtils.finalizePrompt_get_calendar_event('Invite {%recipients%} now'); + expect(result).toBe('Invite now'); + }); + + it('removes both placeholders', () => { + const result = taPromptUtils.finalizePrompt_get_calendar_event('To: {%recipients%}, CC: {%cc_list%}'); + expect(result).toBe('To: , CC: '); + }); + + it('returns prompt unchanged if no placeholders present', () => { + const prompt = 'Create a calendar event from this email'; + expect(taPromptUtils.finalizePrompt_get_calendar_event(prompt)).toBe(prompt); + }); +}); + +// ─── getTagsFromResponse ───────────────────────────────────────── + +describe('taPromptUtils.getTagsFromResponse', () => { + it('parses JSON response with tags array', () => { + const response = '{"tags": ["urgent", "work"]}'; + expect(taPromptUtils.getTagsFromResponse(response)).toEqual(['urgent', 'work']); + }); + + it('parses JSON response with tags as string', () => { + const response = '{"tags": "urgent, work"}'; + expect(taPromptUtils.getTagsFromResponse(response)).toEqual(['urgent', 'work']); + }); + + it('handles JSON embedded in text', () => { + const response = 'Here are the tags: {"tags": ["a", "b"]} done'; + expect(taPromptUtils.getTagsFromResponse(response)).toEqual(['a', 'b']); + }); + + it('falls back to comma splitting on invalid JSON', () => { + const response = 'urgent, work, personal'; + expect(taPromptUtils.getTagsFromResponse(response)).toEqual(['urgent', 'work', 'personal']); + }); + + it('returns empty array for empty input', () => { + expect(taPromptUtils.getTagsFromResponse('')).toEqual([]); + expect(taPromptUtils.getTagsFromResponse(null)).toEqual([]); + expect(taPromptUtils.getTagsFromResponse(undefined)).toEqual([]); + }); + + it('filters tags when filter_tags is true', () => { + const response = '{"tags": ["urgent", "work", "personal"]}'; + const result = taPromptUtils.getTagsFromResponse(response, true, 'urgent, personal'); + expect(result).toEqual(['urgent', 'personal']); + }); + + it('filter is case-insensitive', () => { + const response = '{"tags": ["Urgent", "WORK"]}'; + const result = taPromptUtils.getTagsFromResponse(response, true, 'urgent, work'); + expect(result).toEqual(['Urgent', 'WORK']); + }); + + it('does not filter when filter_tags is false', () => { + const response = '{"tags": ["urgent", "work"]}'; + const result = taPromptUtils.getTagsFromResponse(response, false, 'urgent'); + expect(result).toEqual(['urgent', 'work']); + }); +}); diff --git a/tests/mzta-utils.test.js b/tests/mzta-utils.test.js new file mode 100644 index 00000000..1baf1d06 --- /dev/null +++ b/tests/mzta-utils.test.js @@ -0,0 +1,697 @@ +import { + getLanguageDisplayName, + sanitizeHtml, + sanitizeMailHeaders, + stripHtmlKeepLines, + htmlBodyToPlainText, + cleanupNewlines, + convertNewlinesToBr, + convertNewlinesToParagraphs, + getGPTWebModelString, + getMenuContextCompose, + getMenuContextDisplay, + checkIfTagLabelExists, + normalizeStringList, + prepareOriginURL, + checkAPIIntegration, + hasSpecificIntegration, + extractJsonObject, + isAPIKeyValue, + validateChatGPTWebCustomData, + sanitizeChatGPTModelData, + sanitizeChatGPTWebCustomData, + getActiveSpecialPromptsIDs, + getConnectionType, + getMailBody, + generateCallID, + ChatGPTWeb_models, + fixMsgHeader, + extractEmail, + convertBrToNewlines, + compareThunderbirdVersions, + getTagsKeyFromLabel, + sanitizeString, + formatBytes, +} from '../js/mzta-utils.js'; + +// ─── Constants ─────────────────────────────────────────────────── + +describe('ChatGPTWeb_models', () => { + it('is an array of model strings', () => { + expect(Array.isArray(ChatGPTWeb_models)).toBe(true); + expect(ChatGPTWeb_models.length).toBeGreaterThan(0); + ChatGPTWeb_models.forEach(m => expect(typeof m).toBe('string')); + }); +}); + +describe('getMenuContextCompose', () => { + it('returns compose_action_menu', () => { + expect(getMenuContextCompose()).toBe('compose_action_menu'); + }); +}); + +describe('getMenuContextDisplay', () => { + it('returns message_display_action_menu', () => { + expect(getMenuContextDisplay()).toBe('message_display_action_menu'); + }); +}); + +// ─── String / HTML manipulation ────────────────────────────────── + +describe('sanitizeHtml', () => { + it('strips HTML tags but keeps
', () => { + expect(sanitizeHtml('

Hello


World')).toBe('Hello
World'); + }); + + it('keeps
and
variants', () => { + expect(sanitizeHtml('A
B
C')).toBe('A
B
C'); + }); + + it('passes through plain text unchanged', () => { + expect(sanitizeHtml('just text')).toBe('just text'); + }); + + it('handles empty string', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('strips nested tags', () => { + expect(sanitizeHtml('
Hi
')).toBe('Hi'); + }); +}); + +describe('sanitizeMailHeaders', () => { + it('escapes < and > to HTML entities', () => { + expect(sanitizeMailHeaders('user@example.com ')).toBe('user@example.com <User Name>'); + }); + + it('returns empty string for null/undefined/empty', () => { + expect(sanitizeMailHeaders(null)).toBe(''); + expect(sanitizeMailHeaders(undefined)).toBe(''); + expect(sanitizeMailHeaders('')).toBe(''); + }); + + it('handles strings without angle brackets', () => { + expect(sanitizeMailHeaders('plain text')).toBe('plain text'); + }); +}); + +describe('stripHtmlKeepLines', () => { + it('converts

to newlines and removes

', () => { + expect(stripHtmlKeepLines('

line1

line2

')).toBe('line1\nline2'); + }); + + it('converts
to newlines', () => { + expect(stripHtmlKeepLines('line1
line2')).toBe('line1\nline2'); + }); + + it('removes other HTML tags', () => { + expect(stripHtmlKeepLines('bold italic')).toBe('bold italic'); + }); + + it('trims result', () => { + expect(stripHtmlKeepLines('

text

')).toBe('text'); + }); +}); + +describe('htmlBodyToPlainText', () => { + it('extracts text from simple HTML', () => { + expect(htmlBodyToPlainText('

Hello World

')).toBe('Hello World'); + }); + + it('removes style elements', () => { + expect(htmlBodyToPlainText('

text

')).toBe('text'); + }); + + it('removes display:none elements', () => { + expect(htmlBodyToPlainText('
hidden

visible

')).toBe('visible'); + }); + + it('handles empty string', () => { + expect(htmlBodyToPlainText('')).toBe(''); + }); +}); + +describe('cleanupNewlines', () => { + it('normalizes \\r\\n to \\n', () => { + expect(cleanupNewlines('a\r\nb')).toBe('a\nb'); + }); + + it('collapses multiple newlines to one', () => { + expect(cleanupNewlines('a\n\n\nb')).toBe('a\nb'); + }); + + it('collapses spaces/tabs before newlines', () => { + expect(cleanupNewlines('a \nb')).toBe('a\nb'); + }); + + it('replaces   with space', () => { + expect(cleanupNewlines('hello world')).toBe('hello world'); + }); + + it('trims result', () => { + expect(cleanupNewlines(' text ')).toBe('text'); + }); +}); + +describe('convertNewlinesToBr', () => { + it('converts \\n to
', () => { + expect(convertNewlinesToBr('a\nb')).toBe('a
b'); + }); + + it('handles \\r\\n', () => { + expect(convertNewlinesToBr('a\r\nb')).toBe('a
b'); + }); + + it('handles multiple newlines', () => { + expect(convertNewlinesToBr('a\n\nb')).toBe('a

b'); + }); +}); + +describe('convertNewlinesToParagraphs', () => { + it('wraps each line in

tags', () => { + expect(convertNewlinesToParagraphs('line1\nline2')).toBe('

line1

line2

'); + }); + + it('handles single line', () => { + expect(convertNewlinesToParagraphs('single')).toBe('

single

'); + }); +}); + +describe('convertBrToNewlines', () => { + it('converts
to newlines', () => { + expect(convertBrToNewlines('a
b')).toBe('a\nb'); + }); + + it('handles
and
variants', () => { + expect(convertBrToNewlines('a
b
c')).toBe('a\nb\nc'); + }); + + it('is case-insensitive', () => { + expect(convertBrToNewlines('a
b
c')).toBe('a\nb\nc'); + }); +}); + +// ─── Model / URL helpers ───────────────────────────────────────── + +describe('getGPTWebModelString', () => { + it('maps gpt-5 to "5"', () => { + expect(getGPTWebModelString('gpt-5')).toBe('5'); + }); + + it('maps gpt-5-instant to "5 Fast"', () => { + expect(getGPTWebModelString('gpt-5-instant')).toBe('5 Fast'); + }); + + it('maps gpt-5-t-mini to "5 Thinking mini"', () => { + expect(getGPTWebModelString('gpt-5-t-mini')).toBe('5 Thinking mini'); + }); + + it('maps gpt-5-thinking to "5 Thinking"', () => { + expect(getGPTWebModelString('gpt-5-thinking')).toBe('5 Thinking'); + }); + + it('returns model string for unknown models', () => { + expect(getGPTWebModelString('custom-model')).toBe('custom-model'); + }); + + it('returns empty string for falsy input', () => { + expect(getGPTWebModelString(null)).toBe(''); + expect(getGPTWebModelString('')).toBe(''); + expect(getGPTWebModelString(undefined)).toBe(''); + }); + + it('is case-insensitive and trims whitespace', () => { + expect(getGPTWebModelString(' GPT-5 ')).toBe('5'); + }); +}); + +describe('prepareOriginURL', () => { + it('appends /* to URL without trailing slash', () => { + expect(prepareOriginURL('https://example.com')).toBe('https://example.com/*'); + }); + + it('appends * to URL with trailing slash', () => { + expect(prepareOriginURL('https://example.com/')).toBe('https://example.com/*'); + }); +}); + +// ─── Tag helpers ───────────────────────────────────────────────── + +describe('checkIfTagLabelExists', () => { + const tags = { + tag1: { tag: 'Important' }, + tag2: { tag: 'Work' }, + tag3: { tag: 'Personal' }, + }; + + it('finds tag case-insensitively', () => { + expect(checkIfTagLabelExists('important', tags)).toBe(true); + expect(checkIfTagLabelExists('IMPORTANT', tags)).toBe(true); + expect(checkIfTagLabelExists('Important', tags)).toBe(true); + }); + + it('returns false when tag not found', () => { + expect(checkIfTagLabelExists('missing', tags)).toBe(false); + }); + + it('handles empty tags list', () => { + expect(checkIfTagLabelExists('any', {})).toBe(false); + }); +}); + +describe('getTagsKeyFromLabel', () => { + const allTags = { + key1: { tag: 'Important' }, + key2: { tag: 'Work' }, + key3: { tag: 'Personal' }, + }; + + it('returns keys for matching labels', () => { + expect(getTagsKeyFromLabel(['important', 'work'], allTags)).toEqual(['key1', 'key2']); + }); + + it('returns empty array for no matches', () => { + expect(getTagsKeyFromLabel(['missing'], allTags)).toEqual([]); + }); + + it('handles empty input', () => { + expect(getTagsKeyFromLabel([], allTags)).toEqual([]); + }); +}); + +// ─── List / string utilities ───────────────────────────────────── + +describe('normalizeStringList', () => { + it('deduplicates and sorts comma-separated input', () => { + expect(normalizeStringList('b, a, c, a')).toBe('a, b, c'); + }); + + it('deduplicates and sorts newline-separated input', () => { + expect(normalizeStringList('b\na\nc\na')).toBe('a, b, c'); + }); + + it('returns comma-separated string by default (returnType=0)', () => { + expect(normalizeStringList('b, a', 0)).toBe('a, b'); + }); + + it('returns newline-separated string (returnType=1)', () => { + expect(normalizeStringList('b, a', 1)).toBe('a\nb'); + }); + + it('returns array (returnType=2)', () => { + expect(normalizeStringList('b, a', 2)).toEqual(['a', 'b']); + }); + + it('lowercases all items', () => { + expect(normalizeStringList('Apple, BANANA', 2)).toEqual(['apple', 'banana']); + }); +}); + +describe('generateCallID', () => { + it('returns a string of the specified length', () => { + expect(generateCallID(10)).toHaveLength(10); + expect(generateCallID(20)).toHaveLength(20); + }); + + it('defaults to length 10', () => { + expect(generateCallID()).toHaveLength(10); + }); + + it('contains only alphanumeric characters', () => { + const id = generateCallID(100); + expect(id).toMatch(/^[A-Za-z0-9]+$/); + }); +}); + +// ─── Boolean / logic helpers ───────────────────────────────────── + +describe('checkAPIIntegration', () => { + it('returns true for non-chatgpt_web connection types', () => { + expect(checkAPIIntegration('openai_api', false, null)).toBe(true); + expect(checkAPIIntegration('ollama', false, '')).toBe(true); + }); + + it('returns true for chatgpt_web with specific integration', () => { + expect(checkAPIIntegration('chatgpt_web', true, 'openai_api')).toBe(true); + }); + + it('returns false for chatgpt_web without specific integration', () => { + expect(checkAPIIntegration('chatgpt_web', false, null)).toBe(false); + expect(checkAPIIntegration('chatgpt_web', true, null)).toBe(false); + expect(checkAPIIntegration('chatgpt_web', true, '')).toBe(false); + }); +}); + +describe('hasSpecificIntegration', () => { + it('returns true when use is true and conntype is non-empty', () => { + expect(hasSpecificIntegration(true, 'openai_api')).toBe(true); + }); + + it('returns false when use is false', () => { + expect(hasSpecificIntegration(false, 'openai_api')).toBe(false); + }); + + it('returns false when conntype is null or empty', () => { + expect(hasSpecificIntegration(true, null)).toBe(false); + expect(hasSpecificIntegration(true, '')).toBe(false); + }); +}); + +describe('isAPIKeyValue', () => { + it('returns true for strings ending with _api_key', () => { + expect(isAPIKeyValue('openai_api_key')).toBe(true); + expect(isAPIKeyValue('chatgpt_api_key')).toBe(true); + }); + + it('returns false for other strings', () => { + expect(isAPIKeyValue('openai_model')).toBe(false); + expect(isAPIKeyValue('api_key_value')).toBe(false); + }); +}); + +// ─── JSON extraction ───────────────────────────────────────────── + +describe('extractJsonObject', () => { + it('extracts JSON from string with surrounding text', () => { + const result = extractJsonObject('Here is the result: {"tags": ["a", "b"]} done'); + expect(result).toEqual({ tags: ['a', 'b'] }); + }); + + it('returns parsed object for valid JSON string', () => { + expect(extractJsonObject('{"key": "value"}')).toEqual({ key: 'value' }); + }); + + it('extracts nested JSON', () => { + const result = extractJsonObject('text {"a": {"b": 1}} more'); + expect(result).toEqual({ a: { b: 1 } }); + }); + + it('throws on no JSON found', () => { + expect(() => extractJsonObject('no json here')).toThrow('No JSON object found'); + }); + + it('throws on invalid JSON', () => { + expect(() => extractJsonObject('{invalid json}')).toThrow(); + }); +}); + +// ─── ChatGPT validation / sanitization ─────────────────────────── + +describe('validateChatGPTWebCustomData', () => { + it('validates /g/ paths with alphanumeric chars', () => { + expect(validateChatGPTWebCustomData('/g/abc-123')).toBe(true); + expect(validateChatGPTWebCustomData('/g/my-gpt/test')).toBe(true); + }); + + it('rejects invalid paths', () => { + expect(validateChatGPTWebCustomData('invalid')).toBe(false); + expect(validateChatGPTWebCustomData('/g/')).toBe(false); + expect(validateChatGPTWebCustomData('/g/test space')).toBe(false); + }); + + it('accepts empty string', () => { + expect(validateChatGPTWebCustomData('')).toBe(true); + }); +}); + +describe('sanitizeChatGPTModelData', () => { + it('encodes and lowercases input', () => { + expect(sanitizeChatGPTModelData('GPT-5')).toBe('gpt-5'); + }); + + it('encodes special characters', () => { + expect(sanitizeChatGPTModelData('model name')).toBe('model%20name'); + }); + + it('returns empty string for falsy input', () => { + expect(sanitizeChatGPTModelData(null)).toBe(''); + expect(sanitizeChatGPTModelData('')).toBe(''); + expect(sanitizeChatGPTModelData(undefined)).toBe(''); + }); +}); + +describe('sanitizeChatGPTWebCustomData', () => { + it('removes non-alphanumeric/dash/slash characters', () => { + expect(sanitizeChatGPTWebCustomData('/g/test-path')).toBe('/g/test-path'); + expect(sanitizeChatGPTWebCustomData('/g/test path!')).toBe('/g/testpath'); + }); + + it('returns empty string for falsy input', () => { + expect(sanitizeChatGPTWebCustomData(null)).toBe(''); + expect(sanitizeChatGPTWebCustomData('')).toBe(''); + }); +}); + +// ─── Special prompts ───────────────────────────────────────────── + +describe('getActiveSpecialPromptsIDs', () => { + it('returns empty array with all false', () => { + expect(getActiveSpecialPromptsIDs({})).toEqual([]); + }); + + it('includes prompt_add_tags when addtags is true', () => { + const result = getActiveSpecialPromptsIDs({ addtags: true }); + expect(result).toContain('prompt_add_tags'); + }); + + it('includes calendar and task prompts when enabled', () => { + const result = getActiveSpecialPromptsIDs({ + get_calendar_event: true, + get_calendar_event_from_clipboard: true, + get_task: true, + }); + expect(result).toContain('prompt_get_calendar_event'); + expect(result).toContain('prompt_get_calendar_event_from_clipboard'); + expect(result).toContain('prompt_get_task'); + }); + + it('on chatgpt_web: only includes add_tags if both addtags and addtags_api', () => { + const result = getActiveSpecialPromptsIDs({ + is_chatgpt_web: true, + addtags: true, + addtags_api: true, + }); + expect(result).toEqual(['prompt_add_tags']); + }); + + it('on chatgpt_web: excludes add_tags if addtags_api is false', () => { + const result = getActiveSpecialPromptsIDs({ + is_chatgpt_web: true, + addtags: true, + addtags_api: false, + }); + expect(result).toEqual([]); + }); + + it('on chatgpt_web: excludes calendar/task prompts', () => { + const result = getActiveSpecialPromptsIDs({ + is_chatgpt_web: true, + get_calendar_event: true, + get_task: true, + }); + expect(result).toEqual([]); + }); +}); + +// ─── getConnectionType ─────────────────────────────────────────── + +describe('getConnectionType', () => { + it('returns default connection_type when no prefix', () => { + const prefs = { connection_type: 'openai_api' }; + expect(getConnectionType(prefs, null)).toBe('openai_api'); + }); + + it('returns prompt api_type when no specific type and prompt has api_type', () => { + const prefs = { connection_type: 'openai_api' }; + const prompt = { api_type: 'ollama_api' }; + expect(getConnectionType(prefs, prompt)).toBe('ollama_api'); + }); + + it('returns default when prefs is null', () => { + expect(getConnectionType(null, null)).toBe(''); + }); + + it('returns prompt api_type over default when prompt has api_type', () => { + const prefs = { connection_type: 'openai_api' }; + const prompt = { api_type: 'claude_api' }; + expect(getConnectionType(prefs, prompt)).toBe('claude_api'); + }); +}); + +// ─── getMailBody ───────────────────────────────────────────────── + +describe('getMailBody', () => { + it('extracts text and html from message parts', () => { + const msg = { + parts: [ + { contentType: 'text/plain', body: 'Hello plain' }, + { contentType: 'text/html', body: '

Hello html

' }, + ], + }; + expect(getMailBody(msg)).toEqual({ text: 'Hello plain', html: '

Hello html

' }); + }); + + it('converts text to html with
if no html part', () => { + const msg = { + parts: [{ contentType: 'text/plain', body: 'line1\nline2' }], + }; + expect(getMailBody(msg)).toEqual({ text: 'line1\nline2', html: 'line1
line2' }); + }); + + it('handles nested parts', () => { + const msg = { + parts: [ + { + contentType: 'multipart/alternative', + parts: [ + { contentType: 'text/plain', body: 'nested text' }, + { contentType: 'text/html', body: '

nested html

' }, + ], + }, + ], + }; + expect(getMailBody(msg)).toEqual({ text: 'nested text', html: '

nested html

' }); + }); + + it('returns empty strings when no parts', () => { + expect(getMailBody({ parts: [] })).toEqual({ text: '', html: '' }); + expect(getMailBody({})).toEqual({ text: '', html: '' }); + }); +}); + +// ─── getLanguageDisplayName ────────────────────────────────────── + +describe('getLanguageDisplayName', () => { + it('returns capitalized language name', () => { + const name = getLanguageDisplayName('en'); + expect(name).toBe('English'); + }); + + it('returns localized name for other languages', () => { + const name = getLanguageDisplayName('fr'); + expect(name.charAt(0)).toBe(name.charAt(0).toUpperCase()); + expect(name.length).toBeGreaterThan(0); + }); +}); + +// ─── fixMsgHeader ──────────────────────────────────────────────── + +describe('fixMsgHeader', () => { + it('adds missing bccList, ccList, and recipients', () => { + const header = {}; + const result = fixMsgHeader(header); + expect(result.bccList).toEqual([]); + expect(result.ccList).toEqual([]); + expect(result.recipients).toEqual([]); + }); + + it('preserves existing values', () => { + const header = { + bccList: ['a@b.com'], + ccList: ['c@d.com'], + recipients: ['e@f.com'], + }; + const result = fixMsgHeader(header); + expect(result.bccList).toEqual(['a@b.com']); + expect(result.ccList).toEqual(['c@d.com']); + expect(result.recipients).toEqual(['e@f.com']); + }); +}); + +// ─── extractEmail ──────────────────────────────────────────────── + +describe('extractEmail', () => { + it('extracts email from text', () => { + expect(extractEmail('John Doe ')).toBe('john@example.com'); + }); + + it('extracts first email when multiple present', () => { + expect(extractEmail('a@b.com and c@d.com')).toBe('a@b.com'); + }); + + it('returns empty string for empty/undefined input', () => { + expect(extractEmail('')).toBe(''); + expect(extractEmail(undefined)).toBe(''); + }); + + it('returns empty string when no email found', () => { + expect(extractEmail('no email here')).toBe(''); + }); +}); + +// ─── compareThunderbirdVersions ────────────────────────────────── + +describe('compareThunderbirdVersions', () => { + it('returns 0 for equal versions', () => { + expect(compareThunderbirdVersions('128.0', '128.0')).toBe(0); + }); + + it('returns 1 when first version is greater', () => { + expect(compareThunderbirdVersions('129.0', '128.0')).toBe(1); + }); + + it('returns -1 when first version is lesser', () => { + expect(compareThunderbirdVersions('127.0', '128.0')).toBe(-1); + }); + + it('handles different length version strings', () => { + expect(compareThunderbirdVersions('128.0.1', '128.0')).toBe(1); + expect(compareThunderbirdVersions('128', '128.0.0')).toBe(0); + }); + + it('compares multi-segment versions correctly', () => { + expect(compareThunderbirdVersions('128.1.2', '128.1.3')).toBe(-1); + }); +}); + +// ─── sanitizeString ────────────────────────────────────────────── + +describe('sanitizeString', () => { + it('lowercases and removes invalid characters', () => { + expect(sanitizeString('Hello World')).toBe('helloworld'); + }); + + it('removes special characters like < > * "', () => { + expect(sanitizeString('test<>*"val')).toBe('testval'); + }); + + it('encodes non-ASCII characters', () => { + const result = sanitizeString('café'); + expect(result).toBe('cafu00e9'); + }); + + it('truncates to 29 characters', () => { + const long = 'a'.repeat(50); + expect(sanitizeString(long)).toHaveLength(29); + }); + + it('handles empty string', () => { + expect(sanitizeString('')).toBe(''); + }); +}); + +// ─── formatBytes ───────────────────────────────────────────────── + +describe('formatBytes', () => { + it('returns "0 Bytes" for zero', () => { + expect(formatBytes(0)).toBe('0 Bytes'); + }); + + it('formats bytes correctly', () => { + expect(formatBytes(500)).toBe('500.00 Bytes'); + }); + + it('formats kilobytes correctly', () => { + expect(formatBytes(1024)).toBe('1.00 KB'); + }); + + it('formats megabytes correctly', () => { + expect(formatBytes(1048576)).toBe('1.00 MB'); + }); + + it('respects decimals parameter', () => { + expect(formatBytes(1500, 0)).toBe('1 KB'); + expect(formatBytes(1500, 1)).toBe('1.5 KB'); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 00000000..7f4f313b --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,82 @@ +import { vi } from 'vitest'; + +// Global mock for Thunderbird's browser.* WebExtension APIs +const browser = { + i18n: { + getMessage: vi.fn((key) => `[${key}]`), + getUILanguage: vi.fn(() => 'en-US'), + }, + accounts: { + list: vi.fn(async () => []), + }, + storage: { + sync: { + get: vi.fn(async (defaults) => { + if (typeof defaults === 'object' && defaults !== null) { + return { ...defaults }; + } + return {}; + }), + set: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + }, + local: { + get: vi.fn(async (defaults) => { + if (typeof defaults === 'object' && defaults !== null) { + return { ...defaults }; + } + return {}; + }), + set: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + }, + }, + messages: { + getFull: vi.fn(async () => ({ headers: {}, parts: [] })), + get: vi.fn(async () => ({ tags: [] })), + tags: { + list: vi.fn(async () => []), + create: vi.fn(async () => {}), + }, + update: vi.fn(async () => {}), + listAttachments: vi.fn(async () => []), + }, + tabs: { + query: vi.fn(async () => []), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + }, + compose: { + getComposeDetails: vi.fn(async () => ({ body: '', subject: '' })), + setComposeDetails: vi.fn(async () => {}), + }, + permissions: { + contains: vi.fn(async () => false), + request: vi.fn(async () => true), + }, + runtime: { + getURL: vi.fn((path) => `moz-extension://fake-id/${path}`), + sendMessage: vi.fn(async () => null), + }, + mailTabs: { + getSelectedMessages: vi.fn(async () => ({ messages: [{ subject: '' }] })), + }, +}; + +// Global mock for Thunderbird's messenger.* APIs +const messenger = { + messageDisplay: { + getDisplayedMessage: vi.fn(async () => ({ subject: '' })), + }, + compose: { + getComposeDetails: vi.fn(async () => ({ body: '' })), + setComposeDetails: vi.fn(async () => {}), + }, + messages: { + continueList: vi.fn(async () => ({ messages: [] })), + }, +}; + +// Attach to globalThis so module-level code can access them +globalThis.browser = browser; +globalThis.messenger = messenger; diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 00000000..edfbbe39 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./tests/setup.js'], + include: ['tests/**/*.test.js'], + globals: true, + }, + resolve: { + alias: { + // js/mzta-prompts.js imports from "../../options/mzta-options-default.js" + // which resolves outside the repo root in Node.js. This alias fixes it. + '../../options/mzta-options-default.js': path.resolve(__dirname, 'options/mzta-options-default.js'), + } + } +});