feat(browser-extension): add background script tests

This commit is contained in:
wanhose 2024-11-20 12:58:44 +01:00
parent edf5eda7dc
commit 49ea170ec5
26 changed files with 1156 additions and 135 deletions

View File

@ -9,15 +9,16 @@ const tsconfig = require('./tsconfig.json');
*/
const config = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
moduleNameMapper: pathsToModuleNameMapper(tsconfig.compilerOptions.paths, {
prefix: '<rootDir>/',
}),
moduleNameMapper: {
...pathsToModuleNameMapper(tsconfig.compilerOptions.paths, { prefix: '<rootDir>/' }),
'^url:~assets/(.+).png$': '<rootDir>/mocks/assets/$1.mock.ts',
},
preset: 'ts-jest/presets/default-esm',
setupFiles: ['jest-webextension-mock'],
setupFiles: ['./jest.setup.ts'],
testEnvironment: 'jsdom',
testRegex: ['^.+\\.test.tsx?$'],
transform: {
'^.+.tsx?$': ['ts-jest', { isolatedModules: true }],
'^.+.tsx?$': ['ts-jest', { isolatedModules: true, useESM: true }],
},
};

View File

@ -0,0 +1,4 @@
import 'jest-webextension-mock';
import './mocks/chrome.mock';
import './mocks/utils/domain.mock';
import './mocks/utils/storage.mock';

View File

@ -0,0 +1 @@
export default 'off-icon';

View File

@ -0,0 +1 @@
export default 'on-icon';

View File

@ -0,0 +1 @@
export default 'warn-icon';

View File

@ -0,0 +1,32 @@
import { jest } from '@jest/globals';
chrome.action = {
...chrome.action,
openPopup: jest.fn(),
setBadgeBackgroundColor: jest.fn(),
setBadgeText: jest.fn(),
setIcon: jest.fn(),
} as typeof chrome.action;
chrome.contextMenus = {
...chrome.contextMenus,
create: jest.fn(),
removeAll: jest.fn(),
} as typeof chrome.contextMenus;
chrome.declarativeNetRequest = {
...chrome.declarativeNetRequest,
updateSessionRules: jest.fn(),
} as typeof chrome.declarativeNetRequest;
chrome.runtime = {
...chrome.runtime,
getManifest: jest.fn(
() =>
({
content_scripts: [{ matches: ['https://example.com/*'] }],
version: '1.0.0',
}) as chrome.runtime.ManifestV3
),
openOptionsPage: jest.fn(),
} as typeof chrome.runtime;

View File

@ -0,0 +1,6 @@
import { jest } from '@jest/globals';
jest.mock('~utils/domain', () => ({
formatDomainFromURL: jest.fn(() => 'example.com'),
validateSupport: jest.fn(),
}));

View File

@ -0,0 +1,9 @@
import { jest } from '@jest/globals';
jest.mock('~utils/storage', () => ({
storage: {
get: async (key: string) => (await chrome.storage.local.get(key))?.[key],
remove: (key: string) => chrome.storage.local.remove(key),
set: (key: string, value: any) => chrome.storage.local.set({ [key]: value }),
},
}));

View File

