diff --git a/.gitignore b/.gitignore index 1e6d9ac4..8c59e1e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ build +coverage node_modules promotional_materials/font/berlin.zip scripts/.deps diff --git a/src/background/SpacingRulesHandler.ts b/src/background/SpacingRulesHandler.ts index 95882f21..96526925 100644 --- a/src/background/SpacingRulesHandler.ts +++ b/src/background/SpacingRulesHandler.ts @@ -96,7 +96,10 @@ export class SpacingRulesHandler { const text = `${insertSpaceBefore ? "\xA0" : ""}${lastChar}${ insertSpaceAfter ? "\xA0" : "" }`; - if (text === lastChar) { + if ( + text === lastChar && + SPACING_RULES[lastChar].spaceBefore !== Spacing.REMOVE_SPACE + ) { return null; } return { diff --git a/src/background/background.ts b/src/background/background.ts index 103aca79..d54de9d0 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -300,11 +300,9 @@ function onInstalled(details: chrome.runtime.InstalledDetails) { } else if (details.reason === "update") { const thisVersion = chrome.runtime.getManifest().version; console.log(`Updated from ${details.previousVersion} to ${thisVersion}!`); - try { - migrateToLocalStore(details.previousVersion); - } catch (error) { + migrateToLocalStore(details.previousVersion).catch((error) => { logError("migrateToLocalStore", error); - } + }); } } diff --git a/tests/Migration.test.ts b/tests/Migration.test.ts new file mode 100644 index 00000000..e8ff63e3 --- /dev/null +++ b/tests/Migration.test.ts @@ -0,0 +1,76 @@ +import { jest } from "@jest/globals"; + +const settingsGet = jest.fn<(key: string) => Promise>(); +const settingsSet = + jest.fn<(key: string, value: unknown) => Promise>(); +const settingsManagerCtor = jest.fn().mockImplementation(() => ({ + get: settingsGet, + set: settingsSet, +})); + +jest.unstable_mockModule("../src/shared/settingsManager", () => ({ + SettingsManager: settingsManagerCtor, +})); + +describe("migrateToLocalStore", () => { + let migrateToLocalStore: (lastVersion?: string) => Promise; + + beforeAll(async () => { + ({ migrateToLocalStore } = await import("../src/background/Migration")); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (globalThis as { chrome: unknown }).chrome = { + runtime: { + getManifest: jest.fn(() => ({ version: "2026.2.1" })), + }, + storage: { + sync: { + get: jest.fn((_: unknown, callback: (result: unknown) => void) => + callback({ key: "value" }), + ), + }, + local: { + set: jest.fn(), + }, + }, + }; + }); + + test("migrates sync storage to local storage for older versions", async () => { + settingsGet.mockResolvedValue("en"); + + await migrateToLocalStore("2023.01.01"); + + expect(global.chrome.storage.sync.get).toHaveBeenCalledWith( + null, + expect.any(Function), + ); + expect(global.chrome.storage.local.set).toHaveBeenCalledWith({ + key: "value", + }); + expect(global.chrome.storage.local.set).toHaveBeenCalledWith({ + lastVersion: "2026.2.1", + }); + }); + + test("updates language and fallbackLanguage to full supported keys", async () => { + settingsGet.mockResolvedValueOnce("en").mockResolvedValueOnce("fr"); + + await migrateToLocalStore("2024.01.01"); + + expect(settingsSet).toHaveBeenCalledWith("language", "en_US"); + expect(settingsSet).toHaveBeenCalledWith("fallbackLanguage", "fr_FR"); + }); + + test("skips sync migration and language rewrite for new versions", async () => { + await migrateToLocalStore("2026.03.01"); + + expect(global.chrome.storage.sync.get).not.toHaveBeenCalled(); + expect(settingsManagerCtor).not.toHaveBeenCalled(); + expect(global.chrome.storage.local.set).toHaveBeenCalledWith({ + lastVersion: "2026.2.1", + }); + }); +}); diff --git a/tests/PresageEngine.test.ts b/tests/PresageEngine.test.ts new file mode 100644 index 00000000..1d75d2d2 --- /dev/null +++ b/tests/PresageEngine.test.ts @@ -0,0 +1,69 @@ +import { jest } from "@jest/globals"; +import type { PresageModule } from "../src/background/PresageTypes"; +import { PresageEngine } from "../src/background/PresageEngine"; + +describe("PresageEngine", () => { + test("initializes native Presage with callback/path and suggestion config", () => { + const callbackImplement = jest.fn((callbackImpl) => callbackImpl); + const config = jest.fn(); + + const module = { + PresageCallback: { implement: callbackImplement }, + Presage: class { + constructor( + _callbackImpl: unknown, + public path: string, + ) {} + config = config; + predictWithProbability() { + return { + size: () => 0, + get: () => ({ prediction: "" }), + }; + } + }, + FS: { writeFile: jest.fn() }, + } as unknown as PresageModule; + + const engine = new PresageEngine(module, { numSuggestions: 3 }, "en_US"); + + expect(callbackImplement).toHaveBeenCalledTimes(1); + expect(config).toHaveBeenCalledWith("Presage.Selector.SUGGESTIONS", "3"); + + engine.setConfig({ numSuggestions: 7 }); + expect(config).toHaveBeenCalledWith("Presage.Selector.SUGGESTIONS", "7"); + }); + + test("predict parses JSON predictions and keeps plain string predictions", () => { + const nativePredictions = [ + { prediction: '"hello"' }, + { prediction: "world" }, + { prediction: "null" }, + ]; + const implement = jest.fn((callbackImpl) => callbackImpl); + + const module = { + PresageCallback: { + implement, + }, + Presage: class { + config = jest.fn(); + constructor() {} + predictWithProbability() { + return { + size: () => nativePredictions.length, + get: (index: number) => nativePredictions[index], + }; + } + }, + FS: { writeFile: jest.fn() }, + } as unknown as PresageModule; + + const engine = new PresageEngine(module, { numSuggestions: 3 }, "en_US"); + const predictions = engine.predict("input text"); + + const callbackArg = implement.mock.calls[0]?.[0] as { pastStream: string }; + expect(callbackArg.pastStream).toBe("input text"); + expect(predictions).toEqual(["hello", "world"]); + }); +}); diff --git a/tests/SpacingRulesHandler.test.ts b/tests/SpacingRulesHandler.test.ts new file mode 100644 index 00000000..c7ad2e42 --- /dev/null +++ b/tests/SpacingRulesHandler.test.ts @@ -0,0 +1,62 @@ +import { + SPACE_CHARS, + SPACING_RULES, + Spacing, + SpacingRulesHandler, +} from "../src/background/SpacingRulesHandler"; + +describe("SpacingRulesHandler", () => { + test("exposes spacing constants through static getters", () => { + expect(SpacingRulesHandler.Spacing).toBe(Spacing); + expect(SpacingRulesHandler.SPACING_RULES).toBe(SPACING_RULES); + expect(SpacingRulesHandler.SPACE_CHARS).toBe(SPACE_CHARS); + }); + + test("returns null when rules are disabled, input is empty, or input too short", () => { + const disabled = new SpacingRulesHandler(true, false); + const enabled = new SpacingRulesHandler(true, true); + + expect(disabled.applySpacingRules("a .")).toBeNull(); + expect(enabled.applySpacingRules("")).toBeNull(); + expect(enabled.applySpacingRules(".")).toBeNull(); + }); + + test("inserts non-breaking space before opening punctuation", () => { + const handler = new SpacingRulesHandler(true, true); + + expect(handler.applySpacingRules("a(")).toEqual({ + text: "\xA0(", + length: 1, + }); + }); + + test("removes preceding space and inserts trailing space for sentence punctuation", () => { + const handler = new SpacingRulesHandler(true, true); + + expect(handler.applySpacingRules("a .")).toEqual({ + text: ".\xA0", + length: 2, + }); + }); + + test("removes preceding space even when trailing insertion is disabled", () => { + const handler = new SpacingRulesHandler(false, true); + + expect(handler.applySpacingRules("a .")).toEqual({ + text: ".", + length: 2, + }); + }); + + test("does not change symbols configured with NO_CHANGE spacing", () => { + const handler = new SpacingRulesHandler(false, true); + + expect(handler.applySpacingRules("a -")).toBeNull(); + }); + + test("skips replacement when the previous-previous character is already a space", () => { + const handler = new SpacingRulesHandler(true, true); + + expect(handler.applySpacingRules("a .")).toBeNull(); + }); +}); diff --git a/tests/TemplateExpander.test.ts b/tests/TemplateExpander.test.ts new file mode 100644 index 00000000..6c53825b --- /dev/null +++ b/tests/TemplateExpander.test.ts @@ -0,0 +1,58 @@ +import { jest } from "@jest/globals"; +import { DATE_TIME_VARIABLES } from "../src/shared/variables"; +import { TemplateExpander } from "../src/background/TemplateExpander"; + +describe("TemplateExpander", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("parseStringTemplate replaces known placeholders", () => { + const result = TemplateExpander.parseStringTemplate("Hello ${name}!", { + name: "World", + }); + + expect(result).toBe("Hello World!"); + }); + + test("parseStringTemplate keeps missing placeholders and preserves empty values", () => { + const result = TemplateExpander.parseStringTemplate( + "${known}-${missing}-${empty}", + { + known: "ok", + empty: "", + }, + ); + + expect(result).toBe("ok-${missing}-"); + }); + + test("getExpandedVariables returns empty object when variable expansion is disabled", () => { + expect( + TemplateExpander.getExpandedVariables("en_US", false, "HH:mm", "yyyy"), + ).toEqual({}); + }); + + test("getExpandedVariables uses date/time providers with language and formats", () => { + const timeSpy = jest + .spyOn(DATE_TIME_VARIABLES, "time") + .mockReturnValue("10:30"); + const dateSpy = jest + .spyOn(DATE_TIME_VARIABLES, "date") + .mockReturnValue("2026-01-02"); + + const result = TemplateExpander.getExpandedVariables( + "fr_FR", + true, + "HH:mm", + "yyyy-MM-dd", + ); + + expect(timeSpy).toHaveBeenCalledWith("fr_FR", "HH:mm"); + expect(dateSpy).toHaveBeenCalledWith("fr_FR", "yyyy-MM-dd"); + expect(result).toEqual({ + time: "10:30", + date: "2026-01-02", + }); + }); +}); diff --git a/tests/TextExpansionManager.test.ts b/tests/TextExpansionManager.test.ts new file mode 100644 index 00000000..067cc5a4 --- /dev/null +++ b/tests/TextExpansionManager.test.ts @@ -0,0 +1,58 @@ +import { jest } from "@jest/globals"; +import type { PresageModule } from "../src/background/PresageTypes"; +import { TextExpansionManager } from "../src/background/TextExpansionManager"; + +describe("TextExpansionManager", () => { + test("writes lowercase expansions to file and updates all presage engines", () => { + const writeFile = jest.fn(); + const configEn = jest.fn(); + const configFr = jest.fn(); + + const module = { + FS: { writeFile }, + } as unknown as PresageModule; + + const manager = new TextExpansionManager(module, { + en_US: { libPresage: { config: configEn } }, + fr_FR: { libPresage: { config: configFr } }, + } as never); + + manager.setTextExpansions([ + ["BRB", { phrase: "be right back" }], + ["IDK", { phrase: "I don't know" }], + ]); + + expect(writeFile).toHaveBeenCalledWith( + "/textExpansions.txt", + 'brb\t{"phrase":"be right back"}\n' + 'idk\t{"phrase":"I don\'t know"}\n', + ); + expect(configEn).toHaveBeenCalledWith( + "Presage.Predictors.DefaultAbbreviationExpansionPredictor.ABBREVIATIONS", + "/textExpansions.txt", + ); + expect(configFr).toHaveBeenCalledWith( + "Presage.Predictors.DefaultAbbreviationExpansionPredictor.ABBREVIATIONS", + "/textExpansions.txt", + ); + }); + + test("handles empty expansion lists by writing an empty file", () => { + const writeFile = jest.fn(); + const config = jest.fn(); + const module = { + FS: { writeFile }, + } as unknown as PresageModule; + + const manager = new TextExpansionManager(module, { + en_US: { libPresage: { config } }, + } as never); + + manager.setTextExpansions([]); + + expect(writeFile).toHaveBeenCalledWith("/textExpansions.txt", ""); + expect(config).toHaveBeenCalledWith( + "Presage.Predictors.DefaultAbbreviationExpansionPredictor.ABBREVIATIONS", + "/textExpansions.txt", + ); + }); +}); diff --git a/tests/background.routing.test.ts b/tests/background.routing.test.ts new file mode 100644 index 00000000..c5d8fd73 --- /dev/null +++ b/tests/background.routing.test.ts @@ -0,0 +1,449 @@ +import { jest } from "@jest/globals"; +import { + CMD_BACKGROUND_PAGE_PREDICT_REQ, + CMD_BACKGROUND_PAGE_SET_CONFIG, + CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + CMD_CONTENT_SCRIPT_GET_CONFIG, + CMD_CONTENT_SCRIPT_PREDICT_REQ, + CMD_OPTIONS_PAGE_CONFIG_CHANGE, + CMD_TOGGLE_FT_ACTIVE_LANG, + CMD_TOGGLE_FT_ACTIVE_TAB, + CMD_TRIGGER_FT_ACTIVE_TAB, + KEY_LANGUAGE, +} from "../src/shared/constants"; + +function flushPromises() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function loadBackgroundHarness( + stateOverrides: Record = {}, +) { + jest.resetModules(); + jest.clearAllMocks(); + + const state: Record = { + language: "en_US", + enabled_languages: ["en_US", "fr_FR"], + enabled: true, + autocomplete: true, + autocompleteOnEnter: true, + autocompleteOnTab: true, + selectByDigit: true, + minWordLengthToPredict: 1, + revertOnBackspace: true, + displayLangHeader: true, + inline_suggestion: false, + tributeBgLight: "#fff", + tributeTextLight: "#111", + tributeHighlightBgLight: "#eee", + tributeHighlightTextLight: "#000", + tributeBorderLight: "#ccc", + tributeBgDark: "#111", + tributeTextDark: "#eee", + tributeHighlightBgDark: "#333", + tributeHighlightTextDark: "#fff", + tributeBorderDark: "#666", + tributeFontSize: "14px", + tributePaddingVertical: "8px", + tributePaddingHorizontal: "12px", + numSuggestions: 5, + insertSpaceAfterAutocomplete: true, + autoCapitalize: true, + applySpacingRules: true, + textExpansions: [], + variableExpansion: false, + timeFormat: "HH:mm", + dateFormat: "yyyy-MM-dd", + userDictionaryList: [], + ...stateOverrides, + }; + + const settingsGet = jest.fn(async (key: string) => state[key]); + const settingsSet = jest.fn(async (key: string, value: unknown) => { + state[key] = value; + }); + const languageDetect = jest.fn(async () => "fr_FR"); + const predictionRun = jest.fn(async () => ({ + predictions: ["hello"], + forceReplace: null, + })); + const predictionInitialize = jest.fn(async () => undefined); + const predictionSetConfig = jest.fn(); + const tabSendToAll = jest.fn(); + const tabSendToActive = jest.fn(); + const checkLastError = jest.fn(); + const getDomain = jest.fn(() => "example.com"); + const isEnabledForDomain = jest.fn(async () => true); + const logError = jest.fn(); + const migrateToLocalStore = jest.fn(async () => undefined); + + const onInstalledAddListener = jest.fn(); + const onCommandAddListener = jest.fn(); + const onMessageAddListener = jest.fn(); + const storageLocalGet = jest.fn(); + + const chromeMock = { + runtime: { + onInstalled: { addListener: onInstalledAddListener }, + onMessage: { addListener: onMessageAddListener }, + getManifest: jest.fn(() => ({ version: "2026.2.1" })), + }, + commands: { + onCommand: { addListener: onCommandAddListener }, + }, + tabs: { + create: jest.fn(), + get: jest.fn((tabId: number, callback: (tab: chrome.tabs.Tab) => void) => + callback({ id: tabId } as chrome.tabs.Tab), + ), + sendMessage: jest.fn(), + }, + storage: { + local: { + get: storageLocalGet, + set: jest.fn(), + }, + sync: { + get: jest.fn(), + set: jest.fn(), + }, + }, + }; + (globalThis as unknown as { chrome: unknown }).chrome = chromeMock; + + jest.unstable_mockModule("../src/shared/settingsManager", () => ({ + SettingsManager: jest.fn().mockImplementation(() => ({ + get: settingsGet, + set: settingsSet, + })), + })); + jest.unstable_mockModule("../src/background/LanguageDetector", () => ({ + LanguageDetector: jest.fn().mockImplementation(() => ({ + detectLanguage: languageDetect, + })), + })); + jest.unstable_mockModule("../src/background/PredictionManager", () => ({ + PredictionManager: jest.fn().mockImplementation(() => ({ + runPrediction: predictionRun, + initialize: predictionInitialize, + setConfig: predictionSetConfig, + })), + })); + jest.unstable_mockModule("../src/background/TabMessenger", () => ({ + TabMessenger: jest.fn().mockImplementation(() => ({ + sendToAllTabs: tabSendToAll, + sendToActiveTab: tabSendToActive, + })), + })); + jest.unstable_mockModule("../src/shared/utils", () => ({ + checkLastError, + getDomain, + isEnabledForDomain, + })); + jest.unstable_mockModule("../src/shared/error", () => ({ + logError, + })); + jest.unstable_mockModule("../src/background/Migration", () => ({ + migrateToLocalStore, + })); + + const module = await import("../src/background/background"); + + const onInstalled = onInstalledAddListener.mock.calls[0][0] as ( + details: chrome.runtime.InstalledDetails, + ) => void; + const onCommand = onCommandAddListener.mock.calls[0][0] as ( + command: string, + ) => void; + const onMessage = onMessageAddListener.mock.calls[0][0] as ( + request: { command: string; context?: Record }, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, + ) => boolean; + const startupHandler = storageLocalGet.mock.calls[0][1] as ( + result: Record, + ) => Promise; + + return { + module, + state, + settingsGet, + settingsSet, + languageDetect, + predictionInitialize, + predictionSetConfig, + tabSendToAll, + tabSendToActive, + checkLastError, + getDomain, + isEnabledForDomain, + logError, + migrateToLocalStore, + onInstalled, + onCommand, + onMessage, + startupHandler, + chromeMock: { tabs: chromeMock.tabs }, + }; +} + +describe("background routing and lifecycle", () => { + test("registers listeners and runs startup initialization pipeline", async () => { + const harness = await loadBackgroundHarness(); + + await harness.startupHandler({ lastVersion: "2025.12.0" }); + + expect(harness.migrateToLocalStore).toHaveBeenCalledWith("2025.12.0"); + expect(harness.predictionInitialize).toHaveBeenCalled(); + expect(harness.predictionSetConfig).toHaveBeenCalled(); + expect(harness.tabSendToAll).toHaveBeenCalled(); + }); + + test("startup logs failure when migration rejects", async () => { + const harness = await loadBackgroundHarness(); + harness.migrateToLocalStore.mockRejectedValueOnce(new Error("boom")); + + await harness.startupHandler({ lastVersion: "2025.12.0" }); + + expect(harness.logError).toHaveBeenCalledWith( + "lastVersion handler", + expect.any(Error), + ); + }); + + test("onInstalled handles install and update flows", async () => { + const harness = await loadBackgroundHarness(); + + harness.onInstalled({ + reason: "install", + } as chrome.runtime.InstalledDetails); + expect(harness.chromeMock.tabs.create).toHaveBeenCalledWith({ + url: "new_installation/index.html", + }); + + harness.onInstalled({ + reason: "update", + previousVersion: "2025.1.0", + } as chrome.runtime.InstalledDetails); + await flushPromises(); + expect(harness.migrateToLocalStore).toHaveBeenCalledWith("2025.1.0"); + + harness.migrateToLocalStore.mockRejectedValueOnce(new Error("update fail")); + harness.onInstalled({ + reason: "update", + previousVersion: "2025.1.1", + } as chrome.runtime.InstalledDetails); + await flushPromises(); + expect(harness.logError).toHaveBeenCalledWith( + "migrateToLocalStore", + expect.any(Error), + ); + }); + + test("onCommand toggles active tab, triggers active tab and rotates language", async () => { + const harness = await loadBackgroundHarness(); + + harness.onCommand(CMD_TOGGLE_FT_ACTIVE_TAB); + harness.onCommand(CMD_TRIGGER_FT_ACTIVE_TAB); + harness.onCommand(CMD_TOGGLE_FT_ACTIVE_LANG); + await flushPromises(); + + expect(harness.tabSendToActive).toHaveBeenCalledWith( + expect.objectContaining({ command: CMD_TOGGLE_FT_ACTIVE_TAB }), + ); + expect(harness.tabSendToActive).toHaveBeenCalledWith( + expect.objectContaining({ command: CMD_TRIGGER_FT_ACTIVE_TAB }), + ); + expect(harness.settingsSet).toHaveBeenCalledWith(KEY_LANGUAGE, "fr_FR"); + expect(harness.tabSendToActive).toHaveBeenCalledWith({ + command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + context: { lang: "fr_FR" }, + }); + }); + + test("onCommand logs unsupported command", async () => { + const harness = await loadBackgroundHarness(); + + harness.onCommand("CMD_UNKNOWN"); + + expect(harness.logError).toHaveBeenCalledWith( + "onCommand", + "Unknown command: CMD_UNKNOWN", + ); + }); + + test("onMessage handles content-script predict request with same language", async () => { + const harness = await loadBackgroundHarness(); + const runPredictionSpy = jest + .spyOn(harness.module.BackgroundServiceWorker.prototype, "runPrediction") + .mockResolvedValue(undefined); + + const result = harness.onMessage( + { + command: CMD_CONTENT_SCRIPT_PREDICT_REQ, + context: { + text: "hello", + nextChar: "", + lang: "en_US", + tributeId: 1, + requestId: 9, + }, + }, + { tab: { id: 321 } as chrome.tabs.Tab, frameId: 7 }, + jest.fn(), + ); + + await flushPromises(); + + expect(result).toBe(false); + expect(runPredictionSpy).toHaveBeenCalledWith({ + command: CMD_BACKGROUND_PAGE_PREDICT_REQ, + context: expect.objectContaining({ + text: "hello", + nextChar: "", + lang: "en_US", + tabId: 321, + frameId: 7, + }), + }); + }); + + test("onMessage requests language update when resolved language differs", async () => { + const harness = await loadBackgroundHarness(); + harness.state[KEY_LANGUAGE] = "en_US"; + + const sendToActiveSpy = jest.spyOn( + harness.module.BackgroundServiceWorker.prototype, + "sendCommandToActiveTabContentScript", + ); + + harness.onMessage( + { + command: CMD_CONTENT_SCRIPT_PREDICT_REQ, + context: { + text: "hello", + nextChar: "", + lang: "fr_FR", + tributeId: 1, + requestId: 1, + }, + }, + { tab: { id: 2 } as chrome.tabs.Tab, frameId: 0 }, + jest.fn(), + ); + await flushPromises(); + + expect(sendToActiveSpy).toHaveBeenCalledWith({ + command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + context: { lang: "en_US" }, + }); + }); + + test("onMessage auto-detect branch calls language detector with enabled languages", async () => { + const harness = await loadBackgroundHarness({ + language: "auto_detect", + enabled_languages: ["en_US", "fr_FR"], + }); + + harness.onMessage( + { + command: CMD_CONTENT_SCRIPT_PREDICT_REQ, + context: { + text: "bonjour", + nextChar: "", + lang: "auto_detect", + tributeId: 1, + requestId: 3, + }, + }, + { tab: { id: 111 } as chrome.tabs.Tab, frameId: 0 }, + jest.fn(), + ); + await flushPromises(); + + expect(harness.languageDetect).toHaveBeenCalledWith("bonjour", 111, [ + "en_US", + "fr_FR", + ]); + }); + + test("onMessage handles options page config change success and failure", async () => { + const harness = await loadBackgroundHarness(); + const sendResponse = jest.fn(); + + const ok = harness.onMessage( + { command: CMD_OPTIONS_PAGE_CONFIG_CHANGE, context: {} }, + {} as chrome.runtime.MessageSender, + sendResponse, + ); + await flushPromises(); + expect(ok).toBe(true); + expect(sendResponse).toHaveBeenCalledWith({ ok: true }); + + const updateSpy = jest + .spyOn( + harness.module.BackgroundServiceWorker.prototype, + "updatePresageConfig", + ) + .mockRejectedValueOnce(new Error("failed update")); + sendResponse.mockClear(); + + harness.onMessage( + { command: CMD_OPTIONS_PAGE_CONFIG_CHANGE, context: {} }, + {} as chrome.runtime.MessageSender, + sendResponse, + ); + await flushPromises(); + + expect(updateSpy).toHaveBeenCalled(); + expect(sendResponse).toHaveBeenCalledWith({ ok: false }); + expect(harness.logError).toHaveBeenCalledWith( + "handleOptionsPageConfigChange", + expect.any(Error), + ); + }); + + test("onMessage handles get config request and unsupported commands", async () => { + const harness = await loadBackgroundHarness(); + const sendResponse = jest.fn(); + const getConfigSpy = jest + .spyOn( + harness.module.BackgroundServiceWorker.prototype, + "getBackgroundPageSetConfigMsg", + ) + .mockResolvedValue({ + command: CMD_BACKGROUND_PAGE_SET_CONFIG, + context: { enabled: true, lang: "en_US" }, + } as never); + harness.isEnabledForDomain.mockResolvedValueOnce(false); + + const handled = harness.onMessage( + { command: CMD_CONTENT_SCRIPT_GET_CONFIG, context: {} }, + { tab: { url: "https://example.com" } as chrome.tabs.Tab }, + sendResponse, + ); + await flushPromises(); + + expect(handled).toBe(true); + expect(harness.getDomain).toHaveBeenCalledWith("https://example.com"); + expect(getConfigSpy).toHaveBeenCalled(); + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ enabled: false }), + }), + ); + + const unknown = harness.onMessage( + { command: "UNKNOWN", context: {} }, + {} as chrome.runtime.MessageSender, + jest.fn(), + ); + expect(unknown).toBe(false); + expect(harness.logError).toHaveBeenCalledWith( + "onMessage", + "Unknown command: UNKNOWN", + ); + expect(harness.checkLastError).toHaveBeenCalled(); + }); +}); diff --git a/tests/content_script.behavior.test.ts b/tests/content_script.behavior.test.ts new file mode 100644 index 00000000..066d05ae --- /dev/null +++ b/tests/content_script.behavior.test.ts @@ -0,0 +1,381 @@ +import { jest } from "@jest/globals"; +import { + CMD_BACKGROUND_PAGE_PREDICT_RESP, + CMD_BACKGROUND_PAGE_SET_CONFIG, + CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + CMD_CONTENT_SCRIPT_GET_CONFIG, + CMD_POPUP_PAGE_DISABLE, + CMD_POPUP_PAGE_ENABLE, + CMD_STATUS_COMMAND, + CMD_TOGGLE_FT_ACTIVE_TAB, + CMD_TRIGGER_FT_ACTIVE_TAB, +} from "../src/shared/constants"; + +type TributeLike = { + queryAndAttachHelper: jest.Mock; + detachAllHelpers: jest.Mock; + removeHelpersNotInDocument: jest.Mock; + updateLangConfig: jest.Mock; + triggerActiveTribute: jest.Mock; + fulfillPrediction: jest.Mock; + autocompleteSeparator?: RegExp; +}; + +type DomObserverLike = { + attach: jest.Mock; + disconnect: jest.Mock; + setNode: jest.Mock; + getNode: jest.Mock; +}; + +type LoadedContentScript = { + fluentTyper: { + enabled: boolean; + config: Record; + tributeManager: TributeLike | null; + domObserver: DomObserverLike; + handleGetPrediction: (context: Record) => void; + messageHandler: ( + message: { command: string; context: Record } | null, + sender?: chrome.runtime.MessageSender, + sendResponse?: (response: unknown) => void, + ) => void; + processMutations: (mutations: MutationRecord[]) => void; + watchDog: () => void; + checkHostName: () => boolean; + setConfig: (config: Record) => void; + getConfig: () => void; + enable: () => void; + disable: () => void; + restart: () => void; + }; + tributeInstances: TributeLike[]; + domObserverInstances: DomObserverLike[]; + checkLastError: jest.Mock; + sendMessage: jest.Mock; +}; + +function defaultConfig(overrides: Record = {}) { + return { + enabled: true, + autocomplete: true, + autocompleteOnEnter: true, + autocompleteOnTab: true, + lang: "en_US", + selectByDigit: true, + minWordLengthToPredict: 1, + revertOnBackspace: true, + displayLangHeader: true, + inline_suggestion: false, + themeConfig: undefined, + ...overrides, + }; +} + +async function loadContentScript(): Promise { + jest.resetModules(); + jest.clearAllMocks(); + + const tributeInstances: TributeLike[] = []; + const domObserverInstances: DomObserverLike[] = []; + const checkLastError = jest.fn(); + const sendMessage = jest.fn(); + + (globalThis as unknown as { chrome: unknown }).chrome = { + runtime: { + onMessage: { addListener: jest.fn() }, + sendMessage, + }, + }; + (window as Window & { FluentTyper?: unknown }).FluentTyper = undefined; + + jest.unstable_mockModule("../src/shared/utils", () => ({ + checkLastError, + isInDocument: (element: Element) => document.contains(element), + })); + jest.unstable_mockModule("../src/content-script/TributeManager", () => ({ + TributeManager: jest.fn().mockImplementation(() => { + const instance: TributeLike = { + queryAndAttachHelper: jest.fn(), + detachAllHelpers: jest.fn(), + removeHelpersNotInDocument: jest.fn(), + updateLangConfig: jest.fn(), + triggerActiveTribute: jest.fn(), + fulfillPrediction: jest.fn(), + }; + tributeInstances.push(instance); + return instance; + }), + })); + jest.unstable_mockModule("../src/content-script/DomObserver", () => ({ + DomObserver: jest.fn().mockImplementation((initialNode: unknown) => { + let currentNode = initialNode as Node; + const instance: DomObserverLike = { + attach: jest.fn(), + disconnect: jest.fn(), + setNode: jest.fn((nextNode: unknown) => { + currentNode = nextNode as Node; + }), + getNode: jest.fn(() => currentNode), + }; + domObserverInstances.push(instance); + return instance; + }), + })); + + await import("../src/content-script/content_script"); + const fluentTyper = ( + window as Window & { FluentTyper?: LoadedContentScript["fluentTyper"] } + ).FluentTyper!; + + return { + fluentTyper, + tributeInstances, + domObserverInstances, + checkLastError, + sendMessage, + }; +} + +describe("content_script behavior", () => { + test("enables and disables managers through state transitions", async () => { + const { fluentTyper, tributeInstances, domObserverInstances } = + await loadContentScript(); + const domObserver = domObserverInstances[0]; + + fluentTyper.enabled = true; + expect(tributeInstances).toHaveLength(1); + expect(tributeInstances[0].queryAndAttachHelper).toHaveBeenCalled(); + expect(domObserver.attach).toHaveBeenCalled(); + + fluentTyper.enabled = false; + expect(domObserver.disconnect).toHaveBeenCalled(); + expect(tributeInstances[0].detachAllHelpers).toHaveBeenCalled(); + }); + + test("handleGetPrediction sends request and matching response fulfills prediction", async () => { + const { fluentTyper, tributeInstances, sendMessage } = + await loadContentScript(); + + fluentTyper.enable(); + const tribute = tributeInstances[0]; + + fluentTyper.handleGetPrediction({ + text: "hel", + nextChar: "", + tributeId: 3, + requestId: 10, + }); + + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + command: "CMD_CONTENT_SCRIPT_PREDICT_REQ", + context: expect.objectContaining({ + tributeId: 3, + requestId: 10, + lang: "en_US", + }), + }), + ); + + fluentTyper.messageHandler({ + command: CMD_BACKGROUND_PAGE_PREDICT_RESP, + context: { tributeId: 3, requestId: 10, predictions: ["hello"] }, + }); + expect(tribute.fulfillPrediction).toHaveBeenCalledWith( + expect.objectContaining({ predictions: ["hello"] }), + ); + + tribute.fulfillPrediction.mockClear(); + fluentTyper.messageHandler({ + command: CMD_BACKGROUND_PAGE_PREDICT_RESP, + context: { tributeId: 3, requestId: 11, predictions: ["ignored"] }, + }); + expect(tribute.fulfillPrediction).not.toHaveBeenCalled(); + }); + + test("setConfig applies theme and restarts when already enabled", async () => { + const { fluentTyper } = await loadContentScript(); + const restartSpy = jest.spyOn(fluentTyper, "restart"); + + fluentTyper.enabled = true; + fluentTyper.setConfig( + defaultConfig({ + enabled: true, + themeConfig: { + tributeBgLight: "#ffffff", + tributeTextLight: "#000000", + tributeHighlightBgLight: "#dddddd", + tributeHighlightTextLight: "#111111", + tributeBorderLight: "#cccccc", + tributeBgDark: "#121212", + tributeTextDark: "#f4f4f4", + tributeHighlightBgDark: "#333333", + tributeHighlightTextDark: "#fafafa", + tributeBorderDark: "#555555", + tributeFontSize: "13px", + tributePaddingVertical: "6px", + tributePaddingHorizontal: "9px", + }, + }), + ); + + const style = document.getElementById("fluent-typer-theme-overrides"); + expect(style).not.toBeNull(); + expect(style!.textContent).toContain("--tribute-bg-light: #ffffff"); + expect(restartSpy).toHaveBeenCalled(); + }); + + test("messageHandler handles config/lang/toggle/trigger commands and status replies", async () => { + const { fluentTyper } = await loadContentScript(); + fluentTyper.enable(); + const statusResponses: unknown[] = []; + + fluentTyper.messageHandler( + { + command: CMD_BACKGROUND_PAGE_SET_CONFIG, + context: defaultConfig({ enabled: true }), + }, + undefined, + (response) => statusResponses.push(response), + ); + expect(statusResponses[0]).toEqual({ + command: CMD_STATUS_COMMAND, + context: { enabled: true }, + }); + const tribute = fluentTyper.tributeManager as TributeLike; + + fluentTyper.messageHandler( + { + command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + context: { lang: "fr_FR" }, + }, + undefined, + (response) => statusResponses.push(response), + ); + expect(tribute.updateLangConfig).toHaveBeenCalledWith("fr_FR"); + + fluentTyper.messageHandler( + { command: CMD_POPUP_PAGE_DISABLE, context: {} }, + undefined, + (response) => statusResponses.push(response), + ); + fluentTyper.messageHandler( + { command: CMD_POPUP_PAGE_ENABLE, context: {} }, + undefined, + (response) => statusResponses.push(response), + ); + fluentTyper.messageHandler( + { command: CMD_TOGGLE_FT_ACTIVE_TAB, context: {} }, + undefined, + (response) => statusResponses.push(response), + ); + fluentTyper.messageHandler( + { command: CMD_TRIGGER_FT_ACTIVE_TAB, context: {} }, + undefined, + (response) => statusResponses.push(response), + ); + + expect(tribute.triggerActiveTribute).toHaveBeenCalled(); + expect( + statusResponses.every( + (response) => + (response as { command?: string }).command === CMD_STATUS_COMMAND, + ), + ).toBe(true); + }); + + test("processMutations reattaches helpers for added and attribute-target elements", async () => { + const { fluentTyper, tributeInstances, domObserverInstances } = + await loadContentScript(); + fluentTyper.enable(); + const tribute = tributeInstances[0]; + const domObserver = domObserverInstances[0]; + + const addedElement = document.createElement("div"); + const attrTarget = document.createElement("span"); + document.body.appendChild(addedElement); + document.body.appendChild(attrTarget); + + fluentTyper.processMutations([ + { + type: "childList", + addedNodes: [addedElement] as unknown as NodeList, + target: document.body, + } as unknown as MutationRecord, + { + type: "attributes", + addedNodes: [] as unknown as NodeList, + target: attrTarget, + } as unknown as MutationRecord, + ]); + + expect(domObserver.disconnect).toHaveBeenCalled(); + expect(tribute.removeHelpersNotInDocument).toHaveBeenCalled(); + expect(tribute.queryAndAttachHelper).toHaveBeenCalledWith(addedElement); + expect(tribute.queryAndAttachHelper).toHaveBeenCalledWith(attrTarget); + expect(domObserver.attach).toHaveBeenCalled(); + }); + + test("watchdog checks host/domain changes and restarts on node replacement", async () => { + const { fluentTyper, domObserverInstances, sendMessage } = + await loadContentScript(); + const domObserver = domObserverInstances[0]; + const restartSpy = jest.spyOn(fluentTyper, "restart"); + const getConfigSpy = jest.spyOn(fluentTyper, "getConfig"); + + (fluentTyper as unknown as { hostName: string }).hostName = "example.com"; + expect(fluentTyper.checkHostName()).toBe(true); + expect(getConfigSpy).toHaveBeenCalled(); + + (fluentTyper as unknown as { hostName: string }).hostName = + window.location.hostname; + domObserver.getNode.mockReturnValue(document.createElement("div")); + fluentTyper.enabled = true; + fluentTyper.watchDog(); + + expect(restartSpy).toHaveBeenCalled(); + expect(domObserver.setNode).toHaveBeenCalledWith( + document.body || document.documentElement, + ); + expect(sendMessage).toHaveBeenCalled(); + }); + + test("getConfig requests config and passes callback response to messageHandler", async () => { + const { fluentTyper, sendMessage, checkLastError } = + await loadContentScript(); + const messageHandlerSpy = jest.spyOn(fluentTyper, "messageHandler"); + + sendMessage.mockImplementation((_message: unknown, callback?: unknown) => { + if (typeof callback === "function") { + callback({ + command: CMD_BACKGROUND_PAGE_SET_CONFIG, + context: defaultConfig({ enabled: false }), + }); + } + }); + + fluentTyper.getConfig(); + + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ command: CMD_CONTENT_SCRIPT_GET_CONFIG }), + expect.any(Function), + ); + expect(messageHandlerSpy).toHaveBeenCalledWith( + expect.objectContaining({ command: CMD_BACKGROUND_PAGE_SET_CONFIG }), + ); + expect(checkLastError).toHaveBeenCalled(); + }); + + test("messageHandler handles empty and unknown messages safely", async () => { + const { fluentTyper } = await loadContentScript(); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const traceSpy = jest.spyOn(console, "trace").mockImplementation(() => {}); + + fluentTyper.messageHandler(null); + fluentTyper.messageHandler({ command: "UNKNOWN_COMMAND", context: {} }); + + expect(errorSpy).toHaveBeenCalled(); + expect(traceSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/content_script.watchdog.test.ts b/tests/content_script.watchdog.test.ts index 6abc4464..37911548 100644 --- a/tests/content_script.watchdog.test.ts +++ b/tests/content_script.watchdog.test.ts @@ -31,22 +31,20 @@ async function loadContentScriptModule() { })); jest.unstable_mockModule("../src/content-script/DomObserver", () => ({ - DomObserver: jest - .fn() - .mockImplementation((initialNode: unknown) => { - const firstNode = initialNode as Node; - let currentNode: Node = firstNode; - const instance: DomObserverLike = { - attach: jest.fn(), - disconnect: jest.fn(), - setNode: jest.fn((nextNode: unknown) => { - currentNode = nextNode as Node; - }), - getNode: jest.fn(() => currentNode), - }; - domObserverInstances.push(instance); - return instance; - }), + DomObserver: jest.fn().mockImplementation((initialNode: unknown) => { + const firstNode = initialNode as Node; + let currentNode: Node = firstNode; + const instance: DomObserverLike = { + attach: jest.fn(), + disconnect: jest.fn(), + setNode: jest.fn((nextNode: unknown) => { + currentNode = nextNode as Node; + }), + getNode: jest.fn(() => currentNode), + }; + domObserverInstances.push(instance); + return instance; + }), })); await import("../src/content-script/content_script"); diff --git a/tests/e2e/puppeteer-extension.test.ts b/tests/e2e/puppeteer-extension.test.ts index 992e2bb1..5a77f974 100644 --- a/tests/e2e/puppeteer-extension.test.ts +++ b/tests/e2e/puppeteer-extension.test.ts @@ -42,29 +42,30 @@ async function setSetting( await worker.evaluate( (storageKeyInner, valueInner) => new Promise((resolve, reject) => { - const storage = - ( - globalThis as typeof globalThis & { - chrome?: typeof chrome; - } - ).chrome?.storage?.local; + const storage = ( + globalThis as typeof globalThis & { + chrome?: typeof chrome; + } + ).chrome?.storage?.local; if (!storage) { reject(new Error("chrome.storage.local is unavailable")); return; } - storage.set({ [storageKeyInner]: JSON.stringify(valueInner) }, () => { - const runtime = - ( + storage.set( + { [storageKeyInner]: JSON.stringify(valueInner) }, + () => { + const runtime = ( globalThis as typeof globalThis & { chrome?: typeof chrome; } ).chrome?.runtime; - if (runtime?.lastError) { - reject(new Error(runtime.lastError.message)); - return; - } - resolve(); - }); + if (runtime?.lastError) { + reject(new Error(runtime.lastError.message)); + return; + } + resolve(); + }, + ); }), storageKey, value, @@ -92,23 +93,21 @@ async function getSetting( return (await worker.evaluate( (storageKeyInner) => new Promise((resolve, reject) => { - const storage = - ( - globalThis as typeof globalThis & { - chrome?: typeof chrome; - } - ).chrome?.storage?.local; + const storage = ( + globalThis as typeof globalThis & { + chrome?: typeof chrome; + } + ).chrome?.storage?.local; if (!storage) { reject(new Error("chrome.storage.local is unavailable")); return; } storage.get(storageKeyInner, (result) => { - const runtime = - ( - globalThis as typeof globalThis & { - chrome?: typeof chrome; - } - ).chrome?.runtime; + const runtime = ( + globalThis as typeof globalThis & { + chrome?: typeof chrome; + } + ).chrome?.runtime; if (runtime?.lastError) { reject(new Error(runtime.lastError.message)); return; @@ -158,7 +157,9 @@ async function notifyConfigChange( const extensionId = worker.url().split("/")[2]; const extensionPage = await browser.newPage(); try { - await extensionPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await extensionPage.goto( + `chrome-extension://${extensionId}/popup/popup.html`, + ); await extensionPage.evaluate( () => new Promise((resolve, reject) => { @@ -223,11 +224,11 @@ async function waitForInputReady(page: Page, selector: string) { __testCkEditorError?: string | null; } ).__testCkEditorReady || - ( - window as typeof window & { - __testCkEditorError?: string | null; - } - ).__testCkEditorError, + ( + window as typeof window & { + __testCkEditorError?: string | null; + } + ).__testCkEditorError, ), { timeout: 10000 }, ); @@ -399,7 +400,9 @@ describe("Chrome Extension E2E Test", () => { domainTestServer = createServer((_req, res) => { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end("

domain test page

"); + res.end( + "

domain test page

", + ); }); await new Promise((resolve, reject) => { domainTestServer.once("error", reject); @@ -815,9 +818,9 @@ describe("Chrome Extension E2E Test", () => { await textarea!.click(); await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); await textarea!.type("φιλο"); await new Promise((r) => setTimeout(r, 50)); @@ -880,9 +883,9 @@ describe("Chrome Extension E2E Test", () => { // Ensure textarea is focused and clear await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); await textarea!.type(testData.input); // Wait for predictions to update after typing @@ -913,9 +916,9 @@ describe("Chrome Extension E2E Test", () => { // Cleanup for next iteration await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); // Wait for predictions to disappear await new Promise((r) => setTimeout(r, 50)); @@ -936,53 +939,53 @@ describe("Chrome Extension E2E Test", () => { expected: string; popupExpected: string; }[] = [ - { - locale: "en_US", - expected: "Extension UI Language", - popupExpected: "Advanced Options", - }, - { - locale: "fr_FR", - expected: "Langue de l'interface", - popupExpected: "Options avancées", - }, - { - locale: "hr_HR", - expected: "Jezik su\u010Delja pro\u0161irenja", - popupExpected: "Napredne opcije", - }, - { - locale: "es_ES", - expected: "Idioma de la interfaz", - popupExpected: "Opciones avanzadas", - }, - { - locale: "el_GR", - expected: - "\u0393\u03BB\u03CE\u03C3\u03C3\u03B1 \u03B4\u03B9\u03B5\u03C0\u03B1\u03C6\u03AE\u03C2 \u03B5\u03C0\u03AD\u03BA\u03C4\u03B1\u03C3\u03B7\u03C2", - popupExpected: "Επιλογές για προχωρημένους", - }, - { - locale: "sv_SE", - expected: "Till\u00E4ggets gr\u00E4nssnittsspr\u00E5k", - popupExpected: "Avancerade alternativ", - }, - { - locale: "de_DE", - expected: "Sprache der Erweiterungsoberfl\u00E4che", - popupExpected: "Erweiterte Optionen", - }, - { - locale: "pl_PL", - expected: "J\u0119zyk interfejsu rozszerzenia", - popupExpected: "Zaawansowane opcje", - }, - { - locale: "pt_BR", - expected: "Idioma da interface da extens\u00E3o", - popupExpected: "Opções avançadas", - }, - ]; + { + locale: "en_US", + expected: "Extension UI Language", + popupExpected: "Advanced Options", + }, + { + locale: "fr_FR", + expected: "Langue de l'interface", + popupExpected: "Options avancées", + }, + { + locale: "hr_HR", + expected: "Jezik su\u010Delja pro\u0161irenja", + popupExpected: "Napredne opcije", + }, + { + locale: "es_ES", + expected: "Idioma de la interfaz", + popupExpected: "Opciones avanzadas", + }, + { + locale: "el_GR", + expected: + "\u0393\u03BB\u03CE\u03C3\u03C3\u03B1 \u03B4\u03B9\u03B5\u03C0\u03B1\u03C6\u03AE\u03C2 \u03B5\u03C0\u03AD\u03BA\u03C4\u03B1\u03C3\u03B7\u03C2", + popupExpected: "Επιλογές για προχωρημένους", + }, + { + locale: "sv_SE", + expected: "Till\u00E4ggets gr\u00E4nssnittsspr\u00E5k", + popupExpected: "Avancerade alternativ", + }, + { + locale: "de_DE", + expected: "Sprache der Erweiterungsoberfl\u00E4che", + popupExpected: "Erweiterte Optionen", + }, + { + locale: "pl_PL", + expected: "J\u0119zyk interfejsu rozszerzenia", + popupExpected: "Zaawansowane opcje", + }, + { + locale: "pt_BR", + expected: "Idioma da interface da extens\u00E3o", + popupExpected: "Opções avançadas", + }, + ]; for (const { locale, expected, popupExpected } of TEST_LANGS) { // 1. Set the extension language in chrome.storage.local diff --git a/tests/error.test.ts b/tests/error.test.ts new file mode 100644 index 00000000..4fd6c80d --- /dev/null +++ b/tests/error.test.ts @@ -0,0 +1,39 @@ +import { jest } from "@jest/globals"; +import { getErrorMessage, logError } from "../src/shared/error"; + +describe("shared error helpers", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("returns message from Error instances", () => { + expect(getErrorMessage(new Error("boom"))).toBe("boom"); + }); + + test("stringifies plain objects", () => { + expect(getErrorMessage({ code: 42, reason: "invalid" })).toBe( + '{"code":42,"reason":"invalid"}', + ); + }); + + test("falls back to String() when JSON stringify fails", () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + + expect(getErrorMessage(circular)).toBe("[object Object]"); + }); + + test("logError writes context and forwards original error", () => { + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + const error = new Error("network"); + + logError("SyncJob", error); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[SyncJob] Error: network", + error, + ); + }); +}); diff --git a/tests/utils.additional.test.ts b/tests/utils.additional.test.ts new file mode 100644 index 00000000..80926bea --- /dev/null +++ b/tests/utils.additional.test.ts @@ -0,0 +1,151 @@ +import { jest } from "@jest/globals"; +import { + SETTINGS_DOMAIN_BLACKLIST, + blockUnBlockDomain, + checkLastError, + countDigits, + debounce, + getDomain, + isEnabledForDomain, + isLetter, + isNumber, + isWhiteSpace, +} from "../src/shared/utils"; +import type { SettingsManager } from "../src/shared/settingsManager"; + +function createSettings(state: Record) { + const settings = { + get: jest.fn(async (key: string) => state[key]), + set: jest.fn(async (key: string, value: unknown) => { + state[key] = value; + }), + }; + return settings as unknown as SettingsManager; +} + +describe("shared utils additional coverage", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("getDomain extracts hostname and returns undefined for invalid input", () => { + expect(getDomain("https://example.com/path")).toBe("example.com"); + expect(getDomain("[" as unknown as string)).toBeUndefined(); + }); + + test("isEnabledForDomain applies blacklist and whitelist rules", async () => { + const blackListState = { + enable: true, + domainListMode: "blackList", + [SETTINGS_DOMAIN_BLACKLIST]: ["blocked.example"], + }; + const whiteListState = { + enable: true, + domainListMode: "whiteList", + [SETTINGS_DOMAIN_BLACKLIST]: ["allowed.example"], + }; + + await expect( + isEnabledForDomain( + createSettings(blackListState), + "https://blocked.example", + ), + ).resolves.toBe(false); + await expect( + isEnabledForDomain( + createSettings(blackListState), + "https://other.example", + ), + ).resolves.toBe(true); + await expect( + isEnabledForDomain( + createSettings(whiteListState), + "https://allowed.example", + ), + ).resolves.toBe(true); + await expect( + isEnabledForDomain( + createSettings(whiteListState), + "https://other.example", + ), + ).resolves.toBe(false); + }); + + test("blockUnBlockDomain delegates to add/remove based on mode and action", async () => { + const blackListState = { + domainListMode: "blackList", + [SETTINGS_DOMAIN_BLACKLIST]: ["remove.example"], + }; + const whiteListState = { + domainListMode: "whiteList", + [SETTINGS_DOMAIN_BLACKLIST]: ["remove.example"], + }; + + const blackListSettings = createSettings(blackListState); + await blockUnBlockDomain(blackListSettings, "add.example", true); + await blockUnBlockDomain(blackListSettings, "remove.example", false); + expect(blackListState[SETTINGS_DOMAIN_BLACKLIST]).toEqual(["add.example"]); + + const whiteListSettings = createSettings(whiteListState); + await blockUnBlockDomain(whiteListSettings, "remove.example", true); + await blockUnBlockDomain(whiteListSettings, "add.example", false); + expect(whiteListState[SETTINGS_DOMAIN_BLACKLIST]).toEqual(["add.example"]); + }); + + test("checkLastError logs runtime message and handles missing runtime safely", () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + (globalThis as { chrome: unknown }).chrome = { + runtime: { lastError: { message: "boom" } }, + }; + checkLastError(); + expect(logSpy).toHaveBeenCalledWith("Runtime error:", "boom"); + + (globalThis as { chrome: unknown }).chrome = {}; + checkLastError(); + expect(errorSpy).toHaveBeenCalled(); + }); + + test("debounce supports leading+trailing and trailing-only modes", () => { + jest.useFakeTimers(); + const calls: string[] = []; + + const leadingAndTrailing = debounce( + (value: string) => calls.push(`lt:${value}`), + 10, + { leading: true, trailing: true }, + ); + leadingAndTrailing("a"); + leadingAndTrailing("b"); + expect(calls).toEqual(["lt:a"]); + jest.advanceTimersByTime(10); + expect(calls).toEqual(["lt:a", "lt:b"]); + + const trailingOnly = debounce( + (value: string) => calls.push(`t:${value}`), + 10, + { + leading: false, + trailing: true, + }, + ); + trailingOnly("c"); + expect(calls).toEqual(["lt:a", "lt:b"]); + jest.advanceTimersByTime(10); + expect(calls).toEqual(["lt:a", "lt:b", "t:c"]); + + jest.useRealTimers(); + }); + + test("character helpers correctly classify input", () => { + expect(isWhiteSpace("\n")).toBe(true); + expect(isWhiteSpace("\n", false)).toBe(false); + expect(isLetter("Ż")).toBe(true); + expect(isLetter("1")).toBe(false); + expect(countDigits("ab12c3")).toBe(3); + expect(isNumber("4.2")).toBe(true); + expect(isNumber("a1b2")).toBe(true); + expect(isNumber("abc")).toBe(false); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 468a9a43..485e46eb 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -32,9 +32,9 @@ describe("shared utils domain list handling", () => { test("isDomainOnList matches exact normalized host and not regex-like false positives", async () => { const { settings } = createSettingsManager(["example.com"]); - await expect(isDomainOnList(settings, "https://EXAMPLE.com/path")).resolves.toBe( - true, - ); + await expect( + isDomainOnList(settings, "https://EXAMPLE.com/path"), + ).resolves.toBe(true); await expect(isDomainOnList(settings, "exampleXcom")).resolves.toBe(false); }); diff --git a/tests/variables.test.ts b/tests/variables.test.ts new file mode 100644 index 00000000..12af7af1 --- /dev/null +++ b/tests/variables.test.ts @@ -0,0 +1,52 @@ +import { jest } from "@jest/globals"; +import { DateTime, Settings } from "luxon"; +import { DATE_TIME_VARIABLES } from "../src/shared/variables"; + +describe("shared date/time variables", () => { + const fixedNow = DateTime.utc(2026, 1, 2, 3, 4, 5); + const defaultLocale = Settings.defaultLocale; + + beforeEach(() => { + jest + .spyOn(DateTime, "now") + .mockImplementation(() => fixedNow as DateTime); + Settings.defaultLocale = defaultLocale; + }); + + afterEach(() => { + Settings.defaultLocale = defaultLocale; + jest.restoreAllMocks(); + }); + + test("formats time and date with custom formats", () => { + expect(DATE_TIME_VARIABLES.time("en_US", "HH:mm")).toBe("03:04"); + expect(DATE_TIME_VARIABLES.date("en_US", "yyyy-MM-dd")).toBe("2026-01-02"); + }); + + test("normalizes underscores in locale tags before applying locale", () => { + const setLocaleSpy = jest.spyOn(DateTime.prototype, "setLocale"); + + DATE_TIME_VARIABLES.time("en_US"); + + expect(setLocaleSpy).toHaveBeenCalledWith("en-US"); + }); + + test("uses default locale for auto-detect and text expander pseudo-languages", () => { + Settings.defaultLocale = "pl_PL"; + const setLocaleSpy = jest.spyOn(DateTime.prototype, "setLocale"); + + DATE_TIME_VARIABLES.date("auto_detect"); + DATE_TIME_VARIABLES.time("textExpander"); + + expect(setLocaleSpy).toHaveBeenCalledWith("pl-PL"); + }); + + test("warns and falls back when language input cannot be normalized", () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + const time = DATE_TIME_VARIABLES.time(null as unknown as string, "HH:mm"); + + expect(time).toBe("03:04"); + expect(warnSpy).toHaveBeenCalled(); + }); +});