diff --git a/src/content-script/content_script.ts b/src/content-script/content_script.ts index f7312ad6..965a82ec 100644 --- a/src/content-script/content_script.ts +++ b/src/content-script/content_script.ts @@ -38,6 +38,7 @@ declare global { class FluentTyper { // Logging prefix for all logs in this module private static readonly LOG_PREFIX = "ContentScript"; + private static readonly WATCHDOG_DEBOUNCE_MS = 250; private readonly SELECTORS: string = "textarea, input, [contentEditable]"; public tributeManager: TributeManager | null = null; @@ -58,6 +59,9 @@ class FluentTyper { }; public domObserver: DomObserver; private hostName: string = window.location.hostname; + private watchDogTimeoutId: number | null = null; + private rootNodeObserver: MutationObserver | null = null; + private readonly scheduleWatchDogCheckBound: () => void; constructor() { console.info( @@ -66,16 +70,57 @@ class FluentTyper { this.constructor.name, window.location.hostname, ); + this.scheduleWatchDogCheckBound = this.scheduleWatchDogCheck.bind(this); this.domObserver = new DomObserver( document.body || document.documentElement, this.mutationCallback.bind(this), ); + this.attachRootNodeObserver(); + this.attachWatchDogEventListeners(); chrome.runtime.onMessage.addListener(this.messageHandler.bind(this)); this.getConfig(); - setInterval(this.watchDog.bind(this), 1000); - window.navigation?.addEventListener("navigate", () => { - this.checkHostName(); + this.scheduleWatchDogCheck(); + } + + private attachRootNodeObserver(): void { + if (this.rootNodeObserver) { + return; + } + this.rootNodeObserver = new MutationObserver(() => { + this.scheduleWatchDogCheck(); }); + this.rootNodeObserver.observe(document.documentElement, { + childList: true, + }); + } + + private attachWatchDogEventListeners(): void { + window.navigation?.addEventListener( + "navigate", + this.scheduleWatchDogCheckBound, + ); + window.addEventListener("pageshow", this.scheduleWatchDogCheckBound); + window.addEventListener("popstate", this.scheduleWatchDogCheckBound); + window.addEventListener("hashchange", this.scheduleWatchDogCheckBound); + window.addEventListener("focus", this.scheduleWatchDogCheckBound, true); + document.addEventListener( + "visibilitychange", + this.scheduleWatchDogCheckBound, + ); + document.addEventListener( + "readystatechange", + this.scheduleWatchDogCheckBound, + ); + } + + private scheduleWatchDogCheck(): void { + if (this.watchDogTimeoutId !== null) { + window.clearTimeout(this.watchDogTimeoutId); + } + this.watchDogTimeoutId = window.setTimeout(() => { + this.watchDogTimeoutId = null; + this.watchDog(); + }, FluentTyper.WATCHDOG_DEBOUNCE_MS); } checkHostName(): boolean { diff --git a/tests/content_script.watchdog.test.ts b/tests/content_script.watchdog.test.ts new file mode 100644 index 00000000..6abc4464 --- /dev/null +++ b/tests/content_script.watchdog.test.ts @@ -0,0 +1,121 @@ +import { jest } from "@jest/globals"; + +type DomObserverLike = { + attach: (...args: unknown[]) => void; + disconnect: (...args: unknown[]) => void; + setNode: (...args: unknown[]) => void; + getNode: (...args: unknown[]) => unknown; +}; + +const mockChrome = { + runtime: { + onMessage: { addListener: jest.fn() }, + sendMessage: jest.fn(), + }, +}; + +const domObserverInstances: DomObserverLike[] = []; + +async function loadContentScriptModule() { + domObserverInstances.length = 0; + + jest.unstable_mockModule("../src/content-script/TributeManager", () => ({ + TributeManager: jest.fn().mockImplementation(() => ({ + queryAndAttachHelper: jest.fn(), + detachAllHelpers: jest.fn(), + removeHelpersNotInDocument: jest.fn(), + updateLangConfig: jest.fn(), + triggerActiveTribute: jest.fn(), + fulfillPrediction: jest.fn(), + })), + })); + + 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; + }), + })); + + await import("../src/content-script/content_script"); +} + +describe("content_script watchdog scheduling", () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + jest.useFakeTimers(); + (global as unknown as { chrome: unknown }).chrome = mockChrome; + (window as Window & { FluentTyper?: unknown }).FluentTyper = undefined; + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + test("does not start a 1-second polling interval", async () => { + const setIntervalSpy = jest.spyOn(global, "setInterval"); + + await loadContentScriptModule(); + + expect(setIntervalSpy).not.toHaveBeenCalled(); + }); + + test("debounces watchdog checks when multiple lifecycle events fire", async () => { + await loadContentScriptModule(); + const instance = ( + window as Window & { FluentTyper?: { watchDog: () => void } } + ).FluentTyper; + expect(instance).toBeDefined(); + + const watchDogSpy = jest.spyOn(instance!, "watchDog"); + const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); + + // Consume initial startup scheduling. + jest.advanceTimersByTime(250); + watchDogSpy.mockClear(); + + window.dispatchEvent(new Event("pageshow")); + window.dispatchEvent(new Event("popstate")); + window.dispatchEvent(new Event("hashchange")); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + jest.advanceTimersByTime(249); + expect(watchDogSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(watchDogSpy).toHaveBeenCalledTimes(1); + }); + + test("runs watchdog after document visibility change", async () => { + await loadContentScriptModule(); + const instance = ( + window as Window & { FluentTyper?: { watchDog: () => void } } + ).FluentTyper; + expect(instance).toBeDefined(); + + const watchDogSpy = jest.spyOn(instance!, "watchDog"); + + // Consume initial startup scheduling. + jest.advanceTimersByTime(250); + watchDogSpy.mockClear(); + + document.dispatchEvent(new Event("visibilitychange")); + jest.advanceTimersByTime(250); + + expect(watchDogSpy).toHaveBeenCalledTimes(1); + }); +});