From 7fb9ef9a7f72588c795ac066ff94486a98bcb680 Mon Sep 17 00:00:00 2001 From: saisanthoshpentakota Date: Mon, 13 Oct 2025 15:58:42 +0530 Subject: [PATCH 1/3] chore: use react query to query config from ui --- package.json | 2 + src/pages/background/index.ts | 18 +++++-- .../hooks/query-hooks/useTagConfig.ts | 30 ++++++++++++ src/pages/sidepanel/hooks/useCurrentTab.ts | 48 +++++++++++++++++++ src/pages/sidepanel/index.tsx | 16 +++++-- src/shared/message-broker/broker.ts | 4 ++ src/shared/message-broker/types.ts | 3 +- yarn.lock | 24 ++++++++++ 8 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 src/pages/sidepanel/hooks/query-hooks/useTagConfig.ts create mode 100644 src/pages/sidepanel/hooks/useCurrentTab.ts diff --git a/package.json b/package.json index 0e59380..7b9e45c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "@mui/material": "^5.15.1", "@mui/styles": "^5.15.2", "@mui/x-charts": "^6.18.4", + "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-devtools": "^5.90.2", "construct-style-sheets-polyfill": "3.1.0", "date-fns": "^3.0.6", "moment": "^2.30.1", diff --git a/src/pages/background/index.ts b/src/pages/background/index.ts index 5879dd9..89bd9f2 100644 --- a/src/pages/background/index.ts +++ b/src/pages/background/index.ts @@ -3,17 +3,18 @@ import 'webextension-polyfill'; import { currentTabAutoDetector } from '@root/src/pages/background/currentTabAutoDetector'; import { createNetworkTrafficHandler } from '@root/src/pages/background/lyticsNetworkHandler'; import { EmitLog } from '@root/src/shared/components/EmitLog'; +import { autoDetectedDomainsStore } from '@src/shared/storages/autoDetectedDomainsStorage'; import entityStore from '@src/shared/storages/entityStorage'; import { domainStore } from '@src/shared/storages/extensionDomainStorage'; import extensionStateStorage from '@src/shared/storages/extensionStateStorage'; +import domainStateStore from '@src/shared/storages/tabStateStorage'; import tagActivityStore from '@src/shared/storages/tagActivityStorage'; import tagConfigStore from '@src/shared/storages/tagConfigStorage'; -import { autoDetectedDomainsStore } from '@src/shared/storages/autoDetectedDomainsStorage'; -import domainStateStore from '@src/shared/storages/tabStateStorage'; -chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(error => { - EmitLog({ name: 'background', payload: { msg: 'Failed to set side panel behavior', error: error.message } }); -}); +import { messageBroker } from '../../shared/message-broker'; +import { IMessage } from '../../shared/message-broker/types'; + +chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(error => console.error(error)); chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => { if (!tab.url) return; @@ -450,3 +451,10 @@ setInterval(async () => { }); } }, 60000); +messageBroker.handle('GET_CONFIG', async (message: IMessage) => { + const tabMessage: IMessage = { + key: 'GET_CONFIG', + }; + + return await messageBroker.sendToTab(message.payload.currentTabId, tabMessage); +}); diff --git a/src/pages/sidepanel/hooks/query-hooks/useTagConfig.ts b/src/pages/sidepanel/hooks/query-hooks/useTagConfig.ts new file mode 100644 index 0000000..f035fd9 --- /dev/null +++ b/src/pages/sidepanel/hooks/query-hooks/useTagConfig.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; + +import { messageBroker } from '../../../../shared/message-broker'; +import { IMessage } from '../../../../shared/message-broker/types'; +import { useCurrentTab } from '../useCurrentTab'; + +const getMessageRequest = ( + tabId: number, +): IMessage<{ + currentTabId: number; +}> => ({ + key: 'GET_CONFIG', + payload: { + currentTabId: tabId, + }, +}); + +const getTagConfig = async (currentTabId: number) => { + return await messageBroker.send(getMessageRequest(currentTabId)); +}; + +export const useTagConfig = () => { + const currentTab = useCurrentTab(); + + return useQuery({ + queryKey: ['tagConfig', currentTab?.url, currentTab?.id], + queryFn: () => getTagConfig(currentTab.id), + enabled: !!currentTab?.id, + }); +}; diff --git a/src/pages/sidepanel/hooks/useCurrentTab.ts b/src/pages/sidepanel/hooks/useCurrentTab.ts new file mode 100644 index 0000000..42fec45 --- /dev/null +++ b/src/pages/sidepanel/hooks/useCurrentTab.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; + +export function useCurrentTab() { + const [tab, setTab] = useState(undefined); + + useEffect(() => { + let currentTabId: number | undefined; + + const updateActiveTab = async () => { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + currentTabId = activeTab?.id; + setTab(activeTab); + }; + + const handleActivated = async (activeInfo: chrome.tabs.TabActiveInfo) => { + currentTabId = activeInfo.tabId; + const activeTab = await chrome.tabs.get(activeInfo.tabId); + setTab(activeTab); + }; + + const handleRemoved = (removedTabId: number) => { + if (removedTabId === currentTabId) { + setTab(undefined); + updateActiveTab(); + } + }; + + const handleUpdated = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => { + if (updatedTabId === currentTabId) { + setTab(prev => (prev ? { ...prev, ...changeInfo } : prev)); + } + }; + + updateActiveTab(); + + chrome.tabs.onActivated.addListener(handleActivated); + chrome.tabs.onRemoved.addListener(handleRemoved); + chrome.tabs.onUpdated.addListener(handleUpdated); + + return () => { + chrome.tabs.onActivated.removeListener(handleActivated); + chrome.tabs.onRemoved.removeListener(handleRemoved); + chrome.tabs.onUpdated.removeListener(handleUpdated); + }; + }, []); + + return tab; +} diff --git a/src/pages/sidepanel/index.tsx b/src/pages/sidepanel/index.tsx index 2d7411e..3f1c85f 100644 --- a/src/pages/sidepanel/index.tsx +++ b/src/pages/sidepanel/index.tsx @@ -1,8 +1,12 @@ import React from 'react'; -import { BrowserRouter as Router } from 'react-router-dom'; + import { createRoot } from 'react-dom/client'; +import { BrowserRouter as Router } from 'react-router-dom'; + import State from '@pages/sidepanel/State'; import { AppThemeProvider } from '@src/theme/ThemeProvider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; function init() { const appContainer = document.querySelector('#app-container'); @@ -12,13 +16,17 @@ function init() { } const root = createRoot(appContainer); + const queryClient = new QueryClient(); root.render( - - - + + + + + + , ); diff --git a/src/shared/message-broker/broker.ts b/src/shared/message-broker/broker.ts index f96b10d..3ee92ea 100644 --- a/src/shared/message-broker/broker.ts +++ b/src/shared/message-broker/broker.ts @@ -18,4 +18,8 @@ export class MessageBroker { } }); } + + async sendToTab(tabId: number, message: IMessage): Promise { + return await this.browser.tabs.sendMessage(tabId, message); + } } diff --git a/src/shared/message-broker/types.ts b/src/shared/message-broker/types.ts index 14817bd..9e396d1 100644 --- a/src/shared/message-broker/types.ts +++ b/src/shared/message-broker/types.ts @@ -1,5 +1,6 @@ type MessageKey = 'GET_CONFIG'; -export interface IMessage { +export interface IMessage { key: MessageKey; + payload?: T; } diff --git a/yarn.lock b/yarn.lock index cefb02c..cd6e317 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1837,6 +1837,30 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@tanstack/query-core@5.90.2": + version "5.90.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.90.2.tgz#ac5d0d0f19a38071db2d21d758b5c35a85d9c1d8" + integrity sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ== + +"@tanstack/query-devtools@5.90.1": + version "5.90.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz#c57a739a5293f4960a4a6b6fb4b7e4a56e6bd932" + integrity sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ== + +"@tanstack/react-query-devtools@^5.90.2": + version "5.90.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz#248d6ae89ace7dc2da816fa95cdc2b4f63c9e4d2" + integrity sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ== + dependencies: + "@tanstack/query-devtools" "5.90.1" + +"@tanstack/react-query@^5.90.2": + version "5.90.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.90.2.tgz#2f045931b7d44bef02c5261fedba75ef1a418726" + integrity sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw== + dependencies: + "@tanstack/query-core" "5.90.2" + "@testing-library/dom@^9.0.0": version "9.3.4" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" From 4bc98b4667bb3a9bf932c9d106a2f112d1152d79 Mon Sep 17 00:00:00 2001 From: saisanthoshpentakota Date: Mon, 13 Oct 2025 17:25:23 +0530 Subject: [PATCH 2/3] refactor: extract contentscript into modules --- src/pages/content/modules/autoDetection.ts | 100 +++++ .../content/modules/eventHandler.test.ts | 250 +++++++++++ src/pages/content/modules/eventHandler.ts | 68 +++ src/pages/content/modules/index.ts | 4 + .../content/modules/messageHandler.test.ts | 189 ++++++++ src/pages/content/modules/messageHandler.ts | 92 ++++ .../content/modules/scriptInjection.test.ts | 167 ++++++++ src/pages/content/modules/scriptInjection.ts | 55 +++ src/pages/content/ui/app.test.tsx | 9 +- src/pages/content/ui/app.tsx | 405 +----------------- src/pages/sidepanel/index.tsx | 2 +- 11 files changed, 941 insertions(+), 400 deletions(-) create mode 100644 src/pages/content/modules/autoDetection.ts create mode 100644 src/pages/content/modules/eventHandler.test.ts create mode 100644 src/pages/content/modules/eventHandler.ts create mode 100644 src/pages/content/modules/index.ts create mode 100644 src/pages/content/modules/messageHandler.test.ts create mode 100644 src/pages/content/modules/messageHandler.ts create mode 100644 src/pages/content/modules/scriptInjection.test.ts create mode 100644 src/pages/content/modules/scriptInjection.ts diff --git a/src/pages/content/modules/autoDetection.ts b/src/pages/content/modules/autoDetection.ts new file mode 100644 index 0000000..a9a44d1 --- /dev/null +++ b/src/pages/content/modules/autoDetection.ts @@ -0,0 +1,100 @@ +import { EmitLog } from '@src/shared/components/EmitLog'; +import extensionStateStorage from '@src/shared/storages/extensionStateStorage'; + +export const isJstagAvailable = (): boolean => { + return typeof (window as any).jstag !== 'undefined' && (window as any).jstag !== null; +}; + +export const pollForJstag = (domain: string): void => { + let retryCount = 0; + const maxRetries = 5; + const retryInterval = 750; + + const checkForJstag = async () => { + const isEnabled = await extensionStateStorage.get(); + if (!isEnabled) { + EmitLog({ name: 'content', payload: { msg: 'Extension disabled - stopping auto-detection polling' } }); + return; + } + + retryCount++; + + if (isJstagAvailable()) { + EmitLog({ name: 'content', payload: { msg: 'Lytics jstag detected during auto-detection' } }); + await notifyAutoDetectionSuccess(domain); + return; + } + + if (retryCount < maxRetries) { + EmitLog({ name: 'content', payload: { msg: `Auto-detection retry ${retryCount}/${maxRetries}` } }); + setTimeout(checkForJstag, retryInterval); + } else { + EmitLog({ name: 'content', payload: { msg: 'Auto-detection failed - no jstag found after max retries' } }); + await notifyAutoDetectionFailed(domain); + } + }; + + checkForJstag(); +}; + +export const startAutoDetection = async (domain: string): Promise => { + const isEnabled = await extensionStateStorage.get(); + if (!isEnabled) { + EmitLog({ name: 'content', payload: { msg: 'Extension disabled - skipping auto-detection', domain } }); + return; + } + + EmitLog({ name: 'content', payload: { msg: `Starting auto-detection for domain: ${domain}` } }); + + if (isJstagAvailable()) { + EmitLog({ name: 'content', payload: { msg: 'Lytics jstag found immediately' } }); + await notifyAutoDetectionSuccess(domain); + return; + } + + pollForJstag(domain); +}; + +export const notifyAutoDetectionSuccess = async (domain: string): Promise => { + const isEnabled = await extensionStateStorage.get(); + if (!isEnabled) { + EmitLog({ name: 'content', payload: { msg: 'Extension disabled - not notifying detection success', domain } }); + return; + } + + try { + await chrome.runtime.sendMessage({ + action: 'recordDetection', + domain: domain, + confidence: 0.9, + }); + EmitLog({ name: 'content', payload: { msg: `Notified background of successful detection for: ${domain}` } }); + } catch (error) { + EmitLog({ + name: 'content', + payload: { msg: 'Error notifying background of successful detection', error: error.message }, + }); + } +}; + +export const notifyAutoDetectionFailed = async (domain: string): Promise => { + const isEnabled = await extensionStateStorage.get(); + if (!isEnabled) { + EmitLog({ name: 'content', payload: { msg: 'Extension disabled - not notifying detection failure', domain } }); + return; + } + + try { + await chrome.runtime.sendMessage({ + action: 'autoDetectionFailed', + domain: domain, + retryCount: 0, + }); + EmitLog({ name: 'content', payload: { msg: `Notified background of failed detection for: ${domain}` } }); + } catch (error) { + EmitLog({ + name: 'content', + payload: { msg: 'Error notifying background of failed detection', error: error.message }, + }); + } +}; diff --git a/src/pages/content/modules/eventHandler.test.ts b/src/pages/content/modules/eventHandler.test.ts new file mode 100644 index 0000000..15a0ed8 --- /dev/null +++ b/src/pages/content/modules/eventHandler.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { setupConfigEventListener, setupEntityEventListener } from './eventHandler'; + +vi.mock('@src/shared/components/EmitLog', () => ({ + EmitLog: vi.fn(), +})); + +vi.mock('@src/shared/storages/entityStorage', () => ({ + default: { + set: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('@src/shared/storages/tagConfigStorage', () => ({ + default: { + set: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('@src/shared/storages/extensionStateStorage', () => ({ + default: { + get: vi.fn().mockResolvedValue(true), + }, +})); + +global.chrome = { + runtime: { + sendMessage: vi.fn().mockResolvedValue({ success: true }), + }, +} as any; + +Object.defineProperty(window, 'location', { + value: { hostname: 'example.com' }, + writable: true, +}); + +describe('Event Handler Module', () => { + let mockExtensionStateStorage: any; + let mockTagConfigStore: any; + let mockEntityStore: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + const extensionStateStorage = await import('@src/shared/storages/extensionStateStorage'); + mockExtensionStateStorage = extensionStateStorage.default; + vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(true); + + const tagConfigStorage = await import('@src/shared/storages/tagConfigStorage'); + mockTagConfigStore = tagConfigStorage.default; + vi.mocked(mockTagConfigStore.set).mockResolvedValue(undefined); + + const entityStorage = await import('@src/shared/storages/entityStorage'); + mockEntityStore = entityStorage.default; + vi.mocked(mockEntityStore.set).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('setupConfigEventListener', () => { + test('should set up config event listener', () => { + const handler = setupConfigEventListener(); + + expect(handler).toBeDefined(); + expect(typeof handler).toBe('function'); + }); + + test('should handle config event', async () => { + setupConfigEventListener(); + + const configData = { cid: 'test-cid', stream: 'test-stream' }; + const event = new CustomEvent('config', { + detail: { data: JSON.stringify(configData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'saveTagConfig', + payload: JSON.stringify(configData), + }); + + expect(mockTagConfigStore.set).toHaveBeenCalledWith(JSON.stringify(configData)); + + expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'recordDetection', + domain: 'example.com', + confidence: 0.95, + }); + }); + + test('should not process config event when extension is disabled', async () => { + vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(false); + + setupConfigEventListener(); + + const configData = { cid: 'test-cid', stream: 'test-stream' }; + const event = new CustomEvent('config', { + detail: { data: JSON.stringify(configData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(global.chrome.runtime.sendMessage).not.toHaveBeenCalled(); + expect(mockTagConfigStore.set).not.toHaveBeenCalled(); + }); + + test('should handle config save errors gracefully', async () => { + vi.mocked(mockTagConfigStore.set).mockRejectedValue(new Error('Storage error')); + + setupConfigEventListener(); + + const configData = { cid: 'test-cid', stream: 'test-stream' }; + const event = new CustomEvent('config', { + detail: { data: JSON.stringify(configData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'saveTagConfig', + payload: JSON.stringify(configData), + }); + }); + }); + + describe('setupEntityEventListener', () => { + test('should set up entity event listener', () => { + const handler = setupEntityEventListener(); + + expect(handler).toBeDefined(); + expect(typeof handler).toBe('function'); + }); + + test('should handle entity event', async () => { + setupEntityEventListener(); + + const entityData = { _uid: 'test-uid', email: 'test@example.com' }; + const event = new CustomEvent('entity', { + detail: { data: JSON.stringify(entityData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'saveEntity', + payload: JSON.stringify(entityData), + }); + + expect(mockEntityStore.set).toHaveBeenCalledWith(JSON.stringify(entityData)); + }); + + test('should not process entity event when extension is disabled', async () => { + vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(false); + + setupEntityEventListener(); + + const entityData = { _uid: 'test-uid', email: 'test@example.com' }; + const event = new CustomEvent('entity', { + detail: { data: JSON.stringify(entityData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(global.chrome.runtime.sendMessage).not.toHaveBeenCalled(); + expect(mockEntityStore.set).not.toHaveBeenCalled(); + }); + + test('should handle entity save successfully', async () => { + setupEntityEventListener(); + + const entityData = { _uid: 'test-uid', email: 'test@example.com' }; + const event = new CustomEvent('entity', { + detail: { data: JSON.stringify(entityData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockEntityStore.set).toHaveBeenCalledWith(JSON.stringify(entityData)); + }); + }); + + describe('Extension State Guards', () => { + test('should respect extension state for config events', async () => { + vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(false); + + setupConfigEventListener(); + + const configData = { cid: 'test-cid', stream: 'test-stream' }; + const event = new CustomEvent('config', { + detail: { data: JSON.stringify(configData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockTagConfigStore.set).not.toHaveBeenCalled(); + }); + + test('should respect extension state for entity events', async () => { + vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(false); + + setupEntityEventListener(); + + const entityData = { _uid: 'test-uid', email: 'test@example.com' }; + const event = new CustomEvent('entity', { + detail: { data: JSON.stringify(entityData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockEntityStore.set).not.toHaveBeenCalled(); + }); + + test('should allow operations when extension is enabled', async () => { + vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(true); + + setupConfigEventListener(); + + const configData = { cid: 'test-cid', stream: 'test-stream' }; + const event = new CustomEvent('config', { + detail: { data: JSON.stringify(configData) }, + }); + + document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockTagConfigStore.set).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/pages/content/modules/eventHandler.ts b/src/pages/content/modules/eventHandler.ts new file mode 100644 index 0000000..f60025c --- /dev/null +++ b/src/pages/content/modules/eventHandler.ts @@ -0,0 +1,68 @@ +import { EmitLog } from '@src/shared/components/EmitLog'; +import entityStore from '@src/shared/storages/entityStorage'; +import extensionStateStorage from '@src/shared/storages/extensionStateStorage'; +import tagConfigStore from '@src/shared/storages/tagConfigStorage'; + +export const setupConfigEventListener = () => { + const handleConfigEvent = async function (event) { + const currentState = await extensionStateStorage.get(); + if (!currentState) { + EmitLog({ name: 'content', payload: { msg: 'Extension disabled - ignoring config event' } }); + return; + } + + EmitLog({ name: 'content', payload: { msg: 'Config event received', data: (event as any).detail } }); + const payload = (event as any).detail.data; + + chrome.runtime.sendMessage({ + action: 'saveTagConfig', + payload: payload, + }); + + tagConfigStore + .set(payload) + .then(() => { + EmitLog({ name: 'storage', payload: { msg: 'Tag config saved to global storage.' } }); + }) + .catch(error => { + EmitLog({ name: 'storage', payload: { msg: 'Failed to save tag config', error } }); + }); + + const domain = window.location.hostname; + EmitLog({ + name: 'content', + payload: { msg: `Config event received - triggering auto-detection for: ${domain}` }, + }); + + chrome.runtime.sendMessage({ + action: 'recordDetection', + domain: domain, + confidence: 0.95, + }); + }; + document.addEventListener('config', handleConfigEvent); + return handleConfigEvent; +}; + +export const setupEntityEventListener = () => { + const handleEntityEvent = async function (event) { + const currentState = await extensionStateStorage.get(); + if (!currentState) { + EmitLog({ name: 'content', payload: { msg: 'Extension disabled - ignoring entity event' } }); + return; + } + + const payload = (event as any).detail.data; + + chrome.runtime.sendMessage({ + action: 'saveEntity', + payload: payload, + }); + + entityStore.set(payload).then(() => { + EmitLog({ name: 'storage', payload: { msg: 'Entity saved to global storage.' } }); + }); + }; + document.addEventListener('entity', handleEntityEvent); + return handleEntityEvent; +}; diff --git a/src/pages/content/modules/index.ts b/src/pages/content/modules/index.ts new file mode 100644 index 0000000..c60857a --- /dev/null +++ b/src/pages/content/modules/index.ts @@ -0,0 +1,4 @@ +export * from './autoDetection'; +export * from './scriptInjection'; +export * from './messageHandler'; +export * from './eventHandler'; diff --git a/src/pages/content/modules/messageHandler.test.ts b/src/pages/content/modules/messageHandler.test.ts new file mode 100644 index 0000000..4f7b172 --- /dev/null +++ b/src/pages/content/modules/messageHandler.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { setupChromeMessageListener } from './messageHandler'; + +vi.mock('@src/shared/components/EmitLog', () => ({ + EmitLog: vi.fn(), +})); + +vi.mock('@src/shared/storages/extensionStateStorage', () => ({ + default: { + get: vi.fn().mockResolvedValue(true), + }, +})); + +vi.mock('../../../shared/message-broker', () => ({ + messageBroker: { + handle: vi.fn(), + }, +})); + +vi.mock('./autoDetection', () => ({ + startAutoDetection: vi.fn().mockResolvedValue(undefined), +})); + +global.chrome = { + runtime: { + onMessage: { + addListener: vi.fn(), + }, + sendMessage: vi.fn().mockResolvedValue({ success: true }), + }, +} as any; + +Object.defineProperty(window, 'location', { + value: { hostname: 'example.com' }, + writable: true, +}); + +describe('Message Handler Module', () => { + let mockExtensionStateStorage: any; + let mockStartAutoDetection: any; + let messageListener: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + const extensionStateStorage = await import('@src/shared/storages/extensionStateStorage'); + mockExtensionStateStorage = extensionStateStorage.default; + vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(true); + + const autoDetection = await import('./autoDetection'); + mockStartAutoDetection = autoDetection.startAutoDetection; + + global.chrome.runtime.onMessage.addListener = vi.fn(listener => { + messageListener = listener; + }); + + window.postMessage = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('setupChromeMessageListener', () => { + test('should set up chrome message listener', () => { + setupChromeMessageListener(); + + expect(global.chrome.runtime.onMessage.addListener).toHaveBeenCalled(); + }); + + test('should handle getConfig message', async () => { + setupChromeMessageListener(); + + const message = { action: 'getConfig' }; + const sendResponse = vi.fn(); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(window.postMessage).toHaveBeenCalledWith({ action: 'getConfig' }, '*'); + expect(sendResponse).toHaveBeenCalledWith({ success: true }); + }); + + test('should handle getEntity message', async () => { + setupChromeMessageListener(); + + const message = { action: 'getEntity' }; + const sendResponse = vi.fn(); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(window.postMessage).toHaveBeenCalledWith({ action: 'getEntity' }, '*'); + expect(sendResponse).toHaveBeenCalledWith({ success: true }); + }); + + test('should handle startAutoDetection message', async () => { + setupChromeMessageListener(); + + const message = { action: 'startAutoDetection', domain: 'test.com' }; + const sendResponse = vi.fn(); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockStartAutoDetection).toHaveBeenCalledWith('test.com'); + expect(sendResponse).toHaveBeenCalledWith({ success: true }); + }); + + test('should use window.location.hostname if domain is not provided', async () => { + setupChromeMessageListener(); + + const message = { action: 'startAutoDetection' }; + const sendResponse = vi.fn(); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockStartAutoDetection).toHaveBeenCalledWith('example.com'); + expect(sendResponse).toHaveBeenCalledWith({ success: true }); + }); + + test('should handle detectionSuccess message', async () => { + setupChromeMessageListener(); + + const message = { action: 'detectionSuccess', domain: 'test.com', confidence: 0.9 }; + const sendResponse = vi.fn(); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'recordDetection', + domain: 'test.com', + confidence: 0.9, + }); + expect(sendResponse).toHaveBeenCalledWith({ success: true }); + }); + + test('should not process messages when extension is disabled', async () => { + vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(false); + + setupChromeMessageListener(); + + const message = { action: 'getConfig' }; + const sendResponse = vi.fn(); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(window.postMessage).not.toHaveBeenCalled(); + expect(sendResponse).toHaveBeenCalledWith({ success: false, error: 'Extension is disabled' }); + }); + + test('should handle errors gracefully', async () => { + setupChromeMessageListener(); + + const message = { action: 'startAutoDetection', domain: 'test.com' }; + const sendResponse = vi.fn(); + + vi.mocked(mockStartAutoDetection).mockRejectedValue(new Error('Test error')); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(sendResponse).toHaveBeenCalledWith({ success: false, error: 'Test error' }); + }); + + test('should ignore messages with key property', async () => { + setupChromeMessageListener(); + + const message = { key: 'someKey', action: 'getConfig' }; + const sendResponse = vi.fn(); + + const result = messageListener(message, {}, sendResponse); + + expect(result).toBeUndefined(); + expect(sendResponse).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/pages/content/modules/messageHandler.ts b/src/pages/content/modules/messageHandler.ts new file mode 100644 index 0000000..7224d72 --- /dev/null +++ b/src/pages/content/modules/messageHandler.ts @@ -0,0 +1,92 @@ +import { EmitLog } from '@src/shared/components/EmitLog'; +import extensionStateStorage from '@src/shared/storages/extensionStateStorage'; + +import { messageBroker } from '../../../shared/message-broker'; +import { startAutoDetection } from './autoDetection'; + +const TIMEOUT_MS = 2000; + +export const setupChromeMessageListener = () => { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if ('key' in message) { + return; + } + (async () => { + try { + const currentState = await extensionStateStorage.get(); + if (!currentState) { + EmitLog({ + name: 'content', + payload: { msg: 'Extension disabled - ignoring message', action: message.action }, + }); + sendResponse({ success: false, error: 'Extension is disabled' }); + return; + } + + if (message.action == 'getConfig') { + window.postMessage({ action: 'getConfig' }, '*'); + } + + if (message.action == 'getEntity') { + window.postMessage({ action: 'getEntity' }, '*'); + } + + if (message.action === 'startAutoDetection') { + const domain = message.domain || window.location.hostname; + EmitLog({ name: 'content', payload: { msg: `Starting auto-detection for domain: ${domain}` } }); + + await startAutoDetection(domain); + } + + if (message.action === 'detectionSuccess') { + const domain = message.domain || window.location.hostname; + EmitLog({ name: 'content', payload: { msg: `Detection successful for domain: ${domain}` } }); + + try { + await chrome.runtime.sendMessage({ + action: 'recordDetection', + domain: domain, + confidence: message.confidence || 0.8, + }); + } catch (error) { + EmitLog({ + name: 'content', + payload: { msg: 'Error notifying background of detection success', error: error.message }, + }); + } + } + + sendResponse({ success: true }); + } catch (error) { + EmitLog({ name: 'content', payload: { msg: 'Error handling message', error: error.message } }); + sendResponse({ success: false, error: error.message }); + } + })(); + + return true; + }); + + messageBroker.handle('GET_CONFIG', async () => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + document.removeEventListener('config', configHandler); + reject(new Error(`Config retrieval timeout after ${TIMEOUT_MS / 100} seconds`)); + }, TIMEOUT_MS); + + const configHandler = (event: CustomEvent) => { + clearTimeout(timeout); + document.removeEventListener('config', configHandler); + try { + const config = JSON.parse(event.detail.data); + resolve(config); + } catch (error) { + reject(new Error(`Failed to parse config data: ${error.message}`)); + } + }; + + document.addEventListener('config', configHandler as EventListener); + + window.postMessage({ action: 'getConfig' }, '*'); + }); + }); +}; diff --git a/src/pages/content/modules/scriptInjection.test.ts b/src/pages/content/modules/scriptInjection.test.ts new file mode 100644 index 0000000..dde480b --- /dev/null +++ b/src/pages/content/modules/scriptInjection.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { injectScript, setupBackgroundToContentListener, setupRetryHandler } from './scriptInjection'; + +vi.mock('@src/shared/components/EmitLog', () => ({ + EmitLog: vi.fn(), +})); + +global.chrome = { + runtime: { + getURL: vi.fn().mockReturnValue('chrome-extension://test/tagLink.js'), + }, +} as any; + +describe('Script Injection Module', () => { + let mockScript: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockScript = { + src: '', + onload: null, + onerror: null, + remove: vi.fn(), + }; + + document.createElement = vi.fn().mockReturnValue(mockScript); + Object.defineProperty(document, 'documentElement', { + writable: true, + value: { + appendChild: vi.fn(), + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('injectScript', () => { + test('should inject script successfully', () => { + injectScript(); + + expect(document.createElement).toHaveBeenCalledWith('script'); + expect(mockScript.src).toBe('chrome-extension://test/tagLink.js'); + expect(document.documentElement.appendChild).toHaveBeenCalledWith(mockScript); + }); + + test('should handle script load successfully', () => { + injectScript(); + + if (mockScript.onload) { + mockScript.onload(); + } + + expect(mockScript.remove).toHaveBeenCalled(); + }); + + test('should handle script load error', () => { + injectScript(); + + if (mockScript.onerror) { + mockScript.onerror(); + } + + expect(mockScript.remove).not.toHaveBeenCalled(); + }); + + test('should skip injection when already injected', () => { + const scriptInjectedRef = { current: true }; + + injectScript(scriptInjectedRef); + + expect(document.createElement).not.toHaveBeenCalled(); + expect(document.documentElement.appendChild).not.toHaveBeenCalled(); + }); + + test('should update scriptInjectedRef on successful load', () => { + const scriptInjectedRef = { current: false }; + + injectScript(scriptInjectedRef); + + if (mockScript.onload) { + mockScript.onload(); + } + + expect(scriptInjectedRef.current).toBe(true); + }); + }); + + describe('setupRetryHandler', () => { + test('should handle retry messages', () => { + const retriesRef = { current: 0 }; + const scriptInjectedRef = { current: false }; + + setupRetryHandler(retriesRef, scriptInjectedRef); + + const retryEvent = { + data: { type: 'retry' }, + }; + + window.dispatchEvent(new MessageEvent('message', { data: retryEvent.data })); + + expect(retriesRef.current).toBe(1); + }); + + test('should not handle non-retry messages', () => { + const retriesRef = { current: 0 }; + const scriptInjectedRef = { current: false }; + + setupRetryHandler(retriesRef, scriptInjectedRef); + + const nonRetryEvent = { + data: { type: 'other' }, + }; + + window.dispatchEvent(new MessageEvent('message', { data: nonRetryEvent.data })); + + expect(retriesRef.current).toBe(0); + }); + }); + + describe('setupBackgroundToContentListener', () => { + test('should handle backgroundToContent messages', () => { + setupBackgroundToContentListener(); + + const backgroundEvent = { + source: window, + data: { + action: 'backgroundToContent', + data: { test: 'data' }, + }, + }; + + window.dispatchEvent(new MessageEvent('message', { ...backgroundEvent })); + }); + + test('should ignore messages from other sources', () => { + setupBackgroundToContentListener(); + + const externalEvent = { + source: null as any, + data: { + action: 'backgroundToContent', + data: { test: 'data' }, + }, + }; + + window.dispatchEvent(new MessageEvent('message', { source: externalEvent.source, data: externalEvent.data })); + }); + + test('should ignore messages with different actions', () => { + setupBackgroundToContentListener(); + + const differentActionEvent = { + source: window, + data: { + action: 'differentAction', + data: { test: 'data' }, + }, + }; + + window.dispatchEvent(new MessageEvent('message', { ...differentActionEvent })); + }); + }); +}); diff --git a/src/pages/content/modules/scriptInjection.ts b/src/pages/content/modules/scriptInjection.ts new file mode 100644 index 0000000..1e1db25 --- /dev/null +++ b/src/pages/content/modules/scriptInjection.ts @@ -0,0 +1,55 @@ +import { MutableRefObject } from 'react'; + +import { EmitLog } from '@src/shared/components/EmitLog'; + +export const injectScript = (scriptInjectedRef?: MutableRefObject) => { + if (scriptInjectedRef?.current) { + EmitLog({ name: 'content', payload: { msg: 'TagLink script already injected, skipping' } }); + return; + } + + const script = document.createElement('script'); + + script.src = chrome.runtime.getURL('/tagLink.js'); + script.onload = () => { + EmitLog({ name: 'content', payload: { msg: 'TagLink script loaded successfully' } }); + if (scriptInjectedRef) { + scriptInjectedRef.current = true; + } + script.remove(); + }; + script.onerror = () => { + EmitLog({ name: 'content', payload: { msg: 'Failed to load TagLink script', error: 'Script load error' } }); + }; + + document.documentElement.appendChild(script); +}; + +export const setupRetryHandler = ( + retriesRef: MutableRefObject, + scriptInjectedRef?: MutableRefObject, +) => { + const handleMessage = event => { + if (event.data && event.data.type === 'retry') { + retriesRef.current++; + EmitLog({ name: 'retry', payload: { retries: retriesRef.current } }); + injectScript(scriptInjectedRef); + } + }; + window.addEventListener('message', handleMessage); + return handleMessage; +}; + +export const setupBackgroundToContentListener = () => { + const handleMessage = function (event) { + if (event.source === window && event.data.action === 'backgroundToContent') { + const backgroundData = event.data.data; + EmitLog({ + name: 'message', + payload: { msg: 'Received message from background in content.', data: backgroundData }, + }); + } + }; + window.addEventListener('message', handleMessage); + return handleMessage; +}; diff --git a/src/pages/content/ui/app.test.tsx b/src/pages/content/ui/app.test.tsx index 3df03ab..7f1b951 100644 --- a/src/pages/content/ui/app.test.tsx +++ b/src/pages/content/ui/app.test.tsx @@ -1,11 +1,12 @@ -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + import { isJstagAvailable, + notifyAutoDetectionFailed, + notifyAutoDetectionSuccess, pollForJstag, startAutoDetection, - notifyAutoDetectionSuccess, - notifyAutoDetectionFailed, -} from './app'; +} from '../modules/autoDetection'; // Mock EmitLog vi.mock('@src/shared/components/EmitLog', () => ({ diff --git a/src/pages/content/ui/app.tsx b/src/pages/content/ui/app.tsx index 5280c31..5b0500e 100644 --- a/src/pages/content/ui/app.tsx +++ b/src/pages/content/ui/app.tsx @@ -1,223 +1,28 @@ import { useEffect, useRef, useState } from 'react'; import { EmitLog } from '@src/shared/components/EmitLog'; -import entityStore from '@src/shared/storages/entityStorage'; -import tagConfigStore from '@src/shared/storages/tagConfigStorage'; import extensionStateStorage from '@src/shared/storages/extensionStateStorage'; -/** - * Auto-Detection Flow Documentation - * ================================ - * - * The auto-detection system works across three main components: - * 1. Content Script (this file) - Detects Lytics jstag on web pages - * 2. Background Script - Manages detection state and coordinates between tabs - * 3. Storage - Persists detection results and manages cleanup - * - * Detection Flow: - * 1. User navigates to a website or switches tabs - * 2. Background script detects tab change and sends 'startAutoDetection' message - * 3. Content script receives message and begins detection process - * 4. Content script checks for jstag immediately, then polls if not found - * 5. Upon detection, content script notifies background of success - * 6. Background script records detection and updates persistent storage - * 7. UI displays detected domains with confidence scores - * - * Key Features: - * - Immediate detection for fast-loading sites - * - Polling with timeout for sites that load jstag asynchronously - * - Parent domain inheritance (subdomain.example.com inherits from example.com) - * - Time-based cleanup to prevent storage bloat - * - Memory limits to maintain performance - */ - -/** - * Checks if the Lytics JavaScript tag (jstag) is available on the current window. - * - * The jstag is the core Lytics SDK that gets loaded on customer websites. - * Its presence indicates that the site is using Lytics for data collection. - * - * @returns true if jstag is available and properly initialized, false otherwise - */ -export const isJstagAvailable = (): boolean => { - return typeof (window as any).jstag !== 'undefined' && (window as any).jstag !== null; -}; - -/** - * Polls for jstag availability with a timeout mechanism. - * - * Many websites load the Lytics tag asynchronously after the initial page load. - * This function repeatedly checks for jstag availability and notifies the - * background script when found or when the maximum retry limit is reached. - * - * Polling Strategy: - * - Checks every 750ms for up to 5 retries (total ~3.75 seconds) - * - Allows time for async tag loading while preventing infinite polling - * - Provides early detection for fast-loading tags - * - * @param domain - The domain being checked for Lytics integration - */ -export const pollForJstag = (domain: string): void => { - let retryCount = 0; - const maxRetries = 5; - const retryInterval = 750; - - const checkForJstag = async () => { - // Check extension state before each polling attempt - const isEnabled = await extensionStateStorage.get(); - if (!isEnabled) { - EmitLog({ name: 'content', payload: { msg: 'Extension disabled - stopping auto-detection polling' } }); - return; - } - - retryCount++; - - if (isJstagAvailable()) { - EmitLog({ name: 'content', payload: { msg: 'Lytics jstag detected during auto-detection' } }); - await notifyAutoDetectionSuccess(domain); - return; - } - - if (retryCount < maxRetries) { - EmitLog({ name: 'content', payload: { msg: `Auto-detection retry ${retryCount}/${maxRetries}` } }); - setTimeout(checkForJstag, retryInterval); - } else { - EmitLog({ name: 'content', payload: { msg: 'Auto-detection failed - no jstag found after max retries' } }); - await notifyAutoDetectionFailed(domain); - } - }; - - checkForJstag(); -}; - -/** - * Main entry point for auto-detection on a specific domain. - * - * This function implements a two-phase detection strategy: - * 1. Immediate check - for websites that load jstag synchronously - * 2. Polling phase - for websites that load jstag asynchronously - * - * The immediate check provides fast detection for well-optimized sites, - * while polling handles sites that load the tag after initial page load. - * - * Confidence Levels: - * - 0.9 for polling detection (high confidence, tag was found after waiting) - * - 0.95 for config-based detection (very high confidence, received config data) - * - * @param domain - The domain to check for Lytics integration - */ -export const startAutoDetection = async (domain: string): Promise => { - // Check extension state before starting auto-detection - const isEnabled = await extensionStateStorage.get(); - if (!isEnabled) { - EmitLog({ name: 'content', payload: { msg: 'Extension disabled - skipping auto-detection', domain } }); - return; - } - - EmitLog({ name: 'content', payload: { msg: `Starting auto-detection for domain: ${domain}` } }); - - // Immediate check - if (isJstagAvailable()) { - EmitLog({ name: 'content', payload: { msg: 'Lytics jstag found immediately' } }); - await notifyAutoDetectionSuccess(domain); - return; - } - - // Start polling if not immediately available - pollForJstag(domain); -}; - -/** - * Notifies the background script that Lytics jstag was successfully detected. - * - * This function communicates detection results back to the extension's - * background script, which manages the global state and persistent storage. - * The background script will record the detection and update the UI. - * - * Communication is done via Chrome's messaging API to ensure proper - * isolation between content script and background script contexts. - * - * @param domain - The domain where jstag was detected - */ -export const notifyAutoDetectionSuccess = async (domain: string): Promise => { - // Check extension state before notifying - const isEnabled = await extensionStateStorage.get(); - if (!isEnabled) { - EmitLog({ name: 'content', payload: { msg: 'Extension disabled - not notifying detection success', domain } }); - return; - } - - try { - await chrome.runtime.sendMessage({ - action: 'recordDetection', - domain: domain, - confidence: 0.9, - }); - EmitLog({ name: 'content', payload: { msg: `Notified background of successful detection for: ${domain}` } }); - } catch (error) { - EmitLog({ - name: 'content', - payload: { msg: 'Error notifying background of successful detection', error: error.message }, - }); - } -}; - -/** - * Notifies the background script that Lytics jstag detection failed. - * - * This function is called when the polling mechanism exhausts all retries - * without finding jstag. The background script may choose to mark this - * domain as "not using Lytics" or simply not record it. - * - * Failed detections are still communicated to maintain consistency in - * the detection workflow and allow the background script to make - * informed decisions about retry strategies or user notifications. - * - * @param domain - The domain where jstag detection failed - */ -export const notifyAutoDetectionFailed = async (domain: string): Promise => { - // Check extension state before notifying - const isEnabled = await extensionStateStorage.get(); - if (!isEnabled) { - EmitLog({ name: 'content', payload: { msg: 'Extension disabled - not notifying detection failure', domain } }); - return; - } - - try { - await chrome.runtime.sendMessage({ - action: 'autoDetectionFailed', - domain: domain, - retryCount: 0, - }); - EmitLog({ name: 'content', payload: { msg: `Notified background of failed detection for: ${domain}` } }); - } catch (error) { - EmitLog({ - name: 'content', - payload: { msg: 'Error notifying background of failed detection', error: error.message }, - }); - } -}; +import { setupConfigEventListener, setupEntityEventListener } from '../modules/eventHandler'; +import { setupChromeMessageListener } from '../modules/messageHandler'; +import { injectScript, setupBackgroundToContentListener, setupRetryHandler } from '../modules/scriptInjection'; export default function App() { const retriesRef = useRef(0); const [isExtensionEnabled, setIsExtensionEnabled] = useState(null); const scriptInjectedRef = useRef(false); - // Monitor extension state changes useEffect(() => { - // Get initial state extensionStateStorage.get().then(state => { setIsExtensionEnabled(state); EmitLog({ name: 'content', payload: { msg: `Extension state loaded: ${state}` } }); }); - // Subscribe to state changes const unsubscribe = extensionStateStorage.subscribe(() => { extensionStateStorage.get().then(state => { setIsExtensionEnabled(state); EmitLog({ name: 'content', payload: { msg: `Extension state changed: ${state}` } }); - // Reset script injection flag when state changes to allow re-injection if (state) { scriptInjectedRef.current = false; } @@ -230,12 +35,10 @@ export default function App() { }, []); useEffect(() => { - // Wait for extension state to be loaded if (isExtensionEnabled === null) { return; } - // Only proceed if extension is enabled if (!isExtensionEnabled) { EmitLog({ name: 'content', @@ -244,201 +47,13 @@ export default function App() { return; } - // ------------------------------ - // Handle Tag Link Injection - // ------------------------------ - const injectScript = () => { - // Prevent multiple injections - if (scriptInjectedRef.current) { - EmitLog({ name: 'content', payload: { msg: 'TagLink script already injected, skipping' } }); - return; - } - - const script = document.createElement('script'); - - script.src = chrome.runtime.getURL('/tagLink.js'); - script.onload = () => { - EmitLog({ name: 'content', payload: { msg: 'TagLink script loaded successfully' } }); - scriptInjectedRef.current = true; - script.remove(); - }; - script.onerror = () => { - EmitLog({ name: 'content', payload: { msg: 'Failed to load TagLink script', error: 'Script load error' } }); - }; - - document.documentElement.appendChild(script); - }; - injectScript(); - - const handleMessage = event => { - if (event.data && event.data.type === 'retry') { - retriesRef.current++; - EmitLog({ name: 'retry', payload: { retries: retriesRef.current } }); - injectScript(); - } - }; - window.addEventListener('message', handleMessage); - - window.addEventListener('message', function (event) { - if (event.source === window && event.data.action === 'backgroundToContent') { - const backgroundData = event.data.data; - EmitLog({ - name: 'message', - payload: { msg: 'Received message from background in content.', data: backgroundData }, - }); - } - }); - - // ------------------------------ - // Handle Requests to Tag Link - // ------------------------------ - /** - * Message listener for communication with the background script. - * - * This listener handles several types of messages: - * - 'startAutoDetection': Initiates jstag detection on the current page - * - 'getConfig': Requests current tag configuration (for tag injection) - * - 'getEntity': Requests current entity data (for tag injection) - * - 'detectionSuccess': Handles successful detection notifications - * - * The listener returns true to keep the message channel open during - * async operations, which is required for Chrome extension messaging. - * - * IMPORTANT: All message handlers respect the extension state and only - * process messages when the extension is enabled. - */ - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - // Handle async operations by returning true to keep message channel open - (async () => { - try { - // Check extension state before processing any messages - const currentState = await extensionStateStorage.get(); - if (!currentState) { - EmitLog({ - name: 'content', - payload: { msg: 'Extension disabled - ignoring message', action: message.action }, - }); - sendResponse({ success: false, error: 'Extension is disabled' }); - return; - } - - if (message.action == 'getConfig') { - window.postMessage({ action: 'getConfig' }, '*'); - } - - if (message.action == 'getEntity') { - window.postMessage({ action: 'getEntity' }, '*'); - } - - // Handle auto-detection start message from background - if (message.action === 'startAutoDetection') { - const domain = message.domain || window.location.hostname; - EmitLog({ name: 'content', payload: { msg: `Starting auto-detection for domain: ${domain}` } }); - - // Start auto-detection process - await startAutoDetection(domain); - } - - // Handle detection success notification - if (message.action === 'detectionSuccess') { - const domain = message.domain || window.location.hostname; - EmitLog({ name: 'content', payload: { msg: `Detection successful for domain: ${domain}` } }); - - // Notify background script about successful detection - try { - await chrome.runtime.sendMessage({ - action: 'recordDetection', - domain: domain, - confidence: message.confidence || 0.8, - }); - } catch (error) { - EmitLog({ - name: 'content', - payload: { msg: 'Error notifying background of detection success', error: error.message }, - }); - } - } - - sendResponse({ success: true }); - } catch (error) { - EmitLog({ name: 'content', payload: { msg: 'Error handling message', error: error.message } }); - sendResponse({ success: false, error: error.message }); - } - })(); - - // Return true to keep message channel open for async operations - return true; - }); - - // ------------------------------ - // Handle Requests From Tag Link - // ------------------------------ - - // Listen for and Store JS Tag Config - document.addEventListener('config', async function (event) { - // Check extension state before processing config - const currentState = await extensionStateStorage.get(); - if (!currentState) { - EmitLog({ name: 'content', payload: { msg: 'Extension disabled - ignoring config event' } }); - return; - } - - EmitLog({ name: 'content', payload: { msg: 'Config event received', data: (event as any).detail } }); - const payload = (event as any).detail.data; - - // Send to background script for per-tab storage - chrome.runtime.sendMessage({ - action: 'saveTagConfig', - payload: payload, - }); - - // Also save to global storage for backwards compatibility - tagConfigStore - .set(payload) - .then(() => { - EmitLog({ name: 'storage', payload: { msg: 'Tag config saved to global storage.' } }); - }) - .catch(error => { - EmitLog({ name: 'storage', payload: { msg: 'Failed to save tag config', error } }); - }); - - // Trigger auto-detection when config is received (high confidence) - const domain = window.location.hostname; - EmitLog({ - name: 'content', - payload: { msg: `Config event received - triggering auto-detection for: ${domain}` }, - }); - - chrome.runtime.sendMessage({ - action: 'recordDetection', - domain: domain, - confidence: 0.95, // High confidence since we got config data - }); - }); - - // Listen for and Store JS Tag Entity - document.addEventListener('entity', async function (event) { - // Check extension state before processing entity - const currentState = await extensionStateStorage.get(); - if (!currentState) { - EmitLog({ name: 'content', payload: { msg: 'Extension disabled - ignoring entity event' } }); - return; - } - - const payload = (event as any).detail.data; - - // Send to background script for per-tab storage - chrome.runtime.sendMessage({ - action: 'saveEntity', - payload: payload, - }); - - // Also save to global storage for backwards compatibility - entityStore.set(payload).then(() => { - EmitLog({ name: 'storage', payload: { msg: 'Entity saved to global storage.' } }); - }); - }); + injectScript(scriptInjectedRef); + setupRetryHandler(retriesRef, scriptInjectedRef); + setupBackgroundToContentListener(); + setupChromeMessageListener(); + setupConfigEventListener(); + setupEntityEventListener(); }, [isExtensionEnabled]); - return <>; + return null; } diff --git a/src/pages/sidepanel/index.tsx b/src/pages/sidepanel/index.tsx index 3f1c85f..cc95fc6 100644 --- a/src/pages/sidepanel/index.tsx +++ b/src/pages/sidepanel/index.tsx @@ -25,7 +25,7 @@ function init() { - + , From 92cbbae065b5f4a8912a04ef6f1ab7e82e7ef2ff Mon Sep 17 00:00:00 2001 From: saisanthoshpentakota Date: Tue, 28 Oct 2025 14:03:21 +0530 Subject: [PATCH 3/3] refactor: replace manual storage polling with React Query in SidePanel --- .../content/modules/autoDetection.test.ts | 206 ++++++++++++ .../content/modules/eventHandler.test.ts | 30 +- .../content/modules/messageHandler.test.ts | 132 +++++++- src/pages/content/modules/messageHandler.ts | 2 +- .../content/modules/scriptInjection.test.ts | 48 ++- src/pages/sidepanel/SidePanel.test.tsx | 207 ++++++++++++ src/pages/sidepanel/SidePanel.tsx | 16 +- .../hooks/query-hooks/useTagConfig.ts | 5 +- src/shared/message-broker/broker.test.ts | 307 +++++++++++++++++- src/shared/message-broker/broker.ts | 81 ++++- test-utils/vitest.setup.js | 2 - test-utils/vitest.setup.ts | 88 +++++ vitest.config.ts | 2 +- 13 files changed, 1068 insertions(+), 58 deletions(-) create mode 100644 src/pages/content/modules/autoDetection.test.ts create mode 100644 src/pages/sidepanel/SidePanel.test.tsx delete mode 100644 test-utils/vitest.setup.js create mode 100644 test-utils/vitest.setup.ts diff --git a/src/pages/content/modules/autoDetection.test.ts b/src/pages/content/modules/autoDetection.test.ts new file mode 100644 index 0000000..7d9dc06 --- /dev/null +++ b/src/pages/content/modules/autoDetection.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { EmitLog } from '@src/shared/components/EmitLog'; + +import { + isJstagAvailable, + notifyAutoDetectionFailed, + notifyAutoDetectionSuccess, + pollForJstag, + startAutoDetection, +} from './autoDetection'; + +vi.mock('@src/shared/components/EmitLog'); + +describe('autoDetection', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + delete (window as any).jstag; + global.chrome = { + runtime: { + sendMessage: vi.fn().mockResolvedValue({}), + }, + storage: { + local: { + get: vi.fn().mockResolvedValue({ extensionState: true }), + set: vi.fn().mockResolvedValue(undefined), + }, + }, + } as any; + }); + + describe('isJstagAvailable', () => { + it('should return true when jstag is defined', () => { + (window as any).jstag = {}; + expect(isJstagAvailable()).toBe(true); + }); + + it('should return false when jstag is undefined', () => { + delete (window as any).jstag; + expect(isJstagAvailable()).toBe(false); + }); + + it('should return false when jstag is null', () => { + (window as any).jstag = null; + expect(isJstagAvailable()).toBe(false); + }); + }); + + describe('notifyAutoDetectionSuccess', () => { + it('should send message to background with correct payload', async () => { + const domain = 'example.com'; + await notifyAutoDetectionSuccess(domain); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'recordDetection', + domain: domain, + confidence: 0.9, + }); + expect(EmitLog).toHaveBeenCalledWith({ + name: 'content', + payload: { msg: `Notified background of successful detection for: ${domain}` }, + }); + }); + + it('should handle sendMessage error gracefully', async () => { + const domain = 'example.com'; + const error = new Error('Send failed'); + (chrome.runtime.sendMessage as any).mockRejectedValue(error); + + await notifyAutoDetectionSuccess(domain); + + expect(EmitLog).toHaveBeenCalledWith({ + name: 'content', + payload: { msg: 'Error notifying background of successful detection', error: error.message }, + }); + }); + }); + + describe('notifyAutoDetectionFailed', () => { + it('should send failure message to background', async () => { + const domain = 'example.com'; + await notifyAutoDetectionFailed(domain); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'autoDetectionFailed', + domain: domain, + retryCount: 0, + }); + expect(EmitLog).toHaveBeenCalledWith({ + name: 'content', + payload: { msg: `Notified background of failed detection for: ${domain}` }, + }); + }); + + it('should handle sendMessage error gracefully', async () => { + const domain = 'example.com'; + const error = new Error('Send failed'); + (chrome.runtime.sendMessage as any).mockRejectedValue(error); + + await notifyAutoDetectionFailed(domain); + + expect(EmitLog).toHaveBeenCalledWith({ + name: 'content', + payload: { msg: 'Error notifying background of failed detection', error: error.message }, + }); + }); + }); + + describe('pollForJstag', () => { + it('should detect jstag on first check', async () => { + const domain = 'example.com'; + (window as any).jstag = {}; + + pollForJstag(domain); + + await vi.runAllTimersAsync(); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'recordDetection', + domain: domain, + confidence: 0.9, + }); + }); + + it('should retry and eventually find jstag', async () => { + const domain = 'example.com'; + + pollForJstag(domain); + + await vi.advanceTimersByTimeAsync(750); + expect(EmitLog).toHaveBeenCalledWith({ + name: 'content', + payload: { msg: 'Auto-detection retry 1/5' }, + }); + + (window as any).jstag = {}; + await vi.advanceTimersByTimeAsync(750); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'recordDetection', + domain: domain, + confidence: 0.9, + }); + }); + + it('should fail after max retries', async () => { + const domain = 'example.com'; + + pollForJstag(domain); + + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(750); + } + + expect(EmitLog).toHaveBeenCalledWith({ + name: 'content', + payload: { msg: 'Auto-detection failed - no jstag found after max retries' }, + }); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'autoDetectionFailed', + domain: domain, + retryCount: 0, + }); + }); + }); + + describe('startAutoDetection', () => { + it('should detect jstag immediately if available', async () => { + const domain = 'example.com'; + (window as any).jstag = {}; + + await startAutoDetection(domain); + + expect(EmitLog).toHaveBeenCalledWith({ + name: 'content', + payload: { msg: 'Lytics jstag found immediately' }, + }); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'recordDetection', + domain: domain, + confidence: 0.9, + }); + }); + + it('should start polling if jstag not immediately available', async () => { + const domain = 'example.com'; + + await startAutoDetection(domain); + + expect(EmitLog).toHaveBeenCalledWith({ + name: 'content', + payload: { msg: `Starting auto-detection for domain: ${domain}` }, + }); + + (window as any).jstag = {}; + await vi.runAllTimersAsync(); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'recordDetection', + domain: domain, + confidence: 0.9, + }); + }); + }); +}); diff --git a/src/pages/content/modules/eventHandler.test.ts b/src/pages/content/modules/eventHandler.test.ts index 15a0ed8..fb20c9f 100644 --- a/src/pages/content/modules/eventHandler.test.ts +++ b/src/pages/content/modules/eventHandler.test.ts @@ -73,7 +73,7 @@ describe('Event Handler Module', () => { const configData = { cid: 'test-cid', stream: 'test-stream' }; const event = new CustomEvent('config', { - detail: { data: JSON.stringify(configData) }, + detail: { data: configData }, }); document.dispatchEvent(event); @@ -82,10 +82,10 @@ describe('Event Handler Module', () => { expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ action: 'saveTagConfig', - payload: JSON.stringify(configData), + payload: configData, }); - expect(mockTagConfigStore.set).toHaveBeenCalledWith(JSON.stringify(configData)); + expect(mockTagConfigStore.set).toHaveBeenCalledWith(configData); expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ action: 'recordDetection', @@ -101,7 +101,7 @@ describe('Event Handler Module', () => { const configData = { cid: 'test-cid', stream: 'test-stream' }; const event = new CustomEvent('config', { - detail: { data: JSON.stringify(configData) }, + detail: { data: configData }, }); document.dispatchEvent(event); @@ -119,7 +119,7 @@ describe('Event Handler Module', () => { const configData = { cid: 'test-cid', stream: 'test-stream' }; const event = new CustomEvent('config', { - detail: { data: JSON.stringify(configData) }, + detail: { data: configData }, }); document.dispatchEvent(event); @@ -128,7 +128,7 @@ describe('Event Handler Module', () => { expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ action: 'saveTagConfig', - payload: JSON.stringify(configData), + payload: configData, }); }); }); @@ -146,7 +146,7 @@ describe('Event Handler Module', () => { const entityData = { _uid: 'test-uid', email: 'test@example.com' }; const event = new CustomEvent('entity', { - detail: { data: JSON.stringify(entityData) }, + detail: { data: entityData }, }); document.dispatchEvent(event); @@ -155,10 +155,10 @@ describe('Event Handler Module', () => { expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ action: 'saveEntity', - payload: JSON.stringify(entityData), + payload: entityData, }); - expect(mockEntityStore.set).toHaveBeenCalledWith(JSON.stringify(entityData)); + expect(mockEntityStore.set).toHaveBeenCalledWith(entityData); }); test('should not process entity event when extension is disabled', async () => { @@ -168,7 +168,7 @@ describe('Event Handler Module', () => { const entityData = { _uid: 'test-uid', email: 'test@example.com' }; const event = new CustomEvent('entity', { - detail: { data: JSON.stringify(entityData) }, + detail: { data: entityData }, }); document.dispatchEvent(event); @@ -184,14 +184,14 @@ describe('Event Handler Module', () => { const entityData = { _uid: 'test-uid', email: 'test@example.com' }; const event = new CustomEvent('entity', { - detail: { data: JSON.stringify(entityData) }, + detail: { data: entityData }, }); document.dispatchEvent(event); await new Promise(resolve => setTimeout(resolve, 0)); - expect(mockEntityStore.set).toHaveBeenCalledWith(JSON.stringify(entityData)); + expect(mockEntityStore.set).toHaveBeenCalledWith(entityData); }); }); @@ -203,7 +203,7 @@ describe('Event Handler Module', () => { const configData = { cid: 'test-cid', stream: 'test-stream' }; const event = new CustomEvent('config', { - detail: { data: JSON.stringify(configData) }, + detail: { data: configData }, }); document.dispatchEvent(event); @@ -220,7 +220,7 @@ describe('Event Handler Module', () => { const entityData = { _uid: 'test-uid', email: 'test@example.com' }; const event = new CustomEvent('entity', { - detail: { data: JSON.stringify(entityData) }, + detail: { data: entityData }, }); document.dispatchEvent(event); @@ -237,7 +237,7 @@ describe('Event Handler Module', () => { const configData = { cid: 'test-cid', stream: 'test-stream' }; const event = new CustomEvent('config', { - detail: { data: JSON.stringify(configData) }, + detail: { data: configData }, }); document.dispatchEvent(event); diff --git a/src/pages/content/modules/messageHandler.test.ts b/src/pages/content/modules/messageHandler.test.ts index 4f7b172..812e896 100644 --- a/src/pages/content/modules/messageHandler.test.ts +++ b/src/pages/content/modules/messageHandler.test.ts @@ -39,6 +39,7 @@ Object.defineProperty(window, 'location', { describe('Message Handler Module', () => { let mockExtensionStateStorage: any; let mockStartAutoDetection: any; + let mockMessageBroker: any; let messageListener: any; beforeEach(async () => { @@ -51,6 +52,9 @@ describe('Message Handler Module', () => { const autoDetection = await import('./autoDetection'); mockStartAutoDetection = autoDetection.startAutoDetection; + const messageBroker = await import('../../../shared/message-broker'); + mockMessageBroker = messageBroker.messageBroker; + global.chrome.runtime.onMessage.addListener = vi.fn(listener => { messageListener = listener; }); @@ -69,6 +73,18 @@ describe('Message Handler Module', () => { expect(global.chrome.runtime.onMessage.addListener).toHaveBeenCalled(); }); + test('should ignore messages with key property', async () => { + setupChromeMessageListener(); + + const message = { key: 'someKey', action: 'getConfig' }; + const sendResponse = vi.fn(); + + const result = messageListener(message, {}, sendResponse); + + expect(result).toBeUndefined(); + expect(sendResponse).not.toHaveBeenCalled(); + }); + test('should handle getConfig message', async () => { setupChromeMessageListener(); @@ -143,6 +159,24 @@ describe('Message Handler Module', () => { expect(sendResponse).toHaveBeenCalledWith({ success: true }); }); + test('should handle detectionSuccess with default domain and confidence', async () => { + setupChromeMessageListener(); + + const message = { action: 'detectionSuccess' }; + const sendResponse = vi.fn(); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(global.chrome.runtime.sendMessage).toHaveBeenCalledWith({ + action: 'recordDetection', + domain: 'example.com', + confidence: 0.8, + }); + expect(sendResponse).toHaveBeenCalledWith({ success: true }); + }); + test('should not process messages when extension is disabled', async () => { vi.mocked(mockExtensionStateStorage.get).mockResolvedValue(false); @@ -174,16 +208,106 @@ describe('Message Handler Module', () => { expect(sendResponse).toHaveBeenCalledWith({ success: false, error: 'Test error' }); }); - test('should ignore messages with key property', async () => { + test('should handle detectionSuccess sendMessage error', async () => { + const error = new Error('Send failed'); + vi.mocked(global.chrome.runtime.sendMessage).mockRejectedValue(error); + setupChromeMessageListener(); - const message = { key: 'someKey', action: 'getConfig' }; + const message = { action: 'detectionSuccess' }; + const sendResponse = vi.fn(); + + await messageListener(message, {}, sendResponse); + + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(sendResponse).toHaveBeenCalledWith({ success: true }); + }); + + test('should return true to indicate async response', () => { + setupChromeMessageListener(); + + const message = { action: 'getConfig' }; const sendResponse = vi.fn(); const result = messageListener(message, {}, sendResponse); - expect(result).toBeUndefined(); - expect(sendResponse).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe('messageBroker GET_CONFIG handler', () => { + test('should register GET_CONFIG handler', () => { + setupChromeMessageListener(); + + expect(mockMessageBroker.handle).toHaveBeenCalledWith('GET_CONFIG', expect.any(Function)); + }); + + test('should resolve config from custom event', async () => { + let getConfigHandler: any; + vi.mocked(mockMessageBroker.handle).mockImplementation((action, handler) => { + if (action === 'GET_CONFIG') { + getConfigHandler = handler; + } + }); + + setupChromeMessageListener(); + + const configPromise = getConfigHandler(); + + setTimeout(() => { + const mockConfig = { setting: 'value' }; + const event = new CustomEvent('config', { + detail: { data: JSON.stringify(mockConfig) }, + }); + document.dispatchEvent(event); + }, 100); + + const result = await configPromise; + expect(result).toEqual({ setting: 'value' }); + }); + + test('should timeout if config not received', async () => { + vi.useFakeTimers(); + + let getConfigHandler: any; + vi.mocked(mockMessageBroker.handle).mockImplementation((action, handler) => { + if (action === 'GET_CONFIG') { + getConfigHandler = handler; + } + }); + + setupChromeMessageListener(); + + const configPromise = getConfigHandler(); + + vi.advanceTimersByTime(2000); + + await expect(configPromise).rejects.toThrow('Config retrieval timeout after 2 seconds'); + + vi.useRealTimers(); + }); + + test('should handle JSON parse error', async () => { + let getConfigHandler: any; + vi.mocked(mockMessageBroker.handle).mockImplementation((action, handler) => { + if (action === 'GET_CONFIG') { + getConfigHandler = handler; + } + }); + + setupChromeMessageListener(); + + const configPromise = getConfigHandler(); + + setTimeout(() => { + const event = new CustomEvent('config', { + detail: { data: 'invalid json' }, + }); + document.dispatchEvent(event); + }, 100); + + await expect(configPromise).rejects.toThrow(/Failed to parse config data/); }); }); }); diff --git a/src/pages/content/modules/messageHandler.ts b/src/pages/content/modules/messageHandler.ts index 7224d72..b15b5e7 100644 --- a/src/pages/content/modules/messageHandler.ts +++ b/src/pages/content/modules/messageHandler.ts @@ -70,7 +70,7 @@ export const setupChromeMessageListener = () => { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { document.removeEventListener('config', configHandler); - reject(new Error(`Config retrieval timeout after ${TIMEOUT_MS / 100} seconds`)); + reject(new Error(`Config retrieval timeout after ${TIMEOUT_MS / 1000} seconds`)); }, TIMEOUT_MS); const configHandler = (event: CustomEvent) => { diff --git a/src/pages/content/modules/scriptInjection.test.ts b/src/pages/content/modules/scriptInjection.test.ts index dde480b..bceceae 100644 --- a/src/pages/content/modules/scriptInjection.test.ts +++ b/src/pages/content/modules/scriptInjection.test.ts @@ -1,3 +1,5 @@ +import React from 'react'; + import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { injectScript, setupBackgroundToContentListener, setupRetryHandler } from './scriptInjection'; @@ -91,7 +93,7 @@ describe('Script Injection Module', () => { describe('setupRetryHandler', () => { test('should handle retry messages', () => { - const retriesRef = { current: 0 }; + const retriesRef = { current: 0 } as React.MutableRefObject; const scriptInjectedRef = { current: false }; setupRetryHandler(retriesRef, scriptInjectedRef); @@ -106,7 +108,7 @@ describe('Script Injection Module', () => { }); test('should not handle non-retry messages', () => { - const retriesRef = { current: 0 }; + const retriesRef = { current: 0 } as React.MutableRefObject; const scriptInjectedRef = { current: false }; setupRetryHandler(retriesRef, scriptInjectedRef); @@ -119,6 +121,41 @@ describe('Script Injection Module', () => { expect(retriesRef.current).toBe(0); }); + + test('should inject script on retry message', () => { + const retriesRef = { current: 0 } as React.MutableRefObject; + const scriptInjectedRef = { current: false }; + + setupRetryHandler(retriesRef, scriptInjectedRef); + + const retryEvent = { + data: { type: 'retry' }, + }; + + window.dispatchEvent(new MessageEvent('message', { data: retryEvent.data })); + + expect(document.createElement).toHaveBeenCalledWith('script'); + }); + + test('should handle multiple retries', () => { + const retriesRef = { current: 0 } as React.MutableRefObject; + const scriptInjectedRef = { current: false }; + + setupRetryHandler(retriesRef, scriptInjectedRef); + + window.dispatchEvent(new MessageEvent('message', { data: { type: 'retry' } })); + window.dispatchEvent(new MessageEvent('message', { data: { type: 'retry' } })); + + expect(retriesRef.current).toBe(2); + }); + + test('should return handler function', () => { + const retriesRef = { current: 0 } as React.MutableRefObject; + const handler = setupRetryHandler(retriesRef); + + expect(handler).toBeDefined(); + expect(typeof handler).toBe('function'); + }); }); describe('setupBackgroundToContentListener', () => { @@ -163,5 +200,12 @@ describe('Script Injection Module', () => { window.dispatchEvent(new MessageEvent('message', { ...differentActionEvent })); }); + + test('should return handler function', () => { + const handler = setupBackgroundToContentListener(); + + expect(handler).toBeDefined(); + expect(typeof handler).toBe('function'); + }); }); }); diff --git a/src/pages/sidepanel/SidePanel.test.tsx b/src/pages/sidepanel/SidePanel.test.tsx new file mode 100644 index 0000000..9e8df56 --- /dev/null +++ b/src/pages/sidepanel/SidePanel.test.tsx @@ -0,0 +1,207 @@ +import '@testing-library/jest-dom'; + +import React from 'react'; + +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ThemeProvider } from '@mui/material/styles'; +import { TagConfigModel } from '@root/src/shared/models/tagConfigModel'; +import { appTheme } from '@root/src/theme'; +import { render, screen, waitFor } from '@testing-library/react'; + +import * as useCurrentTabStateModule from './hooks/useCurrentTabState'; +import SidePanel from './SidePanel'; + +vi.mock('@src/shared/components/EmitLog', () => ({ + EmitLog: vi.fn(), +})); + +vi.mock('@src/shared/storages/entityStorage', () => ({ + default: { + get: vi.fn().mockResolvedValue('{}'), + }, +})); + +const renderWithRouter = (component: React.ReactElement) => { + return render( + + {component} + , + ); +}; + +describe('SidePanel', () => { + const mockTagConfig: TagConfigModel = { + version: '4.2.1', + cid: ['test-account-123'], + stream: 'production', + cookie: 'seerid', + entity: { byFieldKey: 'email' }, + loadid: true, + pathfora: { + publish: { + candidates: { + experiences: [], + variations: [], + legacyABTests: [], + }, + }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('when component is loading', () => { + it('shows loading spinner', () => { + vi.spyOn(useCurrentTabStateModule, 'useCurrentTabState').mockReturnValue({ + currentTabId: '1', + domainState: null, + isLoading: true, + domain: 'example.com', + url: 'https://example.com', + isPinned: false, + tagActivity: [], + tagConfig: null, + profile: null, + pinCurrentDomain: vi.fn(), + unpinCurrentDomain: vi.fn(), + clearActivity: vi.fn(), + refreshDomainState: vi.fn(), + }); + + renderWithRouter(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + }); + + describe('when tag data is loaded', () => { + it('renders TagStatus with correct props when tag is installed', async () => { + vi.spyOn(useCurrentTabStateModule, 'useCurrentTabState').mockReturnValue({ + currentTabId: '1', + domainState: { + domain: 'example.com', + tagConfig: mockTagConfig, + profile: null, + tagActivity: [], + isPinned: false, + lastUpdated: Date.now(), + activeTabIds: ['1'], + }, + isLoading: false, + domain: 'example.com', + url: 'https://example.com', + isPinned: false, + tagActivity: [], + tagConfig: mockTagConfig, + profile: null, + pinCurrentDomain: vi.fn(), + unpinCurrentDomain: vi.fn(), + clearActivity: vi.fn(), + refreshDomainState: vi.fn(), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + + expect(screen.getByText('Lytics JavaScript SDK Installed')).toBeInTheDocument(); + expect(screen.getByText('v4.2.1')).toBeInTheDocument(); + }); + + it('renders BottomNavigation when data is loaded', async () => { + vi.spyOn(useCurrentTabStateModule, 'useCurrentTabState').mockReturnValue({ + currentTabId: '1', + domainState: { + domain: 'example.com', + tagConfig: mockTagConfig, + profile: null, + tagActivity: [], + isPinned: false, + lastUpdated: Date.now(), + activeTabIds: ['1'], + }, + isLoading: false, + domain: 'example.com', + url: 'https://example.com', + isPinned: false, + tagActivity: [], + tagConfig: mockTagConfig, + profile: null, + pinCurrentDomain: vi.fn(), + unpinCurrentDomain: vi.fn(), + clearActivity: vi.fn(), + refreshDomainState: vi.fn(), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + }); + }); + + describe('when tag is not installed', () => { + it('shows searching message when tag is not installed', async () => { + vi.spyOn(useCurrentTabStateModule, 'useCurrentTabState').mockReturnValue({ + currentTabId: '1', + domainState: { + domain: 'example.com', + tagConfig: null, + profile: null, + tagActivity: [], + isPinned: false, + lastUpdated: Date.now(), + activeTabIds: ['1'], + }, + isLoading: false, + domain: 'example.com', + url: 'https://example.com', + isPinned: false, + tagActivity: [], + tagConfig: null, + profile: null, + pinCurrentDomain: vi.fn(), + unpinCurrentDomain: vi.fn(), + clearActivity: vi.fn(), + refreshDomainState: vi.fn(), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Searching for Lytics JavaScript SDK')).toBeInTheDocument(); + }); + }); + }); + + describe('when extension is disabled', () => { + it('still uses useCurrentTabState hook', () => { + const mockUseCurrentTabState = vi.spyOn(useCurrentTabStateModule, 'useCurrentTabState').mockReturnValue({ + currentTabId: '1', + domainState: null, + isLoading: true, + domain: 'example.com', + url: 'https://example.com', + isPinned: false, + tagActivity: [], + tagConfig: null, + profile: null, + pinCurrentDomain: vi.fn(), + unpinCurrentDomain: vi.fn(), + clearActivity: vi.fn(), + refreshDomainState: vi.fn(), + }); + + renderWithRouter(); + + expect(mockUseCurrentTabState).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/pages/sidepanel/SidePanel.tsx b/src/pages/sidepanel/SidePanel.tsx index d0c3344..f81dca6 100644 --- a/src/pages/sidepanel/SidePanel.tsx +++ b/src/pages/sidepanel/SidePanel.tsx @@ -1,19 +1,19 @@ import '@pages/sidepanel/SidePanel.css'; -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Box, CircularProgress } from '@mui/material'; import BottomNavigation from '@root/src/pages/sidepanel/components/BottomNavigation'; +import { useCurrentTabState } from '@root/src/pages/sidepanel/hooks/useCurrentTabState'; import Configuration from '@root/src/pages/sidepanel/sections/Configuration'; import Debugger from '@root/src/pages/sidepanel/sections/Debugger'; import Personalization from '@root/src/pages/sidepanel/sections/Personalization'; import Profile from '@root/src/pages/sidepanel/sections/Profile'; +import { hasProfile, hasTagConfig } from '@root/src/pages/sidepanel/utils/domainStateHelpers'; import { TagConfigModel } from '@root/src/shared/models/tagConfigModel'; import { EmitLog } from '@src/shared/components/EmitLog'; -import { useCurrentTabState } from '@root/src/pages/sidepanel/hooks/useCurrentTabState'; -import { hasTagConfig, hasProfile } from '@root/src/pages/sidepanel/utils/domainStateHelpers'; import TagStatus from './sections/TagStatus'; @@ -26,33 +26,26 @@ const SidePanel: React.FC = ({ key, isEnabled }) => { const navigate = useNavigate(); const location = useLocation(); - // Get per-tab state from hook const { tagConfig: currentTabTagConfig, profile: currentTabProfile, domainState } = useCurrentTabState(); - // Local state for component-specific concerns const [isLoading, setIsLoading] = useState(true); const [profileIsLoading, setProfileIsLoading] = useState(false); const [debugTab, setDebugTab] = useState(0); const [profileTab, setProfileTab] = useState(0); const [personalizationTab, setPersonalizationTab] = useState(0); - // DERIVED STATE - No useEffect needed! Pure computation from props/state const tagConfig = currentTabTagConfig as TagConfigModel; const currentProfile = currentTabProfile; const tagIsInstalled = hasTagConfig(domainState); const candidates = useMemo(() => tagConfig?.pathfora?.publish?.candidates || {}, [tagConfig]); - // Compute active path from location (derived state) const activePath = useMemo(() => { return location.pathname === '/src/pages/sidepanel/index.html' ? '/' : location.pathname; }, [location.pathname]); - // Single useEffect for data fetching fallback and logging useEffect(() => { - // Log reset on key change EmitLog({ name: 'sidepanel', payload: { msg: 'SidePanel reset.' } }); - // Handle tag config loading if (hasTagConfig(domainState)) { setIsLoading(false); EmitLog({ @@ -60,7 +53,6 @@ const SidePanel: React.FC = ({ key, isEnabled }) => { payload: { msg: 'Tag config updated from per-tab storage', config: currentTabTagConfig }, }); } else if (isEnabled) { - // Request config from content script as fallback setIsLoading(true); chrome.tabs.query({ active: true, currentWindow: true }, tabs => { if (tabs[0]?.id) { @@ -79,7 +71,6 @@ const SidePanel: React.FC = ({ key, isEnabled }) => { }); } - // Handle profile loading if (hasProfile(domainState)) { setProfileIsLoading(false); EmitLog({ @@ -87,7 +78,6 @@ const SidePanel: React.FC = ({ key, isEnabled }) => { payload: { msg: 'Profile updated from per-tab storage' }, }); } else if (isEnabled) { - // Request profile from content script as fallback setProfileIsLoading(true); chrome.tabs.query({ active: true, currentWindow: true }, tabs => { if (tabs[0]?.id) { diff --git a/src/pages/sidepanel/hooks/query-hooks/useTagConfig.ts b/src/pages/sidepanel/hooks/query-hooks/useTagConfig.ts index f035fd9..dbafa62 100644 --- a/src/pages/sidepanel/hooks/query-hooks/useTagConfig.ts +++ b/src/pages/sidepanel/hooks/query-hooks/useTagConfig.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { messageBroker } from '../../../../shared/message-broker'; import { IMessage } from '../../../../shared/message-broker/types'; +import { TagConfigModel } from '../../../../shared/models/tagConfigModel'; import { useCurrentTab } from '../useCurrentTab'; const getMessageRequest = ( @@ -15,14 +16,14 @@ const getMessageRequest = ( }, }); -const getTagConfig = async (currentTabId: number) => { +const getTagConfig = async (currentTabId: number): Promise => { return await messageBroker.send(getMessageRequest(currentTabId)); }; export const useTagConfig = () => { const currentTab = useCurrentTab(); - return useQuery({ + return useQuery({ queryKey: ['tagConfig', currentTab?.url, currentTab?.id], queryFn: () => getTagConfig(currentTab.id), enabled: !!currentTab?.id, diff --git a/src/shared/message-broker/broker.test.ts b/src/shared/message-broker/broker.test.ts index 57de3c9..4a9aa0b 100644 --- a/src/shared/message-broker/broker.test.ts +++ b/src/shared/message-broker/broker.test.ts @@ -1,31 +1,127 @@ -import { type MockedFunction, vi } from 'vitest'; +import { beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; import { MessageBroker } from './broker'; +import { IMessage } from './types'; -const mockChrome = { - runtime: { - sendMessage: vi.fn(), - }, -} as unknown as typeof chrome; +describe('MessageBroker', () => { + let mockBrowser: typeof chrome; + let mockSendMessage: MockedFunction<(message: any) => Promise>; + let mockTabsSendMessage: MockedFunction<(tabId: number, message: any) => Promise>; -const mockBrowser = mockChrome; + beforeEach(() => { + vi.clearAllMocks(); + + mockSendMessage = vi.fn().mockResolvedValue({}); + mockTabsSendMessage = vi.fn().mockResolvedValue({}); + + mockBrowser = { + runtime: { + sendMessage: mockSendMessage as any, + lastError: undefined, + onMessage: { + addListener: vi.fn() as any, + } as any, + }, + tabs: { + sendMessage: mockTabsSendMessage as any, + } as any, + } as unknown as typeof chrome; + }); -describe('MessageBroker', () => { describe('send', () => { - it('calls sends message to background', async () => { - const mockResponse = { success: true, tag: '' }; - (mockBrowser.runtime.sendMessage as MockedFunction).mockResolvedValue(mockResponse); + it('sends message successfully and returns response', async () => { + const mockResponse = { success: true, data: 'test' }; + mockSendMessage.mockResolvedValue(mockResponse); const broker = new MessageBroker(mockBrowser); const result = await broker.send({ key: 'GET_CONFIG' }); expect(result).toEqual(mockResponse); - expect(mockBrowser.runtime.sendMessage).toHaveBeenCalled(); + expect(mockSendMessage).toHaveBeenCalledWith({ key: 'GET_CONFIG' }); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + }); + + it('throws error when message has no key', async () => { + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send({} as IMessage)).rejects.toThrow('Invalid message: key is required'); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it('throws error when message is null', async () => { + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send(null as unknown as IMessage)).rejects.toThrow('Invalid message: key is required'); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it('throws error when chrome runtime is not available', async () => { + const browserWithoutRuntime = {} as typeof chrome; + const broker = new MessageBroker(browserWithoutRuntime); + + await expect(broker.send({ key: 'GET_CONFIG' })).rejects.toThrow('Chrome runtime is not available'); + }); + + it('throws error when chrome runtime.lastError is set', async () => { + mockSendMessage.mockResolvedValue({}); + mockBrowser.runtime.lastError = { message: 'Runtime error occurred' }; + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send({ key: 'GET_CONFIG' })).rejects.toThrow('Runtime error occurred'); + }); + + it('throws error when chrome runtime.lastError has no message', async () => { + mockSendMessage.mockResolvedValue({}); + mockBrowser.runtime.lastError = {} as chrome.runtime.LastError; + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send({ key: 'GET_CONFIG' })).rejects.toThrow('Unknown runtime error'); + }); + + it('throws error when response contains error property', async () => { + mockSendMessage.mockResolvedValue({ error: 'Custom error message' }); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send({ key: 'GET_CONFIG' })).rejects.toThrow('Custom error message'); + }); + + it('throws specific error for "Receiving end does not exist"', async () => { + mockSendMessage.mockRejectedValue(new Error('Receiving end does not exist')); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send({ key: 'GET_CONFIG' })).rejects.toThrow( + 'Message receiver not available. The extension context may have been invalidated.', + ); + }); + + it('throws specific error for "Extension context invalidated"', async () => { + mockSendMessage.mockRejectedValue(new Error('Extension context invalidated')); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send({ key: 'GET_CONFIG' })).rejects.toThrow( + 'Extension context has been invalidated. Please reload the extension.', + ); + }); + + it('throws generic error for unknown errors', async () => { + mockSendMessage.mockRejectedValue(new Error('Unknown error')); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send({ key: 'GET_CONFIG' })).rejects.toThrow('Failed to send message: Unknown error'); + }); + + it('handles non-Error thrown values', async () => { + mockSendMessage.mockRejectedValue('String error'); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.send({ key: 'GET_CONFIG' })).rejects.toThrow( + 'Failed to send message: Unknown error occurred', + ); }); }); describe('handle', () => { - let mockAddListener: MockedFunction; + let mockAddListener: MockedFunction<(callback: (...args: any[]) => any) => void>; beforeEach(() => { mockAddListener = vi.fn(); @@ -43,5 +139,190 @@ describe('MessageBroker', () => { expect(mockAddListener).toHaveBeenCalledTimes(1); expect(mockAddListener).toHaveBeenCalledWith(expect.any(Function)); }); + + it('calls handler when message with matching key is received', async () => { + const broker = new MessageBroker(mockBrowser); + const mockHandler = vi.fn().mockResolvedValue({ success: true }); + const mockSendResponse = vi.fn(); + + broker.handle('GET_CONFIG', mockHandler); + + const listener = mockAddListener.mock.calls[0][0] as ( + message: any, + sender: any, + sendResponse: (response?: any) => void, + ) => boolean | undefined; + const result = listener({ key: 'GET_CONFIG', payload: 'test' }, {}, mockSendResponse); + + expect(result).toBe(true); + await vi.waitFor(() => { + expect(mockHandler).toHaveBeenCalledWith({ key: 'GET_CONFIG', payload: 'test' }); + expect(mockSendResponse).toHaveBeenCalledWith({ success: true }); + }); + }); + + it('does not call handler when message key does not match', () => { + const broker = new MessageBroker(mockBrowser); + const mockHandler = vi.fn().mockResolvedValue({ success: true }); + const mockSendResponse = vi.fn(); + + broker.handle('GET_CONFIG', mockHandler); + + const listener = mockAddListener.mock.calls[0][0] as ( + message: any, + sender: any, + sendResponse: (response?: any) => void, + ) => boolean | undefined; + const result = listener({ key: 'OTHER_KEY', payload: 'test' }, {}, mockSendResponse); + + expect(result).toBeUndefined(); + expect(mockHandler).not.toHaveBeenCalled(); + expect(mockSendResponse).not.toHaveBeenCalled(); + }); + + it('sends error response when handler throws error', async () => { + const broker = new MessageBroker(mockBrowser); + const mockHandler = vi.fn().mockRejectedValue(new Error('Handler error')); + const mockSendResponse = vi.fn(); + + broker.handle('GET_CONFIG', mockHandler); + + const listener = mockAddListener.mock.calls[0][0] as ( + message: any, + sender: any, + sendResponse: (response?: any) => void, + ) => boolean | undefined; + listener({ key: 'GET_CONFIG' }, {}, mockSendResponse); + + await vi.waitFor(() => { + expect(mockSendResponse).toHaveBeenCalledWith({ error: 'Handler error' }); + }); + }); + + it('sends generic error response when handler throws non-Error value', async () => { + const broker = new MessageBroker(mockBrowser); + const mockHandler = vi.fn().mockRejectedValue('String error'); + const mockSendResponse = vi.fn(); + + broker.handle('GET_CONFIG', mockHandler); + + const listener = mockAddListener.mock.calls[0][0] as ( + message: any, + sender: any, + sendResponse: (response?: any) => void, + ) => boolean | undefined; + listener({ key: 'GET_CONFIG' }, {}, mockSendResponse); + + await vi.waitFor(() => { + expect(mockSendResponse).toHaveBeenCalledWith({ error: 'Unknown error occurred' }); + }); + }); + }); + + describe('sendToTab', () => { + it('sends message to tab successfully', async () => { + const mockResponse = { success: true, data: 'test' }; + mockTabsSendMessage.mockResolvedValue(mockResponse); + const broker = new MessageBroker(mockBrowser); + + const result = await broker.sendToTab(123, { key: 'GET_CONFIG' }); + + expect(result).toEqual(mockResponse); + expect(mockTabsSendMessage).toHaveBeenCalledWith(123, { key: 'GET_CONFIG' }); + expect(mockTabsSendMessage).toHaveBeenCalledTimes(1); + }); + + it('throws error when tabId is negative', async () => { + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(-1, { key: 'GET_CONFIG' })).rejects.toThrow( + 'Invalid tabId: must be a positive integer', + ); + expect(mockTabsSendMessage).not.toHaveBeenCalled(); + }); + + it('throws error when tabId is not an integer', async () => { + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(123.45, { key: 'GET_CONFIG' })).rejects.toThrow( + 'Invalid tabId: must be a positive integer', + ); + expect(mockTabsSendMessage).not.toHaveBeenCalled(); + }); + + it('throws error when message has no key', async () => { + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(123, {} as IMessage)).rejects.toThrow('Invalid message: key is required'); + expect(mockTabsSendMessage).not.toHaveBeenCalled(); + }); + + it('throws error when message is null', async () => { + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(123, null as unknown as IMessage)).rejects.toThrow( + 'Invalid message: key is required', + ); + expect(mockTabsSendMessage).not.toHaveBeenCalled(); + }); + + it('throws error when chrome tabs API is not available', async () => { + const browserWithoutTabs = { + runtime: mockBrowser.runtime, + } as typeof chrome; + const broker = new MessageBroker(browserWithoutTabs); + + await expect(broker.sendToTab(123, { key: 'GET_CONFIG' })).rejects.toThrow('Chrome tabs API is not available'); + }); + + it('throws error when chrome runtime.lastError is set', async () => { + mockTabsSendMessage.mockResolvedValue({}); + mockBrowser.runtime.lastError = { message: 'Tab error occurred' }; + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(123, { key: 'GET_CONFIG' })).rejects.toThrow('Tab error occurred'); + }); + + it('throws error when chrome runtime.lastError has no message', async () => { + mockTabsSendMessage.mockResolvedValue({}); + mockBrowser.runtime.lastError = {} as chrome.runtime.LastError; + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(123, { key: 'GET_CONFIG' })).rejects.toThrow('Unknown runtime error'); + }); + + it('throws specific error for "Receiving end does not exist" in tab', async () => { + mockTabsSendMessage.mockRejectedValue(new Error('Receiving end does not exist')); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(123, { key: 'GET_CONFIG' })).rejects.toThrow( + 'Tab 123 is not available or does not have a message receiver', + ); + }); + + it('throws specific error for "No tab with id"', async () => { + mockTabsSendMessage.mockRejectedValue(new Error('No tab with id: 999')); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(999, { key: 'GET_CONFIG' })).rejects.toThrow('Tab with id 999 does not exist'); + }); + + it('throws generic error for unknown errors', async () => { + mockTabsSendMessage.mockRejectedValue(new Error('Unknown tab error')); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(123, { key: 'GET_CONFIG' })).rejects.toThrow( + 'Failed to send message to tab 123: Unknown tab error', + ); + }); + + it('handles non-Error thrown values', async () => { + mockTabsSendMessage.mockRejectedValue('String error'); + const broker = new MessageBroker(mockBrowser); + + await expect(broker.sendToTab(123, { key: 'GET_CONFIG' })).rejects.toThrow( + 'Failed to send message to tab 123: Unknown error occurred', + ); + }); }); }); diff --git a/src/shared/message-broker/broker.ts b/src/shared/message-broker/broker.ts index 3ee92ea..cdcb8f3 100644 --- a/src/shared/message-broker/broker.ts +++ b/src/shared/message-broker/broker.ts @@ -4,15 +4,54 @@ export class MessageBroker { constructor(private readonly browser: typeof chrome) {} async send(message: IMessage): Promise { - return await this.browser.runtime.sendMessage(message); + if (!message || !message.key) { + throw new Error('Invalid message: key is required'); + } + + try { + if (!this.browser?.runtime) { + throw new Error('Chrome runtime is not available'); + } + + const response = await this.browser.runtime.sendMessage(message); + + if (this.browser.runtime.lastError) { + throw new Error(this.browser.runtime.lastError.message || 'Unknown runtime error'); + } + + if (response.error) { + throw new Error(response.error); + } + + return response; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + + if (errorMessage.includes('Receiving end does not exist')) { + throw new Error('Message receiver not available. The extension context may have been invalidated.'); + } + + if (errorMessage.includes('Extension context invalidated')) { + throw new Error('Extension context has been invalidated. Please reload the extension.'); + } + + throw new Error(`Failed to send message: ${errorMessage}`); + } } handle(key: IMessage['key'], handler: (message: IMessage) => Promise): void { this.browser.runtime.onMessage.addListener((incomingMessage: IMessage, sender, sendResponse) => { - if (incomingMessage.key === key) { + if (incomingMessage?.key === key) { Promise.resolve(handler(incomingMessage)) - .then(result => sendResponse(result)) - .catch(error => sendResponse(error.message)); + .then(result => { + sendResponse(result); + }) + .catch(error => { + const errorResponse = { + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + sendResponse(errorResponse); + }); return true; } @@ -20,6 +59,38 @@ export class MessageBroker { } async sendToTab(tabId: number, message: IMessage): Promise { - return await this.browser.tabs.sendMessage(tabId, message); + if (!Number.isInteger(tabId) || tabId < 0) { + throw new Error('Invalid tabId: must be a positive integer'); + } + + if (!message || !message.key) { + throw new Error('Invalid message: key is required'); + } + + try { + if (!this.browser?.tabs) { + throw new Error('Chrome tabs API is not available'); + } + + const response = await this.browser.tabs.sendMessage(tabId, message); + + if (this.browser.runtime.lastError) { + throw new Error(this.browser.runtime.lastError.message || 'Unknown runtime error'); + } + + return response; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + + if (errorMessage.includes('Receiving end does not exist')) { + throw new Error(`Tab ${tabId} is not available or does not have a message receiver`); + } + + if (errorMessage.includes('No tab with id')) { + throw new Error(`Tab with id ${tabId} does not exist`); + } + + throw new Error(`Failed to send message to tab ${tabId}: ${errorMessage}`); + } } } diff --git a/test-utils/vitest.setup.js b/test-utils/vitest.setup.js deleted file mode 100644 index bab8e81..0000000 --- a/test-utils/vitest.setup.js +++ /dev/null @@ -1,2 +0,0 @@ -// Setup file for Vitest tests -import '@testing-library/jest-dom'; diff --git a/test-utils/vitest.setup.ts b/test-utils/vitest.setup.ts new file mode 100644 index 0000000..a62be60 --- /dev/null +++ b/test-utils/vitest.setup.ts @@ -0,0 +1,88 @@ +import '@testing-library/jest-dom'; + +import { vi } from 'vitest'; + +type StorageKeys = string | string[] | Record | null | undefined; +type StorageItems = Record; +type StorageCallback = (items: StorageItems) => void; + +interface TabQueryInfo { + active?: boolean; + currentWindow?: boolean; + [key: string]: unknown; +} + +interface TabItem { + id?: number; + url?: string; + [key: string]: unknown; +} + +type TabsCallback = (tabs: TabItem[]) => void; + +const chromeMock = { + storage: { + onChanged: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + local: { + get: vi.fn((keys?: StorageKeys, callback?: StorageCallback): Promise => { + if (typeof callback === 'function') { + callback({}); + } + return Promise.resolve({}); + }), + set: vi.fn((): Promise => Promise.resolve()), + onChanged: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + }, + session: { + get: vi.fn((keys?: StorageKeys, callback?: StorageCallback): Promise => { + if (typeof callback === 'function') { + callback({}); + } + return Promise.resolve({}); + }), + set: vi.fn((): Promise => Promise.resolve()), + setAccessLevel: vi.fn((): Promise => Promise.resolve()), + onChanged: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + }, + }, + tabs: { + query: vi.fn((queryInfo?: TabQueryInfo, callback?: TabsCallback): Promise => { + if (typeof callback === 'function') { + callback([{ id: 1, url: 'https://example.com', active: true }]); + } + return Promise.resolve([{ id: 1, url: 'https://example.com', active: true }]); + }), + get: vi.fn((): Promise => Promise.resolve({ id: 1, url: 'https://example.com' })), + sendMessage: vi.fn((tabId: number, message: unknown, callback?: (response?: unknown) => void): Promise => { + if (typeof callback === 'function') { + callback({}); + } + return Promise.resolve(); + }), + onActivated: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + onUpdated: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + }, + runtime: { + lastError: undefined, + }, +}; + +Object.defineProperty(global, 'chrome', { + value: chromeMock, + writable: true, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 438b775..bbcc62a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', include: ['**/*.test.ts', '**/*.test.tsx'], - setupFiles: './test-utils/vitest.setup.js', + setupFiles: './test-utils/vitest.setup.ts', }, resolve: { alias: {