@ -1,129 +1,11 @@
import { sendToContentScript } from '@plasmohq/messaging';
import onClicked from './utils/contextMenus/onClicked';
import onInstalled from './utils/runtime/onInstalled';
import onStartup from './utils/runtime/onStartup';
import onBeforeRequest from './utils/webRequest/onBeforeRequest';
import onErrorOccurred from './utils/webRequest/onErrorOccurred';
import {
DEFAULT_DOMAIN_CONFIG,
DEFAULT_EXTENSION_DATA,
EXTENSION_MENU_ITEM_ID,
REPORT_MENU_ITEM_ID,
SETTINGS_MENU_ITEM_ID,
} from '~utils/constants';
import { formatDomainFromURL, validateSupport } from '~utils/domain';
import { noop, suppressLastError } from '~utils/error';
import { storage } from '~utils/storage';
import type { DomainConfig, ExtensionData } from '~utils/types';
import databaseRefreshHandler from './messages/database/refresh';
import extensionUpdateAvailableHandler from './messages/extension/updateAvailable';
chrome.contextMenus.onClicked.addListener((info) => {
switch (info.menuItemId) {
case REPORT_MENU_ITEM_ID:
chrome.action.openPopup();
break;
case SETTINGS_MENU_ITEM_ID:
chrome.runtime.openOptionsPage();
break;
default:
break;
}
});
chrome.runtime.onInstalled.addListener(async () => {
chrome.contextMenus.removeAll(() => {
const documentUrlPatterns = chrome.runtime.getManifest().content_scripts?.[0].matches;
chrome.contextMenus.create(
{
contexts: ['all'],
documentUrlPatterns,
id: EXTENSION_MENU_ITEM_ID,
title: 'Cookie Dialog Monster',
},
suppressLastError
);
chrome.contextMenus.create(
{
contexts: ['all'],
documentUrlPatterns,
id: SETTINGS_MENU_ITEM_ID,
parentId: EXTENSION_MENU_ITEM_ID,
title: chrome.i18n.getMessage('contextMenu_settingsOption'),
},
suppressLastError
);
chrome.contextMenus.create(
{
contexts: ['all'],
documentUrlPatterns,
id: REPORT_MENU_ITEM_ID,
parentId: EXTENSION_MENU_ITEM_ID,
title: chrome.i18n.getMessage('contextMenu_reportOption'),
},
suppressLastError
);
});
await storage.remove('updateAvailable');
await databaseRefreshHandler({ name: 'database/refresh' }, { send: noop });
});
chrome.runtime.onStartup.addListener(async () => {
await storage.remove('updateAvailable');
await databaseRefreshHandler({ name: 'database/refresh' }, { send: noop });
await extensionUpdateAvailableHandler({ name: 'extension/updateAvailable' }, { send: noop });
});
/**
* @description Listen to the moment before a request is made to apply the rules
* @returns {Promise<void>}
*/
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
const { tabId, type, url } = details;
const location = new URL(url);
const domain = formatDomainFromURL(location);
if (tabId > -1 && type === 'main_frame') {
storage.get<ExtensionData>('data').then(({ exclusions, rules } = DEFAULT_EXTENSION_DATA) => {
if (!validateSupport(location.hostname, exclusions.domains)) {
return;
}
storage.get<DomainConfig>(domain).then((config = DEFAULT_DOMAIN_CONFIG) => {
if (rules.length) {
const rulesWithTabId = rules.map((rule) => ({
...rule,
condition: { ...rule.condition, tabIds: [tabId] },
}));
chrome.declarativeNetRequest.updateSessionRules({
addRules: config.on ? rulesWithTabId : undefined,
removeRuleIds: rules.map((rule) => rule.id),
});
}
});
});
}
},
{ urls: ['<all_urls>'] }
);
/**
* @description Listen for errors on network requests
*/
chrome.webRequest.onErrorOccurred.addListener(
async (details) => {
const { error, tabId } = details;
if (error === 'net::ERR_BLOCKED_BY_CLIENT' && tabId > -1) {
await sendToContentScript({
body: {
value: error,
},
name: 'INCREASE_LOG_COUNT',
tabId,
});
}
},
{ urls: ['<all_urls>'] }
);
chrome.contextMenus.onClicked.addListener(onClicked);
chrome.runtime.onInstalled.addListener(onInstalled);
chrome.runtime.onStartup.addListener(onStartup);
chrome.webRequest.onBeforeRequest.addListener(onBeforeRequest as any, { urls: ['<all_urls>'] });
chrome.webRequest.onErrorOccurred.addListener(onErrorOccurred, { urls: ['<all_urls>'] });

View File

@ -0,0 +1,15 @@
import { REPORT_MENU_ITEM_ID, SETTINGS_MENU_ITEM_ID } from '~utils/constants';
import { suppressLastError } from '~utils/error';
export default function onClicked(data: chrome.contextMenus.OnClickData) {
switch (data.menuItemId) {
case REPORT_MENU_ITEM_ID:
chrome.action.openPopup(suppressLastError);
break;
case SETTINGS_MENU_ITEM_ID:
chrome.runtime.openOptionsPage(suppressLastError);
break;
default:
break;
}
}

View File

@ -0,0 +1,38 @@
import databaseRefreshHandler from '~background/messages/database/refresh';
import {
EXTENSION_MENU_ITEM_ID,
REPORT_MENU_ITEM_ID,
SETTINGS_MENU_ITEM_ID,
} from '~utils/constants';
import { noop } from '~utils/error';
import { storage } from '~utils/storage';
export default async function onInstalled() {
await chrome.contextMenus.removeAll();
const documentUrlPatterns = chrome.runtime.getManifest().content_scripts?.[0].matches;
await chrome.contextMenus.create({
contexts: ['all'],
documentUrlPatterns,
id: EXTENSION_MENU_ITEM_ID,
title: 'Cookie Dialog Monster',
});
await chrome.contextMenus.create({
contexts: ['all'],
documentUrlPatterns,
id: SETTINGS_MENU_ITEM_ID,
parentId: EXTENSION_MENU_ITEM_ID,
title: chrome.i18n.getMessage('contextMenu_settingsOption'),
});
await chrome.contextMenus.create({
contexts: ['all'],
documentUrlPatterns,
id: REPORT_MENU_ITEM_ID,
parentId: EXTENSION_MENU_ITEM_ID,
title: chrome.i18n.getMessage('contextMenu_reportOption'),
});
await storage.remove('updateAvailable');
await databaseRefreshHandler({ name: 'database/refresh' }, { send: noop });
}

