Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions src/content-script/content_script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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 {
Expand Down
121 changes: 121 additions & 0 deletions tests/content_script.watchdog.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading