diff --git a/entrypoints/background/index.test.ts b/entrypoints/background/index.test.ts new file mode 100644 index 0000000..5a7077c --- /dev/null +++ b/entrypoints/background/index.test.ts @@ -0,0 +1,102 @@ +import { browser } from 'wxt/browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMappingSettings, invalidateMappingSettingsCache, updateIcon } from './index'; +import { readPersistedSettingsRaw } from '../../utils/storage-adapter'; + +vi.mock('../../utils/storage-adapter', () => ({ + readPersistedSettingsRaw: vi.fn(), +})); + +const DEFAULT_ICON = { 16: 'icon/16.png', 32: 'icon/32.png', 48: 'icon/48.png', 96: 'icon/96.png', 128: 'icon/128.png' }; +const LINKED_ICON = { + 16: 'icon/16-linked.png', + 32: 'icon/32-linked.png', + 48: 'icon/48-linked.png', + 96: 'icon/96-linked.png', + 128: 'icon/128-linked.png', +}; + +function mockPersisted(linkedOrigins: string, useOriginMappings: boolean) { + (readPersistedSettingsRaw as ReturnType).mockResolvedValue({ + state: { linkedOrigins, useOriginMappings }, + }); +} + +beforeEach(() => { + invalidateMappingSettingsCache(); + vi.clearAllMocks(); + Object.assign(browser, { + action: { setIcon: vi.fn().mockResolvedValue(undefined) }, + }); +}); + +describe('updateIcon', () => { + it('sets the default icon when the tab has no url', async () => { + await updateIcon(1, undefined); + expect(browser.action.setIcon).toHaveBeenCalledWith({ tabId: 1, path: DEFAULT_ICON }); + }); + + it('sets the linked icon when the origin matches a mapping', async () => { + mockPersisted('domain.com => auth.domain.com', true); + await updateIcon(1, 'https://domain.com'); + expect(browser.action.setIcon).toHaveBeenCalledWith({ tabId: 1, path: LINKED_ICON }); + }); + + it('sets the default icon when the origin has no mapping', async () => { + mockPersisted('other.com => auth.other.com', true); + await updateIcon(1, 'https://domain.com'); + expect(browser.action.setIcon).toHaveBeenCalledWith({ tabId: 1, path: DEFAULT_ICON }); + }); + + it('sets the default icon when useOriginMappings is off, even if a mapping would match', async () => { + mockPersisted('domain.com => auth.domain.com', false); + await updateIcon(1, 'https://domain.com'); + expect(browser.action.setIcon).toHaveBeenCalledWith({ tabId: 1, path: DEFAULT_ICON }); + }); + + it('does not throw when setIcon rejects (e.g. the tab already closed)', async () => { + (browser.action.setIcon as ReturnType).mockRejectedValue(new Error('No tab with id: 1')); + await expect(updateIcon(1, undefined)).resolves.toBeUndefined(); + }); + + it('drops a stale update superseded by a newer call for the same tab', async () => { + mockPersisted('domain.com => auth.domain.com', true); + + const stale = updateIcon(1, 'https://domain.com'); + const fresh = updateIcon(1, 'https://other.com'); + await Promise.all([stale, fresh]); + + expect(browser.action.setIcon).toHaveBeenCalledTimes(1); + expect(browser.action.setIcon).toHaveBeenCalledWith({ tabId: 1, path: DEFAULT_ICON }); + }); + + it('tracks tabs independently — updating one tab does not drop another', async () => { + mockPersisted('domain.com => auth.domain.com', true); + await Promise.all([updateIcon(1, 'https://domain.com'), updateIcon(2, 'https://other.com')]); + + expect(browser.action.setIcon).toHaveBeenCalledWith({ tabId: 1, path: LINKED_ICON }); + expect(browser.action.setIcon).toHaveBeenCalledWith({ tabId: 2, path: DEFAULT_ICON }); + }); +}); + +describe('getMappingSettings caching', () => { + it('only reads persisted settings once across repeated calls', async () => { + mockPersisted('domain.com => auth.domain.com', true); + await getMappingSettings(); + await getMappingSettings(); + expect(readPersistedSettingsRaw).toHaveBeenCalledTimes(1); + }); + + it('re-reads after invalidateMappingSettingsCache()', async () => { + mockPersisted('domain.com => auth.domain.com', true); + await getMappingSettings(); + invalidateMappingSettingsCache(); + await getMappingSettings(); + expect(readPersistedSettingsRaw).toHaveBeenCalledTimes(2); + }); + + it('defaults to empty/false when nothing is persisted yet', async () => { + (readPersistedSettingsRaw as ReturnType).mockResolvedValue(null); + expect(await getMappingSettings()).toEqual({ linkedOrigins: '', useOriginMappings: false }); + }); +}); diff --git a/entrypoints/background.ts b/entrypoints/background/index.ts similarity index 53% rename from entrypoints/background.ts rename to entrypoints/background/index.ts index febfcfc..9cb851c 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background/index.ts @@ -1,11 +1,17 @@ import { browser } from 'wxt/browser'; import { defineBackground } from 'wxt/utils/define-background'; -import { linkedOriginsFor, tabOrigin } from '../utils/clearing'; -import { readPersistedSettingsRaw } from '../utils/storage-adapter'; +import { linkedOriginsFor, tabOrigin } from '../../utils/clearing'; +import { readPersistedSettingsRaw } from '../../utils/storage-adapter'; const SETTINGS_KEY = 'cache-cleaner-settings'; -const DEFAULT_ICON = { 16: 'icon/16.png', 32: 'icon/32.png', 48: 'icon/48.png', 96: 'icon/96.png', 128: 'icon/128.png' }; +const DEFAULT_ICON = { + 16: 'icon/16.png', + 32: 'icon/32.png', + 48: 'icon/48.png', + 96: 'icon/96.png', + 128: 'icon/128.png', +}; const LINKED_ICON = { 16: 'icon/16-linked.png', 32: 'icon/32-linked.png', @@ -22,7 +28,7 @@ type MappingSettings = { linkedOrigins: string; useOriginMappings: boolean }; let cachedSettings: MappingSettings | null = null; -async function getMappingSettings(): Promise { +export async function getMappingSettings(): Promise { if (!cachedSettings) { const raw = (await readPersistedSettingsRaw(SETTINGS_KEY)) as PersistedShape | null; cachedSettings = { @@ -33,32 +39,48 @@ async function getMappingSettings(): Promise { return cachedSettings; } -async function updateIcon(tabId: number, url: string | undefined) { +export function invalidateMappingSettingsCache(): void { + cachedSettings = null; +} + +const latestGeneration = new Map(); + +export async function updateIcon(tabId: number, url: string | undefined): Promise { + const generation = (latestGeneration.get(tabId) ?? 0) + 1; + latestGeneration.set(tabId, generation); + const origin = url ? tabOrigin({ url }) : null; - if (!origin) { - await browser.action.setIcon({ tabId, path: DEFAULT_ICON }); - return; + let path = DEFAULT_ICON; + if (origin) { + const { linkedOrigins, useOriginMappings } = await getMappingSettings(); + const hasMapping = useOriginMappings && linkedOriginsFor(linkedOrigins, origin).length > 0; + path = hasMapping ? LINKED_ICON : DEFAULT_ICON; } - const { linkedOrigins, useOriginMappings } = await getMappingSettings(); - const hasMapping = useOriginMappings && linkedOriginsFor(linkedOrigins, origin).length > 0; - - await browser.action.setIcon({ tabId, path: hasMapping ? LINKED_ICON : DEFAULT_ICON }); + if (latestGeneration.get(tabId) !== generation) return; + await browser.action.setIcon({ tabId, path }).catch(() => {}); } export default defineBackground(() => { browser.tabs.onActivated.addListener(({ tabId }) => { - browser.tabs.get(tabId).then((tab) => updateIcon(tabId, tab.url)); + browser.tabs + .get(tabId) + .then((tab) => updateIcon(tabId, tab.url)) + .catch(() => {}); }); browser.tabs.onUpdated.addListener((tabId, info, tab) => { if (info.status === 'complete') updateIcon(tabId, tab.url); }); + browser.tabs.onRemoved.addListener((tabId) => { + latestGeneration.delete(tabId); + }); + browser.storage.onChanged.addListener((changes, areaName) => { if (areaName !== 'sync' || !Object.keys(changes).some((key) => key.startsWith(SETTINGS_KEY))) return; - cachedSettings = null; + invalidateMappingSettingsCache(); browser.tabs.query({ active: true }).then((tabs) => { for (const tab of tabs) { if (tab.id != null) updateIcon(tab.id, tab.url);