View File

@ -0,0 +1,10 @@
import databaseRefreshHandler from '~background/messages/database/refresh';
import extensionUpdateAvailableHandler from '~background/messages/extension/updateAvailable';
import { noop } from '~utils/error';
import { storage } from '~utils/storage';
export default async function onStartup() {
await storage.remove('updateAvailable');
await databaseRefreshHandler({ name: 'database/refresh' }, { send: noop });
await extensionUpdateAvailableHandler({ name: 'extension/updateAvailable' }, { send: noop });
}

View File

@ -0,0 +1,32 @@
import { DEFAULT_DOMAIN_CONFIG, DEFAULT_EXTENSION_DATA } from '~utils/constants';
import { formatDomainFromURL, validateSupport } from '~utils/domain';
import { storage } from '~utils/storage';
import type { DomainConfig, ExtensionData } from '~utils/types';
export default async function onBeforeRequest(details: chrome.webRequest.WebRequestBodyDetails) {
const { tabId, type, url } = details;
const location = new URL(url);
const domain = formatDomainFromURL(location);
if (tabId > -1 && type === 'main_frame') {
const data = (await storage.get<ExtensionData>('data')) || DEFAULT_EXTENSION_DATA;
if (!validateSupport(location.hostname, data.exclusions.domains)) {
return;
}
const config = (await storage.get<DomainConfig>(domain)) || DEFAULT_DOMAIN_CONFIG;
if (data.rules.length) {
const rulesWithTabId = data.rules.map((rule) => ({
...rule,
condition: { ...rule.condition, tabIds: [tabId] },
}));
chrome.declarativeNetRequest.updateSessionRules({
addRules: config.on ? rulesWithTabId : undefined,
removeRuleIds: data.rules.map((rule) => rule.id),
});
}
}
}

View File

@ -0,0 +1,15 @@
import { sendToContentScript } from '@plasmohq/messaging';
export default async function onErrorOccurred(details: chrome.webRequest.WebResponseErrorDetails) {
const { error, tabId } = details;
if (error === 'net::ERR_BLOCKED_BY_CLIENT' && tabId > -1) {
await sendToContentScript({
body: {
value: error,
},
name: 'INCREASE_LOG_COUNT',
tabId,
});
}
}

View File

@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/database/get';
import { DEFAULT_EXTENSION_DATA } from '~utils/constants';
import type { ExtensionData } from '~utils/types';
describe('background/messages/database/get.ts', () => {
const req = { name: 'database/get' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should return default data if no data is in storage', async () => {
await handler(req, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('data');
expect(res.send).toHaveBeenCalledWith({ data: DEFAULT_EXTENSION_DATA, success: true });
});
it('should return stored data if it exists in storage', async () => {
const data: ExtensionData = {
actions: [
{
domain: 'jestjs.io',
name: 'click',
selector: '#jest',
},
],
exclusions: {
domains: ['*.jestjs.io'],
overflows: ['*.jestjs.io'],
tags: ['JEST'],
},
keywords: ['jest'],
rules: [
{
action: {
type: 'block' as chrome.declarativeNetRequest.RuleActionType,
},
condition: {
resourceTypes: [
'font' as chrome.declarativeNetRequest.ResourceType,
'image' as chrome.declarativeNetRequest.ResourceType,
'media' as chrome.declarativeNetRequest.ResourceType,
'object' as chrome.declarativeNetRequest.ResourceType,
'script' as chrome.declarativeNetRequest.ResourceType,
'stylesheet' as chrome.declarativeNetRequest.ResourceType,
'xmlhttprequest' as chrome.declarativeNetRequest.ResourceType,
],
urlFilter: '||jestjs.io^',
},
id: 1,
priority: 1,
},
],
tokens: {
backdrops: ['#backdrop'],
classes: ['jest'],
containers: ['#container'],
selectors: ['#element'],
},
version: '0.0.0',
};
await chrome.storage.local.set({ data });
await handler(req, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('data');
expect(res.send).toHaveBeenCalledWith({ data, success: true });
});
});

View File

@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/database/refresh';
import { API_URL } from '~utils/constants';
import type { ExtensionData } from '~utils/types';
describe('background/messages/database/refresh.ts', () => {
const req = { name: 'database/refresh' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should fetch data from the API, store it, and return the data when successful', async () => {
const data: ExtensionData = {
actions: [
{
domain: 'jestjs.io',
name: 'click',
selector: '#jest',
},
],
exclusions: {
domains: ['*.jestjs.io'],
overflows: ['*.jestjs.io'],
tags: ['JEST'],
},
keywords: ['jest'],
rules: [
{
action: {
type: 'block' as chrome.declarativeNetRequest.RuleActionType,
},
condition: {
resourceTypes: [
'font' as chrome.declarativeNetRequest.ResourceType,
'image' as chrome.declarativeNetRequest.ResourceType,
'media' as chrome.declarativeNetRequest.ResourceType,
'object' as chrome.declarativeNetRequest.ResourceType,
'script' as chrome.declarativeNetRequest.ResourceType,
'stylesheet' as chrome.declarativeNetRequest.ResourceType,
'xmlhttprequest' as chrome.declarativeNetRequest.ResourceType,
],
urlFilter: '||jestjs.io^',
},
id: 1,
priority: 1,
},
],
tokens: {
backdrops: ['#backdrop'],
classes: ['jest'],
containers: ['#container'],
selectors: ['#element'],
},
version: '0.0.0',
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data, success: true }),
status: 200,
})
);
await handler(req, res);
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/data/`);
expect(chrome.storage.local.set).toHaveBeenCalledWith({ data });
expect(res.send).toHaveBeenCalledWith({ data, success: true });
});
it('should return success: false if the API call fails', async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ success: false }),
status: 200,
})
);
await handler(req, res);
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/data/`);
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(res.send).toHaveBeenCalledWith({ success: false });
});
});

View File

@ -0,0 +1,176 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/domain/config';
import { API_URL } from '~utils/constants';
import type { DomainConfig } from '~utils/types';
describe('background/messages/domain/config.ts', () => {
const body = { domain: 'example.com' };
const req = { name: 'domain/config' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should return success: false when domain is missing in request', async () => {
await handler(req, res);
expect(res.send).toHaveBeenCalledWith({ success: false });
});
it('should return cached data when issue is still valid', async () => {
const config: DomainConfig = {
issue: {
expiresAt: Date.now() + 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
data: {
flags: config.issue?.flags,
url: config.issue?.url,
},
success: true,
}),
status: 200,
})
);
await chrome.storage.local.set({ 'example.com': config });
await handler({ ...req, body }, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('example.com');
expect(res.send).toHaveBeenCalledWith({ data: config, success: true });
expect(global.fetch).not.toHaveBeenCalled();
});
it('should fetch new issue data if issue is expired', async () => {
const previous: DomainConfig = {
issue: {
expiresAt: Date.now() - 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
const next: DomainConfig = {
issue: {
expiresAt: Date.now() + 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
data: {
flags: previous.issue?.flags,
url: previous.issue?.url,
},
success: true,
}),
status: 200,
})
);
await chrome.storage.local.set({ 'example.com': previous });
await handler({ ...req, body }, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('example.com');
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/issues/example.com/`);
expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'example.com': next });
expect(res.send).toHaveBeenCalledWith({ data: next, success: true });
});
it('should handle rate-limiting gracefully (HTTP 429)', async () => {
const previous: DomainConfig = {
issue: {
expiresAt: Date.now() - 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
const next: DomainConfig = {
issue: {
expiresAt: Date.now() + 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
errors: [],
success: false,
}),
status: 429,
})
);
await chrome.storage.local.set({ 'example.com': previous });
await handler({ ...req, body }, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('example.com');
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/issues/example.com/`);
expect(chrome.storage.local.set).not.toHaveBeenCalledWith({ 'example.com': next });
expect(res.send).toHaveBeenCalledWith({ data: previous, success: true });
});
it('should fallback to a 24-hour expiration if API returns non-success response', async () => {
const previous: DomainConfig = {
issue: {
expiresAt: Date.now() - 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
const next: DomainConfig = {
issue: {
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
on: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
errors: [],
success: false,
}),
status: 200,
})
);
await chrome.storage.local.set({ 'example.com': previous });
await handler({ ...req, body }, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('example.com');
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/issues/example.com/`);
expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'example.com': next });
expect(res.send).toHaveBeenCalledWith({ data: next, success: true });
});
});

View File

@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/extension/updateAvailable';
import { API_URL } from '~utils/constants';
describe('background/messages/extension/updateAvailable.ts', () => {
const req = { name: 'extension/updateAvailable' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should set updateAvailable if next version differs from current version', async () => {
// const current = '1.0.0';
const next = '2.0.0';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
data: next,
success: true,
}),
status: 200,
})
);
await handler(req, res);
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/version/`);
expect(chrome.storage.local.set).toHaveBeenCalledWith({ updateAvailable: next });
expect(res.send).toHaveBeenCalledWith({ success: true });
});
it('should remove updateAvailable if next version matches current version', async () => {
// const current = '1.0.0';
const next = '1.0.0';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
data: next,
success: true,
}),
status: 200,
})
);
await handler(req, res);
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/version/`);
expect(chrome.storage.local.remove).toHaveBeenCalledWith('updateAvailable');
expect(res.send).toHaveBeenCalledWith({ success: false });
});
it('should return success: false if server responds with HTTP 429', async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
errors: [],
success: false,
}),
status: 429,
})
);
await handler(req, res);
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/version/`);
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
expect(res.send).toHaveBeenCalledWith({ success: false });
});
it('should return success: false if an error occurs during fetch', async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() => Promise.resolve(new Error()));
await handler(req, res);
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/version/`);
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
expect(res.send).toHaveBeenCalledWith({ success: false });
});
});

View File

@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/extension/updateBadge';
describe('background/messages/extension/updateBadge.ts', () => {
const req = { name: 'extension/updateBadge' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should set badge text and color when frameId is 0 and tabId exists', async () => {
const body = { value: 50 };
const sender = { frameId: 0, tab: { id: 123 } } as chrome.runtime.MessageSender;
await handler({ ...req, body, sender }, res);
expect(chrome.action.setBadgeBackgroundColor).toHaveBeenCalledWith({ color: '#6b7280' });
expect(chrome.action.setBadgeText).toHaveBeenCalledWith({ tabId: 123, text: '50' });
expect(res.send).toHaveBeenCalledWith({ success: true });
});
it('should clear badge text if value is zero', async () => {
const body = { value: 0 };
const sender = { frameId: 0, tab: { id: 456 } } as chrome.runtime.MessageSender;
await handler({ ...req, body, sender }, res);
expect(chrome.action.setBadgeBackgroundColor).toHaveBeenCalledWith({ color: '#6b7280' });
expect(chrome.action.setBadgeText).toHaveBeenCalledWith({ tabId: 456, text: '' });
expect(res.send).toHaveBeenCalledWith({ success: true });
});
it('should return success: false if frameId is not 0', async () => {
const body = { value: 50 };
const sender = { frameId: 1, tab: { id: 123 } } as chrome.runtime.MessageSender;
await handler({ ...req, body, sender }, res);
expect(chrome.action.setBadgeBackgroundColor).not.toHaveBeenCalledWith();
expect(chrome.action.setBadgeText).not.toHaveBeenCalledWith();
expect(res.send).toHaveBeenCalledWith({ success: false });
});
it('should return success: false if tabId is undefined', async () => {
const body = { value: 50 };
const sender = { frameId: 1, tab: undefined } as chrome.runtime.MessageSender;
await handler({ ...req, body, sender }, res);
expect(chrome.action.setBadgeBackgroundColor).not.toHaveBeenCalledWith();
expect(chrome.action.setBadgeText).not.toHaveBeenCalledWith();
expect(res.send).toHaveBeenCalledWith({ success: false });
});
});

View File

@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/extension/updateIcon';
import { validateSupport } from '~utils/domain';
describe('background/messages/extension/updateIcon.ts', () => {
const req = { name: 'extension/updateIcon' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
(validateSupport as jest.Mock).mockReturnValue(true);
});
it('should set warnIcon when config is on, tab.url is valid, and issue URL exists', async () => {
const body = { domain: 'example.com' };
const sender = {
frameId: 0,
tab: {
id: 123,
url: 'https://example.com/page',
},
} as chrome.runtime.MessageSender;
await chrome.storage.local.set({
'example.com': {
on: true,
issue: {
expiresAt: Date.now() + 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://example.com/issue',
},
},
});
await handler({ ...req, body, sender }, res);
expect(chrome.action.setIcon).toHaveBeenCalledWith({ path: 'warn-icon', tabId: 123 });
expect(res.send).toHaveBeenCalledWith({ success: true });
});
it('should set onIcon when config is on, tab.url is valid, and no issue URL exists', async () => {
const body = { domain: 'example.com' };
const sender = {
frameId: 0,
tab: {
id: 123,
url: 'https://example.com/page',
},
} as chrome.runtime.MessageSender;
await chrome.storage.local.set({
'example.com': {
on: true,
issue: {
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
},
});
await handler({ ...req, body, sender }, res);
expect(chrome.action.setIcon).toHaveBeenCalledWith({ path: 'on-icon', tabId: 123 });
expect(res.send).toHaveBeenCalledWith({ success: true });
});
it('should set offIcon when config is off or tab.url is not valid', async () => {
const body = { domain: 'example.com' };
const sender = {
frameId: 0,
tab: {
id: 123,
url: 'https://example.com/page',
},
} as chrome.runtime.MessageSender;
await chrome.storage.local.set({
'example.com': {
on: false,
issue: {
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
},
});
await handler({ ...req, body, sender }, res);
expect(chrome.action.setIcon).toHaveBeenCalledWith({ path: 'off-icon', tabId: 123 });
expect(res.send).toHaveBeenCalledWith({ success: true });
});
it('should set offIcon when config is on but validateSupport is false', async () => {
const body = { domain: 'example.com' };
const sender = {
frameId: 0,
tab: {
id: 123,
url: 'https://example.com/page',
},
} as chrome.runtime.MessageSender;
(validateSupport as jest.Mock).mockReturnValue(false);
await chrome.storage.local.set({
'example.com': {
on: true,
issue: {
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
},
});
await handler({ ...req, body, sender }, res);
expect(chrome.action.setIcon).toHaveBeenCalledWith({ path: 'off-icon', tabId: 123 });
expect(res.send).toHaveBeenCalledWith({ success: true });
});
it('should return success: false when frameId is not 0', async () => {
const body = { domain: 'example.com' };
const sender = {
frameId: 1,
tab: {
id: 123,
url: 'https://example.com/page',
},
} as chrome.runtime.MessageSender;
await chrome.storage.local.set({
'example.com': {
on: false,
issue: {
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
},
});
await handler({ ...req, body, sender }, res);
expect(chrome.action.setIcon).not.toHaveBeenCalled();
expect(res.send).toHaveBeenCalledWith({ success: false });
});
it('should return success: false when tab.id is undefined', async () => {
const body = { domain: 'example.com' };
const sender = { frameId: 0, tab: undefined } as chrome.runtime.MessageSender;
await chrome.storage.local.set({
'example.com': {
on: false,
issue: {
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
},
});
await handler({ ...req, body, sender }, res);
expect(chrome.action.setIcon).not.toHaveBeenCalled();
expect(res.send).toHaveBeenCalledWith({ success: false });
});
});

View File

@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import onClicked from '~background/utils/contextMenus/onClicked';
import { REPORT_MENU_ITEM_ID, SETTINGS_MENU_ITEM_ID } from '~utils/constants';
describe('background/utils/runtime/onClicked.ts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call chrome.action.openPopup when REPORT_MENU_ITEM_ID is clicked', () => {
const data = { menuItemId: REPORT_MENU_ITEM_ID } as chrome.contextMenus.OnClickData;
onClicked(data);
expect(chrome.runtime.openOptionsPage).not.toHaveBeenCalled();
expect(chrome.action.openPopup).toHaveBeenCalled();
});
it('should call chrome.runtime.openOptionsPage when SETTINGS_MENU_ITEM_ID is clicked', () => {
const data = { menuItemId: SETTINGS_MENU_ITEM_ID } as chrome.contextMenus.OnClickData;
onClicked(data);
expect(chrome.runtime.openOptionsPage).toHaveBeenCalled();
expect(chrome.action.openPopup).not.toHaveBeenCalled();
});
it('should do nothing when an unrecognized menu item ID is clicked', () => {
const data = { menuItemId: 'UNKNOWN_MENU_ITEM_ID' } as chrome.contextMenus.OnClickData;
onClicked(data);
expect(chrome.runtime.openOptionsPage).not.toHaveBeenCalled();
expect(chrome.action.openPopup).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import databaseRefreshHandler from '~background/messages/database/refresh';
import onInstalled from '~background/utils/runtime/onInstalled';
import {
EXTENSION_MENU_ITEM_ID,
REPORT_MENU_ITEM_ID,
SETTINGS_MENU_ITEM_ID,
} from '~utils/constants';
jest.mock('~background/messages/database/refresh');
describe('background/utils/runtime/onInstalled.ts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should remove existing context menus and create new ones', async () => {
const documentUrlPatterns = ['https://example.com/*'];
await onInstalled();
expect(chrome.contextMenus.removeAll).toHaveBeenCalled();
expect(chrome.contextMenus.create).toHaveBeenNthCalledWith(1, {
contexts: ['all'],
documentUrlPatterns,
id: EXTENSION_MENU_ITEM_ID,
title: 'Cookie Dialog Monster',
});
expect(chrome.contextMenus.create).toHaveBeenNthCalledWith(2, {
contexts: ['all'],
documentUrlPatterns,
id: SETTINGS_MENU_ITEM_ID,
parentId: EXTENSION_MENU_ITEM_ID,
title: chrome.i18n.getMessage('contextMenu_settingsOption'),
});
expect(chrome.contextMenus.create).toHaveBeenNthCalledWith(3, {
contexts: ['all'],
documentUrlPatterns,
id: REPORT_MENU_ITEM_ID,
parentId: EXTENSION_MENU_ITEM_ID,
title: chrome.i18n.getMessage('contextMenu_reportOption'),
});
expect(chrome.storage.local.remove).toHaveBeenCalledWith('updateAvailable');
expect(databaseRefreshHandler).toHaveBeenCalledWith(
{ name: 'database/refresh' },
{ send: expect.any(Function) }
);
});
});

View File

@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import databaseRefreshHandler from '~background/messages/database/refresh';
import updateAvailableHandler from '~background/messages/extension/updateAvailable';
import onStartup from '~background/utils/runtime/onStartup';
jest.mock('~background/messages/database/refresh');
jest.mock('~background/messages/extension/updateAvailable');
describe('background/utils/runtime/onStartup.ts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should execute calls in sequence', async () => {
await onStartup();
expect(chrome.storage.local.remove).toHaveBeenCalledWith('updateAvailable');
expect(databaseRefreshHandler).toHaveBeenCalledWith(
{ name: 'database/refresh' },
{ send: expect.any(Function) }
);
expect(updateAvailableHandler).toHaveBeenCalledWith(
{ name: 'extension/updateAvailable' },
{ send: expect.any(Function) }
);
});
});

View File

@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import onBeforeRequest from '~background/utils/webRequest/onBeforeRequest';
import { DEFAULT_DOMAIN_CONFIG } from '~utils/constants';
import { formatDomainFromURL, validateSupport } from '~utils/domain';
import type { ExtensionData } from '~utils/types';
describe('background/utils/webRequest/onBeforeRequest.ts', () => {
beforeEach(() => {
jest.clearAllMocks();
(validateSupport as jest.Mock).mockReturnValue(true);
});
it('should update session rules if conditions are met', async () => {
const data: ExtensionData = {
actions: [
{
domain: 'jestjs.io',
name: 'click',
selector: '#jest',
},
],
exclusions: {
domains: ['*.jestjs.io'],
overflows: ['*.jestjs.io'],
tags: ['JEST'],
},
keywords: ['jest'],
rules: [
{
action: {
type: 'block' as chrome.declarativeNetRequest.RuleActionType,
},
condition: {
resourceTypes: [
'font' as chrome.declarativeNetRequest.ResourceType,
'image' as chrome.declarativeNetRequest.ResourceType,
'media' as chrome.declarativeNetRequest.ResourceType,
'object' as chrome.declarativeNetRequest.ResourceType,
'script' as chrome.declarativeNetRequest.ResourceType,
'stylesheet' as chrome.declarativeNetRequest.ResourceType,
'xmlhttprequest' as chrome.declarativeNetRequest.ResourceType,
],
urlFilter: '||jestjs.io^',
tabIds: [123],
},
id: 1,
priority: 1,
},
],
tokens: {
backdrops: ['#backdrop'],
classes: ['jest'],
containers: ['#container'],
selectors: ['#element'],
},
version: '0.0.0',
};
const details: chrome.webRequest.WebRequestBodyDetails = {
tabId: 123,
type: 'main_frame',
url: 'https://example.com/path',
} as chrome.webRequest.WebRequestBodyDetails;
await chrome.storage.local.set({ data });
await chrome.storage.local.set({ 'example.com': DEFAULT_DOMAIN_CONFIG });
await onBeforeRequest(details);
expect(chrome.storage.local.get).toHaveBeenNthCalledWith(1, 'data');
expect(chrome.storage.local.get).toHaveBeenNthCalledWith(2, 'example.com');
expect(formatDomainFromURL).toHaveBeenCalledWith(new URL(details.url));
expect(validateSupport).toHaveBeenCalledWith('example.com', ['*.jestjs.io']);
expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenCalledWith({
addRules: [data.rules[0]],
removeRuleIds: [data.rules[0].id],
});
});
it('should not update session rules if config.on is false', async () => {
const details: chrome.webRequest.WebRequestBodyDetails = {
tabId: 123,
type: 'main_frame',
url: 'https://example.com/path',
} as chrome.webRequest.WebRequestBodyDetails;
(validateSupport as jest.Mock).mockReturnValue(false);
await onBeforeRequest(details);
expect(chrome.declarativeNetRequest.updateSessionRules).not.toHaveBeenCalled();
});
it('should not update session rules if validateSupport returns false', async () => {
const data: ExtensionData = {
actions: [
{
domain: 'jestjs.io',
name: 'click',
selector: '#jest',
},
],
exclusions: {
domains: ['*.jestjs.io'],
overflows: ['*.jestjs.io'],
tags: ['JEST'],
},
keywords: ['jest'],
rules: [
{
action: {
type: 'block' as chrome.declarativeNetRequest.RuleActionType,
},
condition: {
resourceTypes: [
'font' as chrome.declarativeNetRequest.ResourceType,
'image' as chrome.declarativeNetRequest.ResourceType,
'media' as chrome.declarativeNetRequest.ResourceType,
'object' as chrome.declarativeNetRequest.ResourceType,
'script' as chrome.declarativeNetRequest.ResourceType,
'stylesheet' as chrome.declarativeNetRequest.ResourceType,
'xmlhttprequest' as chrome.declarativeNetRequest.ResourceType,
],
urlFilter: '||jestjs.io^',
tabIds: [123],
},
id: 1,
priority: 1,
},
],
tokens: {
backdrops: ['#backdrop'],
classes: ['jest'],
containers: ['#container'],
selectors: ['#element'],
},
version: '0.0.0',
};
const details: chrome.webRequest.WebRequestBodyDetails = {
tabId: 123,
type: 'main_frame',
url: 'https://example.com/path',
} as chrome.webRequest.WebRequestBodyDetails;
await chrome.storage.local.set({ 'example.com': { ...DEFAULT_DOMAIN_CONFIG, on: false } });
await onBeforeRequest(details);
expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenCalledWith({
addRules: undefined,
removeRuleIds: [data.rules[0].id],
});
});
it('should not process if tabId is invalid or type is not "main_frame"', async () => {
let details = {} as chrome.webRequest.WebRequestBodyDetails;
details = { ...details, tabId: -1, type: 'main_frame', url: 'https://example.com' };
await onBeforeRequest(details);
details = { ...details, tabId: 123, type: 'sub_frame', url: 'https://example.com' };
await onBeforeRequest(details);
expect(chrome.declarativeNetRequest.updateSessionRules).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { sendToContentScript } from '@plasmohq/messaging';
import onErrorOccurred from '~background/utils/webRequest/onErrorOccurred';
jest.mock('@plasmohq/messaging', () => ({ sendToContentScript: jest.fn() }));
describe('background/utils/webRequest/onErrorOcurred.ts', () => {
beforeEach(() => {
jest.clearAllMocks(); // Clear previous mocks before each test
});
it('should call sendToContentScript when error is "net::ERR_BLOCKED_BY_CLIENT" and tabId is valid', async () => {
const details = {
error: 'net::ERR_BLOCKED_BY_CLIENT',
tabId: 1,
} as chrome.webRequest.WebResponseErrorDetails;
await onErrorOccurred(details);
expect(sendToContentScript).toHaveBeenCalledTimes(1);
expect(sendToContentScript).toHaveBeenCalledWith({
body: {
value: 'net::ERR_BLOCKED_BY_CLIENT',
},
name: 'INCREASE_LOG_COUNT',
tabId: 1,
});
});
it('should NOT call sendToContentScript when error is not "net::ERR_BLOCKED_BY_CLIENT"', async () => {
const details = {
error: 'net::ERR_FAILED',
tabId: 1,
} as chrome.webRequest.WebResponseErrorDetails;
await onErrorOccurred(details);
expect(sendToContentScript).not.toHaveBeenCalled();
});
it('should NOT call sendToContentScript when tabId is invalid (less than 0)', async () => {
const details = {
error: 'net::ERR_BLOCKED_BY_CLIENT',
tabId: -1,
} as chrome.webRequest.WebResponseErrorDetails;
await onErrorOccurred(details);
expect(sendToContentScript).not.toHaveBeenCalled();
});
});

View File

@ -1,3 +1,5 @@
import { describe } from '@jest/globals';
import { describe, it } from '@jest/globals';
describe('contents/index.ts', () => {});
describe('contents/index.ts', () => {
it.skip('temp', () => {});
});