diff --git a/src/background/handlers/messages.ts b/src/background/handlers/messages.ts index 7131eb4..608722d 100644 --- a/src/background/handlers/messages.ts +++ b/src/background/handlers/messages.ts @@ -37,12 +37,12 @@ export function handleMessage( } if (isGoBackActiveTabMessage(message)) { - void handleGoBackActiveTab(sendResponse); + void handleGoBackActiveTab(sender, sendResponse); return true; } if (isContinueActiveTabMessage(message)) { - void handleContinueActiveTab(sendResponse); + void handleContinueActiveTab(message.blockId, sender, sendResponse); return true; } @@ -67,12 +67,31 @@ async function handleCheckUrl( sendResponse({ blocked: decision.action === 'block' }); } -async function handleGoBackActiveTab(sendResponse: (response: unknown) => void): Promise { - sendResponse({ restored: await getTabController().goBackFromActiveTab() }); +async function handleGoBackActiveTab( + sender: chrome.runtime.MessageSender, + sendResponse: (response: unknown) => void +): Promise { + const senderTabId = sender.tab?.id; + const restored = + typeof senderTabId === 'number' + ? await getTabController().goBackFromTab(senderTabId) + : await getTabController().goBackFromActiveTab(); + sendResponse({ restored }); } -async function handleContinueActiveTab(sendResponse: (response: unknown) => void): Promise { - sendResponse({ continued: await getTabController().continueFromActiveTab() }); +async function handleContinueActiveTab( + blockId: string | undefined, + sender: chrome.runtime.MessageSender, + sendResponse: (response: unknown) => void +): Promise { + const senderTabId = sender.tab?.id; + const continued = + typeof senderTabId === 'number' + ? await getTabController().continueFromTab(senderTabId, sender.tab?.url, blockId) + : blockId + ? await getTabController().continueFromBlockedPage(blockId) + : await getTabController().continueFromActiveTab(); + sendResponse({ continued }); } async function handleGetBlockedPageState( diff --git a/src/background/tabController.ts b/src/background/tabController.ts index a752921..3171e67 100644 --- a/src/background/tabController.ts +++ b/src/background/tabController.ts @@ -28,6 +28,7 @@ import { getRulesProvider, type CurrentRules, type RulesProvider } from './rules interface ResolvedBlockedTarget { readonly targetUrl: string; readonly blockId?: string; + readonly tabId?: number; readonly hasSessionState: boolean; } @@ -196,7 +197,11 @@ class TabController { return false; } - const lastAllowedUrl = await getLastAllowedUrl(activeTab.id); + return this.goBackFromTab(activeTab.id); + } + + async goBackFromTab(tabId: number): Promise { + const lastAllowedUrl = await getLastAllowedUrl(tabId); if (!lastAllowedUrl || isInternalUrl(lastAllowedUrl)) { return false; } @@ -206,8 +211,8 @@ class TabController { return false; } - await updateTabUrl(activeTab.id, lastAllowedUrl); - await this.allowTab(activeTab.id, lastAllowedUrl); + await updateTabUrl(tabId, lastAllowedUrl); + await this.allowTab(tabId, lastAllowedUrl); return true; } @@ -217,11 +222,29 @@ class TabController { return false; } - const resolvedTarget = await this.resolveBlockedTarget(activeTab.id, activeTab.url); + return this.continueFromTab(activeTab.id, activeTab.url); + } + + async continueFromBlockedPage(blockId: string): Promise { + const pageState = await getBlockedPageState(blockId); + if (!pageState) { + return false; + } + + return this.continueFromTab(pageState.tabId, undefined, blockId); + } + + async continueFromTab( + tabId: number, + blockedPageUrl?: string, + blockId?: string + ): Promise { + const resolvedTarget = await this.resolveBlockedTarget(tabId, blockedPageUrl, blockId); if (!resolvedTarget) { return false; } + const targetTabId = resolvedTarget.tabId ?? tabId; const rules = await this.getRules(); const decision = rules.engine.evaluate(resolvedTarget.targetUrl); if (decision.action !== 'block' || decision.blockType !== 'warning') { @@ -229,14 +252,14 @@ class TabController { } await Promise.all([ - setWarningBypassState(activeTab.id, { + setWarningBypassState(targetTabId, { filterId: decision.filterId, urlKey: getWarningBypassUrlKey(resolvedTarget.targetUrl), }), - clearBlockedTabState(activeTab.id), - setLastAllowedUrl(activeTab.id, resolvedTarget.targetUrl), + clearBlockedTabState(targetTabId), + setLastAllowedUrl(targetTabId, resolvedTarget.targetUrl), ]); - await updateTabUrl(activeTab.id, resolvedTarget.targetUrl); + await updateTabUrl(targetTabId, resolvedTarget.targetUrl); return true; } @@ -353,16 +376,18 @@ class TabController { private async resolveBlockedTarget( tabId: number, - blockedPageUrl?: string + blockedPageUrl?: string, + explicitBlockId?: string ): Promise { const existingState = await getBlockedTabState(tabId); - const blockId = parseBlockedPageBlockId(blockedPageUrl); + const blockId = explicitBlockId ?? parseBlockedPageBlockId(blockedPageUrl); if (blockId) { const pageState = await getBlockedPageState(blockId); if (pageState) { return { targetUrl: pageState.targetUrl, blockId, + tabId: pageState.tabId, hasSessionState: true, }; } @@ -372,6 +397,7 @@ class TabController { ? { targetUrl: existingState.targetUrl, blockId: existingState.blockId, + tabId: existingState.tabId, hasSessionState: true, } : undefined; diff --git a/src/blocked/index.ts b/src/blocked/index.ts index 86fcb8e..b5ea968 100644 --- a/src/blocked/index.ts +++ b/src/blocked/index.ts @@ -3,14 +3,13 @@ * Displays information about the blocked URL and provides navigation options */ +import { sendExtensionMessage } from '../shared/api'; import { openOptionsPage } from '../shared/api/runtime'; import { MessageType, type BlockedPageState, - type ContinueActiveTabResponse, type FilterMatchMode, type GetBlockedPageStateResponse, - type GoBackActiveTabResponse, } from '../shared/types'; import { getElementByIdOrNull } from '../shared/utils/dom'; import { formatGroupScheduleSummary } from '../shared/utils/schedules'; @@ -66,10 +65,12 @@ async function renderPage(): Promise { async function getBlockedPageState(): Promise { try { - const response = (await chrome.runtime.sendMessage({ - type: MessageType.GET_BLOCKED_PAGE_STATE, - blockId: getBlockedPageBlockId(), - })) as GetBlockedPageStateResponse; + const blockId = getBlockedPageBlockId(); + const response = await sendExtensionMessage( + blockId + ? { type: MessageType.GET_BLOCKED_PAGE_STATE, blockId } + : { type: MessageType.GET_BLOCKED_PAGE_STATE } + ); if (!isBlockedPageStateResponse(response)) { return getUnavailableBlockedPageState(); @@ -141,9 +142,9 @@ function isBlockedPageStateResponse(response: unknown): response is GetBlockedPa } async function handleGoBack(): Promise { - const response = (await chrome.runtime.sendMessage({ + const response = await sendExtensionMessage({ type: MessageType.GO_BACK_ACTIVE_TAB, - })) as GoBackActiveTabResponse; + }); if (!response.restored) { console.warn('[Teichos] No restorable tab target is available.'); @@ -151,9 +152,11 @@ async function handleGoBack(): Promise { } async function handleContinue(): Promise { - const response = (await chrome.runtime.sendMessage({ + const blockId = getBlockedPageBlockId(); + const response = await sendExtensionMessage({ type: MessageType.CONTINUE_ACTIVE_TAB, - })) as ContinueActiveTabResponse; + ...(blockId ? { blockId } : {}), + }); if (!response.continued) { console.warn('[Teichos] No warning bypass is available for this tab.'); diff --git a/src/options/index.ts b/src/options/index.ts index 74e5bc7..625c31c 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -59,12 +59,13 @@ let setInfoPopoverOpen: ((isOpen: boolean) => void) | null = null; * Initialize options page */ async function init(): Promise { - await renderGroups(); setupEventListeners(); setupStorageSync(); populateInfoPanel(); + await renderGroups(); openFilterFromQuery(); openInfoFromQuery(); + document.documentElement.dataset['optionsReady'] = 'true'; } /** diff --git a/src/popup/index.ts b/src/popup/index.ts index e146932..e5a9d43 100644 --- a/src/popup/index.ts +++ b/src/popup/index.ts @@ -10,6 +10,7 @@ import { saveData, setSnooze, updateFilter, + sendExtensionMessage, } from '../shared/api'; import { openOptionsPage, openOptionsPageWithParams } from '../shared/api/runtime'; import { getActiveTab } from '../shared/api/tabs'; @@ -96,10 +97,11 @@ async function disableExpiredTemporaryFilters(data: StorageData): Promise { - await renderFilters(); setupEventListeners(); setupStorageSync(); ensureSnoozeCountdownTicker(); + await renderFilters(); + document.documentElement.dataset['popupReady'] = 'true'; } /** @@ -108,7 +110,7 @@ async function init(): Promise { function setupEventListeners(): void { const openOptionsButton = getElementByIdOrNull('open-options'); openOptionsButton?.addEventListener('click', () => { - void chrome.runtime.sendMessage({ type: MessageType.CLOSE_INFO_PANEL }); + void sendExtensionMessage({ type: MessageType.CLOSE_INFO_PANEL }); openOptionsPage() .catch((error: unknown) => { console.error('Failed to open options page:', error); diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index b36bca5..9d5229b 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -6,3 +6,4 @@ export * from './storage'; export * from './runtime'; export * from './tabs'; export * from './session'; +export * from './messaging'; diff --git a/src/shared/api/messaging.ts b/src/shared/api/messaging.ts new file mode 100644 index 0000000..e5a96d4 --- /dev/null +++ b/src/shared/api/messaging.ts @@ -0,0 +1,7 @@ +import type { ExtensionMessage, MessageResponse } from '../types'; + +export async function sendExtensionMessage( + message: T +): Promise> { + return (await chrome.runtime.sendMessage(message)) as MessageResponse; +} diff --git a/src/shared/api/storage.ts b/src/shared/api/storage.ts index 5d1e763..1ee0e04 100644 --- a/src/shared/api/storage.ts +++ b/src/shared/api/storage.ts @@ -3,397 +3,16 @@ * Promise-based utilities for storage operations */ -import type { - BlockType, - StorageData, - FilterGroup, - Filter, - FilterBlockType, - FilterMatchMode, - TimeSchedule, - Whitelist, - SnoozeState, -} from '../types'; +import type { StorageData, FilterGroup, Filter, Whitelist, SnoozeState } from '../types'; import { STORAGE_KEY, DEFAULT_GROUP_ID } from '../types'; -import { getRegexValidationError } from '../utils'; +import { parseImportedData, serializeDataForExport } from '../storage/importExport'; +import { normalizeStoredData, type LegacyStorageData } from '../storage/normalize'; +import { createDefaultGroup } from '../storage/defaults'; import { setSessionSnooze } from './session'; -/** - * Creates the default 24/7 filter group - */ -export function createDefaultGroup(): FilterGroup { - return { - id: DEFAULT_GROUP_ID, - name: '24/7 (Always Active)', - schedules: [], - is24x7: true, - enabled: true, - }; -} - -/** - * Creates empty default storage data - */ -function createDefaultData(): StorageData { - return { - groups: [createDefaultGroup()], - filters: [], - whitelist: [], - snooze: { active: false }, - blockType: 'block', - rulesVersion: 0, - }; -} - -type LegacyFilter = Omit & { - readonly matchMode?: FilterMatchMode; - readonly blockType?: FilterBlockType; - readonly isRegex?: boolean; -}; - -type LegacyWhitelist = Omit & { - readonly matchMode?: FilterMatchMode; - readonly isRegex?: boolean; - readonly groupId?: string; -}; - -interface LegacyStorageData { - readonly groups?: readonly FilterGroup[]; - readonly filters?: readonly LegacyFilter[]; - readonly whitelist?: readonly LegacyWhitelist[]; - readonly rulesVersion?: number; - readonly blockType?: BlockType; - readonly snooze?: { - readonly active?: boolean; - readonly until?: number; - }; -} - -type JsonObject = Record; - -function isObject(value: unknown): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isValidMatchMode(value: unknown): value is FilterMatchMode { - return value === 'contains' || value === 'exact' || value === 'regex'; -} - -function isValidBlockType(value: unknown): value is BlockType { - return value === 'block' || value === 'warning'; -} - -function isValidFilterBlockType(value: unknown): value is FilterBlockType { - return value === 'default' || isValidBlockType(value); -} - -function isOptionalString(value: unknown): value is string | undefined { - return value === undefined || typeof value === 'string'; -} - -function isOptionalBoolean(value: unknown): value is boolean | undefined { - return value === undefined || typeof value === 'boolean'; -} - -function isOptionalFiniteNumber(value: unknown): value is number | undefined { - return value === undefined || (typeof value === 'number' && Number.isFinite(value)); -} - -function isValidDayOfWeek(value: unknown): value is number { - return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6; -} - -function isValidSchedule(value: unknown): value is TimeSchedule { - if (!isObject(value)) { - return false; - } - - return ( - Array.isArray(value['daysOfWeek']) && - value['daysOfWeek'].every(isValidDayOfWeek) && - typeof value['startTime'] === 'string' && - typeof value['endTime'] === 'string' - ); -} - -function isValidGroup(value: unknown): value is FilterGroup { - if (!isObject(value)) { - return false; - } - - return ( - typeof value['id'] === 'string' && - typeof value['name'] === 'string' && - typeof value['is24x7'] === 'boolean' && - isOptionalBoolean(value['enabled']) && - Array.isArray(value['schedules']) && - value['schedules'].every(isValidSchedule) - ); -} - -function isValidFilterLike(value: unknown): value is LegacyFilter { - if (!isObject(value)) { - return false; - } - - return ( - typeof value['id'] === 'string' && - typeof value['pattern'] === 'string' && - typeof value['groupId'] === 'string' && - typeof value['enabled'] === 'boolean' && - (value['matchMode'] === undefined || isValidMatchMode(value['matchMode'])) && - (value['blockType'] === undefined || isValidFilterBlockType(value['blockType'])) && - isOptionalBoolean(value['isRegex']) && - isOptionalString(value['description']) && - isOptionalFiniteNumber(value['expiresAt']) - ); -} - -function isValidWhitelistLike(value: unknown): value is LegacyWhitelist { - if (!isObject(value)) { - return false; - } - - return ( - typeof value['id'] === 'string' && - typeof value['pattern'] === 'string' && - typeof value['enabled'] === 'boolean' && - isOptionalString(value['groupId']) && - (value['matchMode'] === undefined || isValidMatchMode(value['matchMode'])) && - isOptionalBoolean(value['isRegex']) && - isOptionalString(value['description']) - ); -} - -function isValidSnooze(value: unknown): value is LegacyStorageData['snooze'] { - if (value === undefined) { - return true; - } - - if (!isObject(value)) { - return false; - } - - return isOptionalBoolean(value['active']) && isOptionalFiniteNumber(value['until']); -} - -function assertUniqueIds( - items: readonly { readonly id: string }[], - entityName: 'group' | 'filter' | 'exception' -): void { - const seen = new Set(); - for (const item of items) { - if (seen.has(item.id)) { - throw new Error(`Imported settings contain duplicate ${entityName} ids.`); - } - seen.add(item.id); - } -} - -function ensureDefaultGroup(groups: readonly FilterGroup[]): FilterGroup[] { - if (groups.some((group) => group.id === DEFAULT_GROUP_ID)) { - return [...groups]; - } - - return [createDefaultGroup(), ...groups]; -} - -function assertKnownGroupReferences( - filters: readonly Filter[], - whitelist: readonly Whitelist[], - groups: readonly FilterGroup[] -): void { - const groupIds = new Set(groups.map((group) => group.id)); - - for (const filter of filters) { - if (!groupIds.has(filter.groupId)) { - throw new Error(`Imported filter "${filter.id}" references an unknown group.`); - } - } - - for (const entry of whitelist) { - if (!groupIds.has(entry.groupId)) { - throw new Error(`Imported exception "${entry.id}" references an unknown group.`); - } - } -} - -function assertValidRegexEntries( - filters: readonly Filter[], - whitelist: readonly Whitelist[] -): void { - for (const filter of filters) { - if (filter.matchMode !== 'regex') { - continue; - } - - if (getRegexValidationError(filter.pattern)) { - throw new Error(`Imported filter "${filter.id}" has an invalid regex pattern.`); - } - } - - for (const entry of whitelist) { - if (entry.matchMode !== 'regex') { - continue; - } - - if (getRegexValidationError(entry.pattern)) { - throw new Error(`Imported exception "${entry.id}" has an invalid regex pattern.`); - } - } -} - -function validateImportedStorageShape(raw: JsonObject): void { - const hasKnownCollections = - Object.hasOwn(raw, 'groups') || - Object.hasOwn(raw, 'filters') || - Object.hasOwn(raw, 'whitelist'); - - if (!hasKnownCollections) { - throw new Error('Settings file does not contain Teichos data.'); - } - - if (Object.hasOwn(raw, 'groups')) { - if (!Array.isArray(raw['groups']) || !raw['groups'].every(isValidGroup)) { - throw new Error('Settings file contains invalid groups.'); - } - } - - if (Object.hasOwn(raw, 'filters')) { - if (!Array.isArray(raw['filters']) || !raw['filters'].every(isValidFilterLike)) { - throw new Error('Settings file contains invalid filters.'); - } - } - - if (Object.hasOwn(raw, 'whitelist')) { - if (!Array.isArray(raw['whitelist']) || !raw['whitelist'].every(isValidWhitelistLike)) { - throw new Error('Settings file contains invalid exceptions.'); - } - } - - if (!isValidSnooze(raw['snooze'])) { - throw new Error('Settings file contains an invalid snooze state.'); - } - - if (!isOptionalFiniteNumber(raw['rulesVersion'])) { - throw new Error('Settings file contains an invalid rules version.'); - } - - if (raw['blockType'] !== undefined && !isValidBlockType(raw['blockType'])) { - throw new Error('Settings file contains an invalid block type.'); - } -} - -function resolveMatchMode( - matchMode: FilterMatchMode | undefined, - isRegex?: boolean -): FilterMatchMode { - if (matchMode === 'contains' || matchMode === 'exact' || matchMode === 'regex') { - return matchMode; - } - return isRegex ? 'regex' : 'contains'; -} - -function normalizeFilters(filters: readonly LegacyFilter[] | undefined): Filter[] { - return (filters ?? []).map(({ isRegex, matchMode, blockType, ...filter }) => ({ - ...filter, - matchMode: resolveMatchMode(matchMode, isRegex), - blockType: isValidFilterBlockType(blockType) ? blockType : 'default', - })); -} - -function normalizeWhitelist( - whitelist: readonly LegacyWhitelist[] | undefined, - groupIds: ReadonlySet -): Whitelist[] { - return (whitelist ?? []).map(({ isRegex, matchMode, groupId, ...entry }) => ({ - ...entry, - groupId: groupId && groupIds.has(groupId) ? groupId : DEFAULT_GROUP_ID, - matchMode: resolveMatchMode(matchMode, isRegex), - })); -} - -function normalizeSnooze(snooze: LegacyStorageData['snooze']): SnoozeState { - if (!snooze?.active) { - return { active: false }; - } - - if (typeof snooze.until === 'number' && Number.isFinite(snooze.until)) { - return { active: true, until: snooze.until }; - } - - return { active: true }; -} - -function normalizeGroups(groups: readonly FilterGroup[] | undefined): FilterGroup[] { - return (groups && groups.length > 0 ? groups : [createDefaultGroup()]).map((group) => ({ - ...group, - enabled: group.enabled ?? true, - })); -} - -export function normalizeStoredData(raw: LegacyStorageData | undefined): StorageData { - if (!raw) { - return createDefaultData(); - } - - const data = raw; - const groups = normalizeGroups(data.groups); - const groupIds = new Set(groups.map((group) => group.id)); - const filters = normalizeFilters(data.filters); - const whitelist = normalizeWhitelist(data.whitelist, groupIds); - const snooze = normalizeSnooze(data.snooze); - const rulesVersion = - typeof data.rulesVersion === 'number' && Number.isFinite(data.rulesVersion) - ? data.rulesVersion - : 0; - const blockType = isValidBlockType(data.blockType) ? data.blockType : 'block'; - - return { - ...data, - groups, - filters, - whitelist, - snooze, - blockType, - rulesVersion, - }; -} - -export function serializeDataForExport(data: StorageData): string { - return `${JSON.stringify(data, null, 2)}\n`; -} - -export function parseImportedData(serialized: string): StorageData { - let parsed: unknown; - - try { - parsed = JSON.parse(serialized); - } catch { - throw new Error('Settings file is not valid JSON.'); - } - - if (!isObject(parsed)) { - throw new Error('Settings file must contain a JSON object.'); - } - - validateImportedStorageShape(parsed); - - const normalized = normalizeStoredData(parsed as LegacyStorageData); - const groups = ensureDefaultGroup(normalized.groups); - const importedData: StorageData = { - ...normalized, - groups, - }; - - assertUniqueIds(importedData.groups, 'group'); - assertUniqueIds(importedData.filters, 'filter'); - assertUniqueIds(importedData.whitelist, 'exception'); - assertKnownGroupReferences(importedData.filters, importedData.whitelist, importedData.groups); - assertValidRegexEntries(importedData.filters, importedData.whitelist); - - return importedData; -} +export { createDefaultGroup }; +export { normalizeStoredData }; +export { serializeDataForExport, parseImportedData }; /** * Load storage data from chrome.storage.sync @@ -428,6 +47,17 @@ export async function saveData(data: StorageData): Promise { }); } +async function updateData(updater: (data: StorageData) => StorageData): Promise { + const data = await loadData(); + const updatedData = updater(data); + + if (updatedData !== data) { + await saveData(updatedData); + } + + return updatedData; +} + export async function importData(serialized: string): Promise { const data = parseImportedData(serialized); await Promise.all([saveData(data), setSessionSnooze(data.snooze)]); @@ -437,22 +67,24 @@ export async function importData(serialized: string): Promise { // Group operations export async function addGroup(group: FilterGroup): Promise { - const data = await loadData(); - await saveData({ + await updateData((data) => ({ ...data, groups: [...data.groups, group], - }); + })); } export async function updateGroup(group: FilterGroup): Promise { - const data = await loadData(); - const index = data.groups.findIndex((g) => g.id === group.id); + await updateData((data) => { + const index = data.groups.findIndex((g) => g.id === group.id); + + if (index === -1) { + return data; + } - if (index !== -1) { const newGroups = [...data.groups]; newGroups[index] = group; - await saveData({ ...data, groups: newGroups }); - } + return { ...data, groups: newGroups }; + }); } export async function deleteGroup(groupId: string): Promise { @@ -460,85 +92,84 @@ export async function deleteGroup(groupId: string): Promise { throw new Error('Cannot delete the default 24/7 group'); } - const data = await loadData(); - await saveData({ + await updateData((data) => ({ ...data, groups: data.groups.filter((g) => g.id !== groupId), - // Move filters from deleted group to default group filters: data.filters.map((f) => f.groupId === groupId ? { ...f, groupId: DEFAULT_GROUP_ID } : f ), whitelist: data.whitelist.map((entry) => entry.groupId === groupId ? { ...entry, groupId: DEFAULT_GROUP_ID } : entry ), - }); + })); } // Filter operations export async function addFilter(filter: Filter): Promise { - const data = await loadData(); - await saveData({ + await updateData((data) => ({ ...data, filters: [...data.filters, filter], - }); + })); } export async function updateFilter(filter: Filter): Promise { - const data = await loadData(); - const index = data.filters.findIndex((f) => f.id === filter.id); + await updateData((data) => { + const index = data.filters.findIndex((f) => f.id === filter.id); + + if (index === -1) { + return data; + } - if (index !== -1) { const newFilters = [...data.filters]; newFilters[index] = filter; - await saveData({ ...data, filters: newFilters }); - } + return { ...data, filters: newFilters }; + }); } export async function deleteFilter(filterId: string): Promise { - const data = await loadData(); - await saveData({ + await updateData((data) => ({ ...data, filters: data.filters.filter((f) => f.id !== filterId), - }); + })); } // Whitelist operations export async function addWhitelist(whitelist: Whitelist): Promise { - const data = await loadData(); - await saveData({ + await updateData((data) => ({ ...data, whitelist: [...data.whitelist, whitelist], - }); + })); } export async function updateWhitelist(whitelist: Whitelist): Promise { - const data = await loadData(); - const index = data.whitelist.findIndex((w) => w.id === whitelist.id); + await updateData((data) => { + const index = data.whitelist.findIndex((w) => w.id === whitelist.id); + + if (index === -1) { + return data; + } - if (index !== -1) { const newWhitelist = [...data.whitelist]; newWhitelist[index] = whitelist; - await saveData({ ...data, whitelist: newWhitelist }); - } + return { ...data, whitelist: newWhitelist }; + }); } export async function deleteWhitelist(whitelistId: string): Promise { - const data = await loadData(); - await saveData({ + await updateData((data) => ({ ...data, whitelist: data.whitelist.filter((w) => w.id !== whitelistId), - }); + })); } export async function setSnooze(snooze: SnoozeState): Promise { - const data = await loadData(); await Promise.all([ - saveData({ + updateData((data) => ({ ...data, snooze, - }), + })), setSessionSnooze(snooze), ]); } diff --git a/src/shared/filtering/engine.ts b/src/shared/filtering/engine.ts new file mode 100644 index 0000000..3faa480 --- /dev/null +++ b/src/shared/filtering/engine.ts @@ -0,0 +1,160 @@ +import type { BlockType, Filter, FilterGroup, StorageData, Whitelist } from '../types'; +import { matchesPattern } from './patterns'; +import { + buildGroupById, + buildWhitelistByGroup, + getFilterEffectiveState, + getScheduleContext, + isSnoozeActive, + isTemporaryFilter, + isTemporaryFilterExpired, + sortFiltersTemporaryFirst, + type ScheduleContext, +} from './schedules'; + +export type FilterDecisionAllowReason = + | 'no-match' + | 'snoozed' + | 'whitelisted' + | 'group-inactive' + | 'filter-disabled' + | 'temporary-expired'; + +export type FilterDecision = + | { readonly action: 'allow'; readonly reason: FilterDecisionAllowReason } + | { + readonly action: 'block'; + readonly filterId: string; + readonly groupId: string; + readonly blockType: BlockType; + readonly reason: 'matched-filter'; + }; + +export interface FilteringEngine { + readonly data: StorageData; + readonly groupsById: ReadonlyMap; + readonly whitelistByGroup: ReadonlyMap; + evaluate: (url: string, context?: ScheduleContext) => FilterDecision; +} + +const FILTER_DECISION_REASON_PRIORITY: Record = { + 'no-match': 0, + 'filter-disabled': 1, + 'temporary-expired': 2, + 'group-inactive': 3, + whitelisted: 4, + snoozed: 5, +}; + +export function createFilteringEngine(data: StorageData): FilteringEngine { + const groupsById = buildGroupById(data.groups); + const whitelistByGroup = buildWhitelistByGroup(data.whitelist); + const orderedFilters = sortFiltersTemporaryFirst(data.filters); + + return { + data, + groupsById, + whitelistByGroup, + evaluate(url, context = getScheduleContext()): FilterDecision { + return evaluateFilterDecision(url, data, { + context, + filters: orderedFilters, + groupsById, + whitelistByGroup, + }); + }, + }; +} + +interface EvaluationOptions { + readonly context: ScheduleContext; + readonly filters: readonly Filter[]; + readonly groupsById: ReadonlyMap; + readonly whitelistByGroup: ReadonlyMap; +} + +export function evaluateFilterDecision( + url: string, + data: StorageData, + options?: Partial +): FilterDecision { + if (isSnoozeActive(data.snooze)) { + return { action: 'allow', reason: 'snoozed' }; + } + + const context = options?.context ?? getScheduleContext(); + const filters = options?.filters ?? sortFiltersTemporaryFirst(data.filters); + const groupsById = options?.groupsById ?? buildGroupById(data.groups); + const whitelistByGroup = options?.whitelistByGroup ?? buildWhitelistByGroup(data.whitelist); + const urlLower = url.toLowerCase(); + const now = Date.now(); + let fallbackReason: FilterDecisionAllowReason = 'no-match'; + let warningDecision: Extract | undefined; + + for (const filter of filters) { + if (!matchesPattern(url, filter, undefined, urlLower)) { + continue; + } + + if (!filter.enabled) { + fallbackReason = selectHigherPriorityReason(fallbackReason, 'filter-disabled'); + continue; + } + + if (isTemporaryFilterExpired(filter, now)) { + fallbackReason = selectHigherPriorityReason(fallbackReason, 'temporary-expired'); + continue; + } + + if (!getFilterEffectiveState(filter, groupsById, context, now).groupActive) { + fallbackReason = selectHigherPriorityReason(fallbackReason, 'group-inactive'); + continue; + } + + if (!isTemporaryFilter(filter)) { + const groupWhitelist = whitelistByGroup.get(filter.groupId); + if (groupWhitelist?.some((entry) => matchesPattern(url, entry, undefined, urlLower))) { + fallbackReason = selectHigherPriorityReason(fallbackReason, 'whitelisted'); + continue; + } + } + + const blockType = resolveFilterBlockType(filter, data); + const decision: Extract = { + action: 'block', + filterId: filter.id, + groupId: filter.groupId, + blockType, + reason: 'matched-filter', + }; + + if (blockType === 'block') { + return decision; + } + + warningDecision ??= decision; + } + + if (warningDecision) { + return warningDecision; + } + + return { action: 'allow', reason: fallbackReason }; +} + +function resolveFilterBlockType(filter: Filter, data: StorageData): BlockType { + if (filter.blockType === 'block' || filter.blockType === 'warning') { + return filter.blockType; + } + + return data.blockType === 'warning' ? 'warning' : 'block'; +} + +function selectHigherPriorityReason( + current: FilterDecisionAllowReason, + candidate: FilterDecisionAllowReason +): FilterDecisionAllowReason { + return FILTER_DECISION_REASON_PRIORITY[candidate] > FILTER_DECISION_REASON_PRIORITY[current] + ? candidate + : current; +} diff --git a/src/shared/filtering/patterns.ts b/src/shared/filtering/patterns.ts new file mode 100644 index 0000000..965b961 --- /dev/null +++ b/src/shared/filtering/patterns.ts @@ -0,0 +1,77 @@ +import type { FilterMatchMode } from '../types'; + +export interface PreparedPattern { + readonly pattern: string; + readonly matchMode: FilterMatchMode; + readonly patternLower?: string; + readonly regex?: RegExp | null; +} + +export function getRegexValidationError(pattern: string): string | null { + try { + new RegExp(pattern); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +export function compileRegex(pattern: string): RegExp | null { + try { + return new RegExp(pattern); + } catch { + return null; + } +} + +export function preparePattern( + pattern: string, + matchMode: FilterMatchMode +): Pick { + if (matchMode === 'regex') { + return { regex: compileRegex(pattern) }; + } + return { patternLower: pattern.toLowerCase() }; +} + +export function matchesPattern( + url: string, + pattern: string | PreparedPattern, + matchMode: FilterMatchMode = 'contains', + urlLower?: string +): boolean { + let resolvedPattern: string; + let resolvedMode: FilterMatchMode; + let patternLower: string | undefined; + let regex: RegExp | null | undefined; + + if (typeof pattern === 'string') { + resolvedPattern = pattern; + resolvedMode = matchMode; + } else { + resolvedPattern = pattern.pattern; + resolvedMode = pattern.matchMode; + patternLower = pattern.patternLower; + regex = pattern.regex; + } + + if (resolvedMode === 'regex') { + if (regex === null) { + return false; + } + const resolvedRegex = regex ?? compileRegex(resolvedPattern); + if (!resolvedRegex) { + return false; + } + return resolvedRegex.test(url); + } + + const normalizedUrl = urlLower ?? url.toLowerCase(); + const normalizedPattern = patternLower ?? resolvedPattern.toLowerCase(); + + if (resolvedMode === 'exact') { + return normalizedUrl === normalizedPattern; + } + + return normalizedUrl.includes(normalizedPattern); +} diff --git a/src/shared/filtering/schedules.ts b/src/shared/filtering/schedules.ts new file mode 100644 index 0000000..59ee317 --- /dev/null +++ b/src/shared/filtering/schedules.ts @@ -0,0 +1,189 @@ +import type { Filter, FilterGroup, SnoozeState, Whitelist } from '../types'; +import { getCurrentDayOfWeek, getCurrentTimeString } from '../utils/helpers'; + +export type WhitelistByGroup = ReadonlyMap; +export type GroupById = ReadonlyMap; +export type GroupLookup = GroupById | readonly FilterGroup[]; + +export interface ScheduleContext { + readonly dayOfWeek: number; + readonly time: string; +} + +export interface FilterEffectiveState { + readonly filterEnabled: boolean; + readonly groupEnabled: boolean; + readonly groupActive: boolean; + readonly active: boolean; +} + +export function isTemporaryFilter(filter: Filter): filter is Filter & { expiresAt: number } { + return typeof filter.expiresAt === 'number' && Number.isFinite(filter.expiresAt); +} + +export function getTemporaryFilterRemainingMs(filter: Filter, now = Date.now()): number | null { + if (!isTemporaryFilter(filter)) { + return null; + } + return filter.expiresAt - now; +} + +export function isTemporaryFilterExpired(filter: Filter, now = Date.now()): boolean { + const remaining = getTemporaryFilterRemainingMs(filter, now); + return remaining !== null && remaining <= 0; +} + +export function getSnoozeRemainingMs( + snooze: SnoozeState | undefined, + now = Date.now() +): number | null { + if (!snooze?.active) { + return null; + } + + if (typeof snooze.until !== 'number' || !Number.isFinite(snooze.until)) { + return null; + } + + return snooze.until - now; +} + +export function isSnoozeActive(snooze: SnoozeState | undefined, now = Date.now()): boolean { + if (!snooze?.active) { + return false; + } + + const remaining = getSnoozeRemainingMs(snooze, now); + return remaining === null || remaining > 0; +} + +export function isSnoozeExpired(snooze: SnoozeState | undefined, now = Date.now()): boolean { + if (!snooze?.active) { + return false; + } + + const remaining = getSnoozeRemainingMs(snooze, now); + return remaining !== null && remaining <= 0; +} + +export function sortFiltersTemporaryFirst(filters: readonly T[]): T[] { + const temporary: T[] = []; + const nonTemporary: T[] = []; + + for (const filter of filters) { + if (isTemporaryFilter(filter)) { + temporary.push(filter); + } else { + nonTemporary.push(filter); + } + } + + return [...temporary, ...nonTemporary]; +} + +export function getScheduleContext(): ScheduleContext { + return { + dayOfWeek: getCurrentDayOfWeek(), + time: getCurrentTimeString(), + }; +} + +export function buildGroupById(groups: readonly FilterGroup[]): GroupById { + return new Map(groups.map((group) => [group.id, group])); +} + +export function buildWhitelistByGroup(whitelist: readonly Whitelist[]): WhitelistByGroup; +export function buildWhitelistByGroup( + whitelist: readonly T[] +): WhitelistByGroup { + const whitelistByGroup = new Map(); + for (const entry of whitelist) { + if (!entry.enabled) { + continue; + } + const groupEntries = whitelistByGroup.get(entry.groupId); + if (groupEntries) { + groupEntries.push(entry); + } else { + whitelistByGroup.set(entry.groupId, [entry]); + } + } + return whitelistByGroup; +} + +export function isGroupEnabled(group: FilterGroup | undefined): boolean { + return group?.enabled !== false; +} + +export function isFilterActive( + filter: Filter, + groups: GroupLookup, + context: ScheduleContext = getScheduleContext() +): boolean { + return getFilterEffectiveState(filter, groups, context).active; +} + +export function isFilterScheduledActive( + filter: Filter, + groups: GroupLookup, + context: ScheduleContext = getScheduleContext() +): boolean { + return getFilterEffectiveState(filter, groups, context).groupActive; +} + +export function getFilterEffectiveState( + filter: Filter, + groups: GroupLookup, + context: ScheduleContext = getScheduleContext(), + now = Date.now() +): FilterEffectiveState { + const expired = isTemporaryFilterExpired(filter, now); + if (expired) { + return { + filterEnabled: filter.enabled, + groupEnabled: false, + groupActive: false, + active: false, + }; + } + + const group = getGroupFromLookup(filter.groupId, groups); + if (!group) { + return { + filterEnabled: filter.enabled, + groupEnabled: false, + groupActive: false, + active: false, + }; + } + + const groupEnabled = isGroupEnabled(group); + const groupActive = groupEnabled && isGroupScheduleActive(group, context); + + return { + filterEnabled: filter.enabled, + groupEnabled, + groupActive, + active: filter.enabled && groupActive, + }; +} + +function getGroupFromLookup(groupId: string, groups: GroupLookup): FilterGroup | undefined { + if (Array.isArray(groups)) { + return groups.find((group) => group.id === groupId); + } + return (groups as GroupById).get(groupId); +} + +function isGroupScheduleActive(group: FilterGroup, context: ScheduleContext): boolean { + if (group.is24x7) { + return true; + } + + return group.schedules.some((schedule) => { + if (!schedule.daysOfWeek.includes(context.dayOfWeek)) { + return false; + } + return context.time >= schedule.startTime && context.time <= schedule.endTime; + }); +} diff --git a/src/shared/storage/defaults.ts b/src/shared/storage/defaults.ts new file mode 100644 index 0000000..30b87ee --- /dev/null +++ b/src/shared/storage/defaults.ts @@ -0,0 +1,23 @@ +import type { FilterGroup, StorageData } from '../types'; +import { DEFAULT_GROUP_ID } from '../types'; + +export function createDefaultGroup(): FilterGroup { + return { + id: DEFAULT_GROUP_ID, + name: '24/7 (Always Active)', + schedules: [], + is24x7: true, + enabled: true, + }; +} + +export function createDefaultData(): StorageData { + return { + groups: [createDefaultGroup()], + filters: [], + whitelist: [], + snooze: { active: false }, + blockType: 'block', + rulesVersion: 0, + }; +} diff --git a/src/shared/storage/guards.ts b/src/shared/storage/guards.ts new file mode 100644 index 0000000..964cbe3 --- /dev/null +++ b/src/shared/storage/guards.ts @@ -0,0 +1,143 @@ +import type { + BlockType, + Filter, + FilterBlockType, + FilterGroup, + FilterMatchMode, + SnoozeState, + TimeSchedule, + Whitelist, +} from '../types'; + +export type JsonObject = Record; + +export interface FilterLike extends Omit { + readonly matchMode?: FilterMatchMode; + readonly blockType?: FilterBlockType; + readonly isRegex?: boolean; +} + +export interface WhitelistLike extends Omit { + readonly matchMode?: FilterMatchMode; + readonly isRegex?: boolean; + readonly groupId?: string; +} + +export interface SnoozeLike { + readonly active?: boolean; + readonly until?: number; +} + +export function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isValidMatchMode(value: unknown): value is FilterMatchMode { + return value === 'contains' || value === 'exact' || value === 'regex'; +} + +export function isValidBlockType(value: unknown): value is BlockType { + return value === 'block' || value === 'warning'; +} + +export function isValidFilterBlockType(value: unknown): value is FilterBlockType { + return value === 'default' || isValidBlockType(value); +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === 'string'; +} + +function isOptionalBoolean(value: unknown): value is boolean | undefined { + return value === undefined || typeof value === 'boolean'; +} + +function isOptionalFiniteNumber(value: unknown): value is number | undefined { + return value === undefined || (typeof value === 'number' && Number.isFinite(value)); +} + +function isValidDayOfWeek(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6; +} + +export function isValidSchedule(value: unknown): value is TimeSchedule { + if (!isObject(value)) { + return false; + } + + return ( + Array.isArray(value['daysOfWeek']) && + value['daysOfWeek'].every(isValidDayOfWeek) && + typeof value['startTime'] === 'string' && + typeof value['endTime'] === 'string' + ); +} + +export function isValidGroup(value: unknown): value is FilterGroup { + if (!isObject(value)) { + return false; + } + + return ( + typeof value['id'] === 'string' && + typeof value['name'] === 'string' && + typeof value['is24x7'] === 'boolean' && + isOptionalBoolean(value['enabled']) && + Array.isArray(value['schedules']) && + value['schedules'].every(isValidSchedule) + ); +} + +export function isValidFilterLike(value: unknown): value is FilterLike { + if (!isObject(value)) { + return false; + } + + return ( + typeof value['id'] === 'string' && + typeof value['pattern'] === 'string' && + typeof value['groupId'] === 'string' && + typeof value['enabled'] === 'boolean' && + (value['matchMode'] === undefined || isValidMatchMode(value['matchMode'])) && + (value['blockType'] === undefined || isValidFilterBlockType(value['blockType'])) && + isOptionalBoolean(value['isRegex']) && + isOptionalString(value['description']) && + isOptionalFiniteNumber(value['expiresAt']) + ); +} + +export function isValidWhitelistLike(value: unknown): value is WhitelistLike { + if (!isObject(value)) { + return false; + } + + return ( + typeof value['id'] === 'string' && + typeof value['pattern'] === 'string' && + typeof value['enabled'] === 'boolean' && + isOptionalString(value['groupId']) && + (value['matchMode'] === undefined || isValidMatchMode(value['matchMode'])) && + isOptionalBoolean(value['isRegex']) && + isOptionalString(value['description']) + ); +} + +export function isValidSnooze(value: unknown): value is SnoozeLike | undefined { + if (value === undefined) { + return true; + } + + if (!isObject(value)) { + return false; + } + + return isOptionalBoolean(value['active']) && isOptionalFiniteNumber(value['until']); +} + +export function isValidSnoozeState(value: unknown): value is SnoozeState { + if (!isObject(value)) { + return false; + } + + return typeof value['active'] === 'boolean' && isOptionalFiniteNumber(value['until']); +} diff --git a/src/shared/storage/importExport.ts b/src/shared/storage/importExport.ts new file mode 100644 index 0000000..de16af8 --- /dev/null +++ b/src/shared/storage/importExport.ts @@ -0,0 +1,160 @@ +import type { Filter, FilterGroup, StorageData, Whitelist } from '../types'; +import { DEFAULT_GROUP_ID } from '../types'; +import { getRegexValidationError } from '../filtering/patterns'; +import { + isObject, + isValidBlockType, + isValidFilterLike, + isValidGroup, + isValidSnooze, + isValidWhitelistLike, + type JsonObject, +} from './guards'; +import { createDefaultGroup } from './defaults'; +import { normalizeStoredData, type LegacyStorageData } from './normalize'; + +function assertUniqueIds( + items: readonly { readonly id: string }[], + entityName: 'group' | 'filter' | 'exception' +): void { + const seen = new Set(); + for (const item of items) { + if (seen.has(item.id)) { + throw new Error(`Imported settings contain duplicate ${entityName} ids.`); + } + seen.add(item.id); + } +} + +function ensureDefaultGroup(groups: readonly FilterGroup[]): FilterGroup[] { + if (groups.some((group) => group.id === DEFAULT_GROUP_ID)) { + return [...groups]; + } + + return [createDefaultGroup(), ...groups]; +} + +function assertKnownGroupReferences( + filters: readonly Filter[], + whitelist: readonly Whitelist[], + groups: readonly FilterGroup[] +): void { + const groupIds = new Set(groups.map((group) => group.id)); + + for (const filter of filters) { + if (!groupIds.has(filter.groupId)) { + throw new Error(`Imported filter "${filter.id}" references an unknown group.`); + } + } + + for (const entry of whitelist) { + if (!groupIds.has(entry.groupId)) { + throw new Error(`Imported exception "${entry.id}" references an unknown group.`); + } + } +} + +function assertValidRegexEntries( + filters: readonly Filter[], + whitelist: readonly Whitelist[] +): void { + for (const filter of filters) { + if (filter.matchMode !== 'regex') { + continue; + } + + if (getRegexValidationError(filter.pattern)) { + throw new Error(`Imported filter "${filter.id}" has an invalid regex pattern.`); + } + } + + for (const entry of whitelist) { + if (entry.matchMode !== 'regex') { + continue; + } + + if (getRegexValidationError(entry.pattern)) { + throw new Error(`Imported exception "${entry.id}" has an invalid regex pattern.`); + } + } +} + +function isOptionalFiniteNumber(value: unknown): value is number | undefined { + return value === undefined || (typeof value === 'number' && Number.isFinite(value)); +} + +function validateImportedStorageShape(raw: JsonObject): void { + const hasKnownCollections = + Object.hasOwn(raw, 'groups') || + Object.hasOwn(raw, 'filters') || + Object.hasOwn(raw, 'whitelist'); + + if (!hasKnownCollections) { + throw new Error('Settings file does not contain Teichos data.'); + } + + if (Object.hasOwn(raw, 'groups')) { + if (!Array.isArray(raw['groups']) || !raw['groups'].every(isValidGroup)) { + throw new Error('Settings file contains invalid groups.'); + } + } + + if (Object.hasOwn(raw, 'filters')) { + if (!Array.isArray(raw['filters']) || !raw['filters'].every(isValidFilterLike)) { + throw new Error('Settings file contains invalid filters.'); + } + } + + if (Object.hasOwn(raw, 'whitelist')) { + if (!Array.isArray(raw['whitelist']) || !raw['whitelist'].every(isValidWhitelistLike)) { + throw new Error('Settings file contains invalid exceptions.'); + } + } + + if (!isValidSnooze(raw['snooze'])) { + throw new Error('Settings file contains an invalid snooze state.'); + } + + if (!isOptionalFiniteNumber(raw['rulesVersion'])) { + throw new Error('Settings file contains an invalid rules version.'); + } + + if (raw['blockType'] !== undefined && !isValidBlockType(raw['blockType'])) { + throw new Error('Settings file contains an invalid block type.'); + } +} + +export function serializeDataForExport(data: StorageData): string { + return `${JSON.stringify(data, null, 2)}\n`; +} + +export function parseImportedData(serialized: string): StorageData { + let parsed: unknown; + + try { + parsed = JSON.parse(serialized); + } catch { + throw new Error('Settings file is not valid JSON.'); + } + + if (!isObject(parsed)) { + throw new Error('Settings file must contain a JSON object.'); + } + + validateImportedStorageShape(parsed); + + const normalized = normalizeStoredData(parsed as LegacyStorageData); + const groups = ensureDefaultGroup(normalized.groups); + const importedData: StorageData = { + ...normalized, + groups, + }; + + assertUniqueIds(importedData.groups, 'group'); + assertUniqueIds(importedData.filters, 'filter'); + assertUniqueIds(importedData.whitelist, 'exception'); + assertKnownGroupReferences(importedData.filters, importedData.whitelist, importedData.groups); + assertValidRegexEntries(importedData.filters, importedData.whitelist); + + return importedData; +} diff --git a/src/shared/storage/normalize.ts b/src/shared/storage/normalize.ts new file mode 100644 index 0000000..fd3d546 --- /dev/null +++ b/src/shared/storage/normalize.ts @@ -0,0 +1,111 @@ +import type { + BlockType, + Filter, + FilterBlockType, + FilterGroup, + FilterMatchMode, + SnoozeState, + StorageData, + Whitelist, +} from '../types'; +import { DEFAULT_GROUP_ID } from '../types'; +import { createDefaultData, createDefaultGroup } from './defaults'; +import { isValidBlockType, isValidFilterBlockType } from './guards'; + +export type LegacyFilter = Omit & { + readonly matchMode?: FilterMatchMode; + readonly blockType?: FilterBlockType; + readonly isRegex?: boolean; +}; + +export type LegacyWhitelist = Omit & { + readonly matchMode?: FilterMatchMode; + readonly isRegex?: boolean; + readonly groupId?: string; +}; + +export interface LegacyStorageData { + readonly groups?: readonly FilterGroup[]; + readonly filters?: readonly LegacyFilter[]; + readonly whitelist?: readonly LegacyWhitelist[]; + readonly rulesVersion?: number; + readonly blockType?: BlockType; + readonly snooze?: { + readonly active?: boolean; + readonly until?: number; + }; +} + +function resolveMatchMode( + matchMode: FilterMatchMode | undefined, + isRegex?: boolean +): FilterMatchMode { + if (matchMode === 'contains' || matchMode === 'exact' || matchMode === 'regex') { + return matchMode; + } + return isRegex ? 'regex' : 'contains'; +} + +function normalizeFilters(filters: readonly LegacyFilter[] | undefined): Filter[] { + return (filters ?? []).map(({ isRegex, matchMode, blockType, ...filter }) => ({ + ...filter, + matchMode: resolveMatchMode(matchMode, isRegex), + blockType: isValidFilterBlockType(blockType) ? blockType : 'default', + })); +} + +function normalizeWhitelist( + whitelist: readonly LegacyWhitelist[] | undefined, + groupIds: ReadonlySet +): Whitelist[] { + return (whitelist ?? []).map(({ isRegex, matchMode, groupId, ...entry }) => ({ + ...entry, + groupId: groupId && groupIds.has(groupId) ? groupId : DEFAULT_GROUP_ID, + matchMode: resolveMatchMode(matchMode, isRegex), + })); +} + +function normalizeSnooze(snooze: LegacyStorageData['snooze']): SnoozeState { + if (!snooze?.active) { + return { active: false }; + } + + if (typeof snooze.until === 'number' && Number.isFinite(snooze.until)) { + return { active: true, until: snooze.until }; + } + + return { active: true }; +} + +function normalizeGroups(groups: readonly FilterGroup[] | undefined): FilterGroup[] { + return (groups && groups.length > 0 ? groups : [createDefaultGroup()]).map((group) => ({ + ...group, + enabled: group.enabled ?? true, + })); +} + +export function normalizeStoredData(raw: LegacyStorageData | undefined): StorageData { + if (!raw) { + return createDefaultData(); + } + + const groups = normalizeGroups(raw.groups); + const groupIds = new Set(groups.map((group) => group.id)); + const filters = normalizeFilters(raw.filters); + const whitelist = normalizeWhitelist(raw.whitelist, groupIds); + const snooze = normalizeSnooze(raw.snooze); + const rulesVersion = + typeof raw.rulesVersion === 'number' && Number.isFinite(raw.rulesVersion) + ? raw.rulesVersion + : 0; + const blockType = isValidBlockType(raw.blockType) ? raw.blockType : 'block'; + + return { + groups, + filters, + whitelist, + snooze, + blockType, + rulesVersion, + }; +} diff --git a/src/shared/types/messages.ts b/src/shared/types/messages.ts index 243a384..a1b10a5 100644 --- a/src/shared/types/messages.ts +++ b/src/shared/types/messages.ts @@ -3,14 +3,11 @@ * Uses discriminated unions for type-safe message handling */ -import type { BlockedPageState, Filter, StorageData } from './storage'; +import type { BlockedPageState, StorageData } from './storage'; -// Message types enum for discriminated union export const MessageType = { GET_DATA: 'GET_DATA', - DATA_UPDATED: 'DATA_UPDATED', CHECK_URL: 'CHECK_URL', - URL_BLOCKED: 'URL_BLOCKED', GET_BLOCKED_PAGE_STATE: 'GET_BLOCKED_PAGE_STATE', GO_BACK_ACTIVE_TAB: 'GO_BACK_ACTIVE_TAB', CONTINUE_ACTIVE_TAB: 'CONTINUE_ACTIVE_TAB', @@ -19,7 +16,6 @@ export const MessageType = { export type MessageTypeValue = (typeof MessageType)[keyof typeof MessageType]; -// Request messages (sent to background) export interface GetDataMessage { readonly type: typeof MessageType.GET_DATA; } @@ -35,6 +31,7 @@ export interface GoBackActiveTabMessage { export interface ContinueActiveTabMessage { readonly type: typeof MessageType.CONTINUE_ACTIVE_TAB; + readonly blockId?: string; } export interface GetBlockedPageStateMessage { @@ -42,7 +39,6 @@ export interface GetBlockedPageStateMessage { readonly blockId?: string; } -// Response messages export interface GetDataResponse { readonly success: true; readonly data: StorageData; @@ -73,34 +69,18 @@ export type GetBlockedPageStateResponse = readonly status: 'unavailable'; }; -// Notification messages (broadcast) -export interface DataUpdatedMessage { - readonly type: typeof MessageType.DATA_UPDATED; - readonly data: StorageData; -} - -export interface UrlBlockedMessage { - readonly type: typeof MessageType.URL_BLOCKED; - readonly url: string; - readonly filter: Filter; -} - export interface CloseInfoPanelMessage { readonly type: typeof MessageType.CLOSE_INFO_PANEL; } -// Discriminated union of all messages export type ExtensionMessage = | GetDataMessage | CheckUrlMessage | GoBackActiveTabMessage | ContinueActiveTabMessage | GetBlockedPageStateMessage - | DataUpdatedMessage - | UrlBlockedMessage | CloseInfoPanelMessage; -// Response type mapping export type MessageResponse = T extends GetDataMessage ? GetDataResponse : T extends CheckUrlMessage @@ -113,7 +93,6 @@ export type MessageResponse = T extends GetDataMessa ? GetBlockedPageStateResponse : undefined; -// Type guards for message validation export function isGetDataMessage(msg: unknown): msg is GetDataMessage { return ( typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === MessageType.GET_DATA @@ -145,7 +124,8 @@ export function isContinueActiveTabMessage(msg: unknown): msg is ContinueActiveT typeof msg === 'object' && msg !== null && 'type' in msg && - msg.type === MessageType.CONTINUE_ACTIVE_TAB + msg.type === MessageType.CONTINUE_ACTIVE_TAB && + (!('blockId' in msg) || typeof msg.blockId === 'string') ); } @@ -159,27 +139,6 @@ export function isGetBlockedPageStateMessage(msg: unknown): msg is GetBlockedPag ); } -export function isDataUpdatedMessage(msg: unknown): msg is DataUpdatedMessage { - return ( - typeof msg === 'object' && - msg !== null && - 'type' in msg && - msg.type === MessageType.DATA_UPDATED && - 'data' in msg - ); -} - -export function isUrlBlockedMessage(msg: unknown): msg is UrlBlockedMessage { - return ( - typeof msg === 'object' && - msg !== null && - 'type' in msg && - msg.type === MessageType.URL_BLOCKED && - 'url' in msg && - 'filter' in msg - ); -} - export function isCloseInfoPanelMessage(msg: unknown): msg is CloseInfoPanelMessage { return ( typeof msg === 'object' && diff --git a/src/shared/utils/filteringEngine.ts b/src/shared/utils/filteringEngine.ts index 5c76d1d..5f7f094 100644 --- a/src/shared/utils/filteringEngine.ts +++ b/src/shared/utils/filteringEngine.ts @@ -1,160 +1 @@ -import type { BlockType, Filter, FilterGroup, StorageData, Whitelist } from '../types'; -import type { ScheduleContext } from './filters'; -import { - buildGroupById, - buildWhitelistByGroup, - getFilterEffectiveState, - getScheduleContext, - isSnoozeActive, - isTemporaryFilter, - isTemporaryFilterExpired, - matchesPattern, - sortFiltersTemporaryFirst, -} from './filters'; - -export type FilterDecisionAllowReason = - | 'no-match' - | 'snoozed' - | 'whitelisted' - | 'group-inactive' - | 'filter-disabled' - | 'temporary-expired'; - -export type FilterDecision = - | { readonly action: 'allow'; readonly reason: FilterDecisionAllowReason } - | { - readonly action: 'block'; - readonly filterId: string; - readonly groupId: string; - readonly blockType: BlockType; - readonly reason: 'matched-filter'; - }; - -export interface FilteringEngine { - readonly data: StorageData; - readonly groupsById: ReadonlyMap; - readonly whitelistByGroup: ReadonlyMap; - evaluate: (url: string, context?: ScheduleContext) => FilterDecision; -} - -const FILTER_DECISION_REASON_PRIORITY: Record = { - 'no-match': 0, - 'filter-disabled': 1, - 'temporary-expired': 2, - 'group-inactive': 3, - whitelisted: 4, - snoozed: 5, -}; - -export function createFilteringEngine(data: StorageData): FilteringEngine { - const groupsById = buildGroupById(data.groups); - const whitelistByGroup = buildWhitelistByGroup(data.whitelist); - const orderedFilters = sortFiltersTemporaryFirst(data.filters); - - return { - data, - groupsById, - whitelistByGroup, - evaluate(url, context = getScheduleContext()): FilterDecision { - return evaluateFilterDecision(url, data, { - context, - filters: orderedFilters, - groupsById, - whitelistByGroup, - }); - }, - }; -} - -interface EvaluationOptions { - readonly context: ScheduleContext; - readonly filters: readonly Filter[]; - readonly groupsById: ReadonlyMap; - readonly whitelistByGroup: ReadonlyMap; -} - -export function evaluateFilterDecision( - url: string, - data: StorageData, - options?: Partial -): FilterDecision { - if (isSnoozeActive(data.snooze)) { - return { action: 'allow', reason: 'snoozed' }; - } - - const context = options?.context ?? getScheduleContext(); - const filters = options?.filters ?? sortFiltersTemporaryFirst(data.filters); - const groupsById = options?.groupsById ?? buildGroupById(data.groups); - const whitelistByGroup = options?.whitelistByGroup ?? buildWhitelistByGroup(data.whitelist); - const urlLower = url.toLowerCase(); - const now = Date.now(); - let fallbackReason: FilterDecisionAllowReason = 'no-match'; - let warningDecision: Extract | undefined; - - for (const filter of filters) { - if (!matchesPattern(url, filter, undefined, urlLower)) { - continue; - } - - if (!filter.enabled) { - fallbackReason = selectHigherPriorityReason(fallbackReason, 'filter-disabled'); - continue; - } - - if (isTemporaryFilterExpired(filter, now)) { - fallbackReason = selectHigherPriorityReason(fallbackReason, 'temporary-expired'); - continue; - } - - if (!getFilterEffectiveState(filter, groupsById, context, now).groupActive) { - fallbackReason = selectHigherPriorityReason(fallbackReason, 'group-inactive'); - continue; - } - - if (!isTemporaryFilter(filter)) { - const groupWhitelist = whitelistByGroup.get(filter.groupId); - if (groupWhitelist?.some((entry) => matchesPattern(url, entry, undefined, urlLower))) { - fallbackReason = selectHigherPriorityReason(fallbackReason, 'whitelisted'); - continue; - } - } - - const blockType = resolveFilterBlockType(filter, data); - const decision: Extract = { - action: 'block', - filterId: filter.id, - groupId: filter.groupId, - blockType, - reason: 'matched-filter', - }; - - if (blockType === 'block') { - return decision; - } - - warningDecision ??= decision; - } - - if (warningDecision) { - return warningDecision; - } - - return { action: 'allow', reason: fallbackReason }; -} - -function resolveFilterBlockType(filter: Filter, data: StorageData): BlockType { - if (filter.blockType === 'block' || filter.blockType === 'warning') { - return filter.blockType; - } - - return data.blockType === 'warning' ? 'warning' : 'block'; -} - -function selectHigherPriorityReason( - current: FilterDecisionAllowReason, - candidate: FilterDecisionAllowReason -): FilterDecisionAllowReason { - return FILTER_DECISION_REASON_PRIORITY[candidate] > FILTER_DECISION_REASON_PRIORITY[current] - ? candidate - : current; -} +export * from '../filtering/engine'; diff --git a/src/shared/utils/filters.ts b/src/shared/utils/filters.ts index d984dfd..78d5b16 100644 --- a/src/shared/utils/filters.ts +++ b/src/shared/utils/filters.ts @@ -1,429 +1,2 @@ -/** - * Filter matching and scheduling utilities - */ - -import type { Filter, FilterGroup, FilterMatchMode, SnoozeState, Whitelist } from '../types'; -import { getCurrentTimeString, getCurrentDayOfWeek } from './helpers'; - -export type WhitelistByGroup = ReadonlyMap; -export type GroupById = ReadonlyMap; -export type GroupLookup = GroupById | readonly FilterGroup[]; -export interface ScheduleContext { - readonly dayOfWeek: number; - readonly time: string; -} -export interface PreparedPattern { - readonly pattern: string; - readonly matchMode: FilterMatchMode; - readonly patternLower?: string; - readonly regex?: RegExp | null; -} -export type PreparedFilter = Filter & { - readonly patternLower?: string; - readonly regex?: RegExp | null; -}; -export type PreparedWhitelist = Whitelist & { - readonly patternLower?: string; - readonly regex?: RegExp | null; -}; -export interface BlockingIndex { - readonly groupsById: GroupById; - readonly filters: readonly PreparedFilter[]; - readonly whitelistByGroup: WhitelistByGroup; -} - -export interface FilterEffectiveState { - readonly filterEnabled: boolean; - readonly groupEnabled: boolean; - readonly groupActive: boolean; - readonly active: boolean; -} - -export function isTemporaryFilter(filter: Filter): filter is Filter & { expiresAt: number } { - return typeof filter.expiresAt === 'number' && Number.isFinite(filter.expiresAt); -} - -export function getTemporaryFilterRemainingMs(filter: Filter, now = Date.now()): number | null { - if (!isTemporaryFilter(filter)) { - return null; - } - return filter.expiresAt - now; -} - -export function isTemporaryFilterExpired(filter: Filter, now = Date.now()): boolean { - const remaining = getTemporaryFilterRemainingMs(filter, now); - return remaining !== null && remaining <= 0; -} - -export function getSnoozeRemainingMs( - snooze: SnoozeState | undefined, - now = Date.now() -): number | null { - if (!snooze?.active) { - return null; - } - - if (typeof snooze.until !== 'number' || !Number.isFinite(snooze.until)) { - return null; - } - - return snooze.until - now; -} - -export function isSnoozeActive(snooze: SnoozeState | undefined, now = Date.now()): boolean { - if (!snooze?.active) { - return false; - } - - const remaining = getSnoozeRemainingMs(snooze, now); - return remaining === null || remaining > 0; -} - -export function isSnoozeExpired(snooze: SnoozeState | undefined, now = Date.now()): boolean { - if (!snooze?.active) { - return false; - } - - const remaining = getSnoozeRemainingMs(snooze, now); - return remaining !== null && remaining <= 0; -} - -/** - * Return a new array with temporary filters first while preserving relative order. - */ -export function sortFiltersTemporaryFirst(filters: readonly T[]): T[] { - const temporary: T[] = []; - const nonTemporary: T[] = []; - - for (const filter of filters) { - if (isTemporaryFilter(filter)) { - temporary.push(filter); - } else { - nonTemporary.push(filter); - } - } - - return [...temporary, ...nonTemporary]; -} - -export function getRegexValidationError(pattern: string): string | null { - try { - new RegExp(pattern); - return null; - } catch (error) { - return error instanceof Error ? error.message : String(error); - } -} - -export function getScheduleContext(): ScheduleContext { - return { - dayOfWeek: getCurrentDayOfWeek(), - time: getCurrentTimeString(), - }; -} - -export function buildGroupById(groups: readonly FilterGroup[]): GroupById { - return new Map(groups.map((group) => [group.id, group])); -} - -/** - * Check whether a group is enabled for runtime evaluation. - * Groups without a persisted enabled flag default to enabled for backwards compatibility. - */ -export function isGroupEnabled(group: FilterGroup | undefined): boolean { - return group?.enabled !== false; -} - -/** - * Check if a URL matches a filter pattern - * @param url - The URL to check - * @param pattern - The pattern to match against - * @param matchMode - Matching mode (default: contains) - */ -export function matchesPattern( - url: string, - pattern: string | PreparedPattern, - matchMode: FilterMatchMode = 'contains', - urlLower?: string -): boolean { - let resolvedPattern: string; - let resolvedMode: FilterMatchMode; - let patternLower: string | undefined; - let regex: RegExp | null | undefined; - - if (typeof pattern === 'string') { - resolvedPattern = pattern; - resolvedMode = matchMode; - } else { - resolvedPattern = pattern.pattern; - resolvedMode = pattern.matchMode; - patternLower = pattern.patternLower; - regex = pattern.regex; - } - - if (resolvedMode === 'regex') { - if (regex === null) { - return false; - } - const resolvedRegex = regex ?? compileRegex(resolvedPattern); - if (!resolvedRegex) { - return false; - } - return resolvedRegex.test(url); - } - - const normalizedUrl = urlLower ?? url.toLowerCase(); - const normalizedPattern = patternLower ?? resolvedPattern.toLowerCase(); - - if (resolvedMode === 'exact') { - return normalizedUrl === normalizedPattern; - } - - return normalizedUrl.includes(normalizedPattern); -} - -export function buildWhitelistByGroup(whitelist: readonly Whitelist[]): WhitelistByGroup; -export function buildWhitelistByGroup( - whitelist: readonly T[] -): WhitelistByGroup { - const whitelistByGroup = new Map(); - for (const entry of whitelist) { - if (!entry.enabled) { - continue; - } - const groupEntries = whitelistByGroup.get(entry.groupId); - if (groupEntries) { - groupEntries.push(entry); - } else { - whitelistByGroup.set(entry.groupId, [entry]); - } - } - return whitelistByGroup; -} - -export function buildBlockingIndex( - filters: readonly Filter[], - groups: readonly FilterGroup[], - whitelist: readonly Whitelist[] -): BlockingIndex { - const groupsById = buildGroupById(groups); - const preparedFilters: PreparedFilter[] = []; - for (const filter of filters) { - if (!filter.enabled) { - continue; - } - if (!groupsById.has(filter.groupId)) { - continue; - } - preparedFilters.push(prepareFilter(filter)); - } - - const whitelistByGroup = new Map(); - for (const entry of whitelist) { - if (!entry.enabled) { - continue; - } - if (!groupsById.has(entry.groupId)) { - continue; - } - const preparedEntry = prepareWhitelist(entry); - const groupEntries = whitelistByGroup.get(entry.groupId); - if (groupEntries) { - groupEntries.push(preparedEntry); - } else { - whitelistByGroup.set(entry.groupId, [preparedEntry]); - } - } - - return { groupsById, filters: preparedFilters, whitelistByGroup }; -} - -/** - * Check if a filter is currently active based on its group's schedule - * @param filter - The filter to check - * @param groups - All available filter groups - */ -export function isFilterActive( - filter: Filter, - groups: GroupLookup, - context: ScheduleContext = getScheduleContext() -): boolean { - return getFilterEffectiveState(filter, groups, context).active; -} - -/** - * Check if a filter's group schedule is currently active - * @param filter - The filter to check - * @param groups - All available filter groups - */ -export function isFilterScheduledActive( - filter: Filter, - groups: GroupLookup, - context: ScheduleContext = getScheduleContext() -): boolean { - return getFilterEffectiveState(filter, groups, context).groupActive; -} - -/** - * Resolve a filter's persisted and effective runtime state. - * Returns the child filter's saved enabled flag, the group's saved enabled flag, - * whether the group is currently active after enabled+schedule checks, and whether - * the filter is fully active for blocking after child, group, and expiry checks. - */ -export function getFilterEffectiveState( - filter: Filter, - groups: GroupLookup, - context: ScheduleContext = getScheduleContext(), - now = Date.now() -): FilterEffectiveState { - const expired = isTemporaryFilterExpired(filter, now); - if (expired) { - return { - filterEnabled: filter.enabled, - groupEnabled: false, - groupActive: false, - active: false, - }; - } - - const group = getGroupFromLookup(filter.groupId, groups); - if (!group) { - return { - filterEnabled: filter.enabled, - groupEnabled: false, - groupActive: false, - active: false, - }; - } - - const groupEnabled = isGroupEnabled(group); - const groupActive = groupEnabled && isGroupScheduleActive(group, context); - - return { - filterEnabled: filter.enabled, - groupEnabled, - groupActive, - active: filter.enabled && groupActive, - }; -} - -export function shouldBlockUrlWithIndex( - url: string, - blockingIndex: BlockingIndex, - context: ScheduleContext = getScheduleContext() -): PreparedFilter | undefined { - if (blockingIndex.filters.length === 0) { - return undefined; - } - - const urlLower = url.toLowerCase(); - const now = Date.now(); - const activeGroupStatus = new Map(); - const whitelistStatus = new Map(); - - for (const filter of blockingIndex.filters) { - if (isTemporaryFilterExpired(filter, now)) { - continue; - } - - let isActive = activeGroupStatus.get(filter.groupId); - if (isActive === undefined) { - const group = blockingIndex.groupsById.get(filter.groupId); - isActive = group ? isGroupEnabled(group) && isGroupScheduleActive(group, context) : false; - activeGroupStatus.set(filter.groupId, isActive); - } - if (!isActive) { - continue; - } - - if (!isTemporaryFilter(filter)) { - let isWhitelisted = whitelistStatus.get(filter.groupId); - if (isWhitelisted === undefined) { - const groupWhitelist = blockingIndex.whitelistByGroup.get(filter.groupId); - isWhitelisted = groupWhitelist - ? groupWhitelist.some((entry) => matchesPattern(url, entry, undefined, urlLower)) - : false; - whitelistStatus.set(filter.groupId, isWhitelisted); - } - if (isWhitelisted) { - continue; - } - } - - if (matchesPattern(url, filter, undefined, urlLower)) { - return filter; - } - } - - return undefined; -} - -/** - * Check if a URL should be blocked based on filters and whitelist - * @param url - The URL to check - * @param filters - All filters - * @param groups - All filter groups - * @param whitelist - All whitelist entries scoped to groups - * @returns The matching filter if blocked, undefined if not blocked - */ -export function shouldBlockUrl( - url: string, - filters: readonly Filter[], - groups: readonly FilterGroup[], - whitelist: readonly Whitelist[] -): Filter | undefined { - const blockingIndex = buildBlockingIndex(filters, groups, whitelist); - return shouldBlockUrlWithIndex(url, blockingIndex); -} - -function compileRegex(pattern: string): RegExp | null { - try { - return new RegExp(pattern); - } catch { - return null; - } -} - -function preparePattern( - pattern: string, - matchMode: FilterMatchMode -): Pick { - if (matchMode === 'regex') { - return { regex: compileRegex(pattern) }; - } - return { patternLower: pattern.toLowerCase() }; -} - -function prepareFilter(filter: Filter): PreparedFilter { - return { - ...filter, - ...preparePattern(filter.pattern, filter.matchMode), - }; -} - -function prepareWhitelist(entry: Whitelist): PreparedWhitelist { - return { - ...entry, - ...preparePattern(entry.pattern, entry.matchMode), - }; -} - -function getGroupFromLookup(groupId: string, groups: GroupLookup): FilterGroup | undefined { - if (Array.isArray(groups)) { - return groups.find((group) => group.id === groupId); - } - return (groups as GroupById).get(groupId); -} - -function isGroupScheduleActive(group: FilterGroup, context: ScheduleContext): boolean { - if (group.is24x7) { - return true; - } - - return group.schedules.some((schedule) => { - if (!schedule.daysOfWeek.includes(context.dayOfWeek)) { - return false; - } - return context.time >= schedule.startTime && context.time <= schedule.endTime; - }); -} +export * from '../filtering/patterns'; +export * from '../filtering/schedules'; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 14ceacb..45ec40d 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -3,7 +3,8 @@ */ export * from './helpers'; -export * from './filters'; -export * from './filteringEngine'; +export * from '../filtering/patterns'; +export * from '../filtering/schedules'; +export * from '../filtering/engine'; export * from './dom'; export * from './schedules'; diff --git a/test/e2e/blocked.spec.ts b/test/e2e/blocked.spec.ts index d5015b1..3cdca75 100644 --- a/test/e2e/blocked.spec.ts +++ b/test/e2e/blocked.spec.ts @@ -5,6 +5,7 @@ import { defaultGroup, expectAllowed, expectBlocked, + mockAllowedPage, seedStorage, } from './helpers'; import { PAGES } from '../../src/shared/constants'; @@ -47,9 +48,7 @@ test('go back restores the last allowed url', async ({ await chrome.storage.session.set({ [`last_allowed_url_${tab.id}`]: url }); } }, allowedUrl); - await page.goto(blockedUrl).catch(() => undefined); - - await expect(page.getByLabel('Blocked URL')).toHaveText(blockedUrl); + await expectBlocked(page, blockedUrl); await captureScreenshot(page, testInfo, 'blocked-page.png'); await Promise.all([ @@ -102,6 +101,7 @@ test('renders the blocked url and responsible filter from block id state', async test('warning blocks show Continue and allow same-tab bypass', async ({ extensionPage, page }) => { const targetUrl = 'https://example.com/warning-focus'; + await mockAllowedPage(page, targetUrl, 'Warning bypass allowed'); await page.goto(extensionPage(PAGES.OPTIONS)); await seedStorage( diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts index 83f408d..1eb46fb 100644 --- a/test/e2e/helpers.ts +++ b/test/e2e/helpers.ts @@ -3,6 +3,7 @@ import { DAY_NAMES, PAGES } from '../../src/shared/constants'; import type { BlockedTabState, StorageData } from '../../src/shared/types'; export const STORAGE_KEY = 'pageblock_data'; +const EXTENSION_NAVIGATION_TIMEOUT_MS = 10_000; export type ClipboardCaptureGlobal = typeof globalThis & { __e2eClipboardText?: string; @@ -47,7 +48,9 @@ export async function readStorage(page: Page): Promise } export async function expectBlocked(page: Page, targetUrl: string): Promise { - await page.goto(targetUrl).catch(() => undefined); + await page + .goto(targetUrl, { waitUntil: 'commit', timeout: EXTENSION_NAVIGATION_TIMEOUT_MS }) + .catch(() => undefined); await expect .poll(() => { const currentUrl = new URL(page.url()); @@ -64,7 +67,9 @@ export async function expectBlocked(page: Page, targetUrl: string): Promise { - await page.goto(targetUrl).catch(() => undefined); + await page + .goto(targetUrl, { waitUntil: 'commit', timeout: EXTENSION_NAVIGATION_TIMEOUT_MS }) + .catch(() => undefined); await expect.poll(() => page.url()).not.toContain(`/${PAGES.BLOCKED}`); } @@ -75,9 +80,14 @@ export async function openOptions( const optionsPage = await page.context().newPage(); await optionsPage.goto(extensionPage(PAGES.OPTIONS)); await optionsPage.waitForLoadState('domcontentloaded'); + await waitForOptionsReady(optionsPage); return optionsPage; } +export async function waitForOptionsReady(page: Page): Promise { + await expect(page.locator('html[data-options-ready="true"]')).toHaveCount(1); +} + export async function openPopup( extensionPage: (relativePath: string) => string, page: Page @@ -87,9 +97,14 @@ export async function openPopup( await page.bringToFront(); await popupPage.reload(); await popupPage.waitForLoadState('domcontentloaded'); + await waitForPopupReady(popupPage); return popupPage; } +export async function waitForPopupReady(page: Page): Promise { + await expect(page.locator('html[data-popup-ready="true"]')).toHaveCount(1); +} + async function openGroupIfNeeded(optionsPage: Page, groupName: string): Promise { const group = optionsPage.locator('details.group-item').filter({ hasText: groupName }); await expect(group).toHaveCount(1); @@ -117,10 +132,10 @@ export async function createFilterViaOptions( const modal = optionsPage.locator('#filter-modal.active'); await expect(modal).toBeVisible(); - await modal.getByLabel('Name').fill(filter.name ?? ''); - await modal.getByLabel('URL Pattern').fill(filter.pattern); - await modal.getByLabel('Match Mode').selectOption(filter.matchMode ?? 'contains'); - await modal.getByLabel('Block Type').selectOption(filter.blockType ?? 'default'); + await modal.locator('#filter-description').fill(filter.name ?? ''); + await modal.locator('#filter-pattern').fill(filter.pattern); + await modal.locator('#filter-match-mode').selectOption(filter.matchMode ?? 'contains'); + await modal.locator('#filter-block-type').selectOption(filter.blockType ?? 'default'); const enabled = filter.enabled ?? true; const enabledInput = modal.getByLabel('Enabled'); @@ -152,7 +167,7 @@ export async function createGroupViaOptions( const modal = optionsPage.locator('#group-modal.active'); await expect(modal).toBeVisible(); - await modal.getByLabel('Group Name').fill(group.name); + await modal.locator('#group-name').fill(group.name); const alwaysActive = modal.getByLabel('Always Active (24/7)'); const is24x7 = group.is24x7 ?? false; @@ -181,8 +196,12 @@ export async function createGroupViaOptions( } } - await modal.getByLabel(`Start time for schedule ${index + 1}`).fill(schedule.startTime); - await modal.getByLabel(`End time for schedule ${index + 1}`).fill(schedule.endTime); + await modal + .locator(`input[aria-label="Start time for schedule ${index + 1}"]`) + .fill(schedule.startTime); + await modal + .locator(`input[aria-label="End time for schedule ${index + 1}"]`) + .fill(schedule.endTime); } } @@ -210,9 +229,9 @@ export async function createWhitelistViaOptions( const modal = optionsPage.locator('#whitelist-modal.active'); await expect(modal).toBeVisible(); - await modal.getByLabel('Name').fill(whitelist.name ?? ''); - await modal.getByLabel('URL Pattern').fill(whitelist.pattern); - await modal.getByLabel('Match Mode').selectOption(whitelist.matchMode ?? 'contains'); + await modal.locator('#whitelist-description').fill(whitelist.name ?? ''); + await modal.locator('#whitelist-pattern').fill(whitelist.pattern); + await modal.locator('#whitelist-match-mode').selectOption(whitelist.matchMode ?? 'contains'); const enabled = whitelist.enabled ?? true; const enabledInput = modal.getByLabel('Enabled'); diff --git a/test/e2e/lifecycle.spec.ts b/test/e2e/lifecycle.spec.ts index eb5d951..9895939 100644 --- a/test/e2e/lifecycle.spec.ts +++ b/test/e2e/lifecycle.spec.ts @@ -254,8 +254,8 @@ test('whitelisting keeps navigation allowed and hides the matching popup filter const whitelistModal = optionsPage.locator('#whitelist-modal.active'); await expect(whitelistModal).toBeVisible(); - await whitelistModal.getByLabel('Name').fill('Allow Docs'); - await whitelistModal.getByLabel('URL Pattern').fill(targetUrl); + await whitelistModal.locator('#whitelist-description').fill('Allow Docs'); + await whitelistModal.locator('#whitelist-pattern').fill(targetUrl); await whitelistModal.getByRole('button', { name: 'Save' }).click(); await expect( defaultGroupCard.locator('.filter-item').filter({ hasText: 'Allow Docs' }) diff --git a/test/e2e/options.spec.ts b/test/e2e/options.spec.ts index a5a9cec..9a9bd38 100644 --- a/test/e2e/options.spec.ts +++ b/test/e2e/options.spec.ts @@ -11,12 +11,22 @@ import { openPopup, readStorage, seedStorage, + waitForOptionsReady, } from './helpers'; const OPTIONS_PATHNAME = `/${PAGES.OPTIONS}`; +async function gotoOptions( + extensionPage: (relativePath: string) => string, + page: Parameters[0], + path: string = PAGES.OPTIONS +): Promise { + await page.goto(extensionPage(path)); + await waitForOptionsReady(page); +} + test('shows schedule hints in the group header', async ({ extensionPage, page }, testInfo) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); await seedStorage( page, createStorageData({ @@ -46,13 +56,13 @@ test('creates, edits, and deletes a scheduled group with filters and exceptions' extensionPage, page, }, testInfo) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); const groupModal = page.locator('#group-modal.active'); await expect(page.getByRole('button', { name: 'New Group' })).toBeVisible(); await page.getByRole('button', { name: 'New Group' }).click(); await expect(groupModal).toBeVisible(); - await groupModal.getByLabel('Group Name').fill('Work Hours'); + await groupModal.locator('#group-name').fill('Work Hours'); await groupModal.getByRole('button', { name: 'New Schedule' }).click(); await expect(groupModal.getByLabel('Start time for schedule 1')).toHaveValue('09:00'); await expect(groupModal.getByLabel('End time for schedule 1')).toHaveValue('17:00'); @@ -65,16 +75,16 @@ test('creates, edits, and deletes a scheduled group with filters and exceptions' await workHoursGroup.getByRole('button', { name: 'New Filter' }).click(); const filterModal = page.locator('#filter-modal.active'); await expect(filterModal).toBeVisible(); - await filterModal.getByLabel('Name').fill('Focus Block'); - await filterModal.getByLabel('URL Pattern').fill('focus.example.com'); + await filterModal.locator('#filter-description').fill('Focus Block'); + await filterModal.locator('#filter-pattern').fill('focus.example.com'); await filterModal.getByRole('button', { name: 'Save' }).click(); await expect(workHoursGroup).toContainText('Focus Block'); await workHoursGroup.getByRole('button', { name: 'New Exception' }).click(); const whitelistModal = page.locator('#whitelist-modal.active'); await expect(whitelistModal).toBeVisible(); - await whitelistModal.getByLabel('Name').fill('Allow Docs'); - await whitelistModal.getByLabel('URL Pattern').fill('focus.example.com/docs'); + await whitelistModal.locator('#whitelist-description').fill('Allow Docs'); + await whitelistModal.locator('#whitelist-pattern').fill('focus.example.com/docs'); await whitelistModal.getByRole('button', { name: 'Save' }).click(); await expect(workHoursGroup).toContainText('focus.example.com/docs'); @@ -82,7 +92,7 @@ test('creates, edits, and deletes a scheduled group with filters and exceptions' await workHoursGroup.locator('button[data-action="edit-group"]').click(); await expect(groupModal).toBeVisible(); - await groupModal.getByLabel('Group Name').fill('Deep Work'); + await groupModal.locator('#group-name').fill('Deep Work'); await groupModal.getByLabel('Always Active (24/7)').check(); await groupModal.getByRole('button', { name: 'Save' }).click(); @@ -112,7 +122,7 @@ test('shows an alert for invalid regex filters', async ({ extensionPage, page }) (globalThis as AlertCaptureGlobal).__lastAlertMessage = message ?? ''; }; }); - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); await page .locator('details.group-item') @@ -121,8 +131,8 @@ test('shows an alert for invalid regex filters', async ({ extensionPage, page }) .click(); const filterModal = page.locator('#filter-modal.active'); - await filterModal.getByLabel('URL Pattern').fill('('); - await filterModal.getByLabel('Match Mode').selectOption('regex'); + await filterModal.locator('#filter-pattern').fill('('); + await filterModal.locator('#filter-match-mode').selectOption('regex'); await filterModal.getByRole('button', { name: 'Save' }).click(); @@ -134,7 +144,7 @@ test('shows an alert for invalid regex filters', async ({ extensionPage, page }) }); test('exports current settings from global settings', async ({ extensionPage, page }) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); const expectedData = createStorageData({ groups: [ defaultGroup, @@ -187,7 +197,7 @@ test('persists global and per-filter block types from the options UI', async ({ extensionPage, page, }) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); await page.getByLabel('Block Type').first().selectOption('warning'); await expect(page.locator('#global-settings-status')).toHaveText('Block type updated.'); @@ -202,7 +212,7 @@ test('persists global and per-filter block types from the options UI', async ({ await filterCard.getByRole('button', { name: 'Edit' }).click(); const filterModal = page.locator('#filter-modal.active'); await expect(filterModal).toBeVisible(); - await filterModal.getByLabel('Block Type').selectOption('block'); + await filterModal.locator('#filter-block-type').selectOption('block'); await filterModal.getByRole('button', { name: 'Save' }).click(); await expect @@ -219,7 +229,7 @@ test('persists global and per-filter block types from the options UI', async ({ }); test('imports settings from global settings', async ({ extensionPage, page }) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); await seedStorage( page, createStorageData({ @@ -298,7 +308,7 @@ test('keeps existing settings when global settings import fails', async ({ extensionPage, page, }) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); const originalData = createStorageData({ filters: [ { @@ -330,7 +340,7 @@ test('opens filter, group, and exception modals from query params', async ({ extensionPage, page, }) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); await seedStorage( page, createStorageData({ @@ -347,22 +357,22 @@ test('opens filter, group, and exception modals from query params', async ({ }) ); - await page.goto(extensionPage(`${PAGES.OPTIONS}?modal=group`)); + await gotoOptions(extensionPage, page, `${PAGES.OPTIONS}?modal=group`); await expect(page.locator('#group-modal.active')).toBeVisible(); await expect.poll(() => new URL(page.url()).pathname).toBe(OPTIONS_PATHNAME); await page.getByRole('button', { name: 'Close group dialog' }).click(); - await page.goto(extensionPage(`${PAGES.OPTIONS}?modal=filter`)); + await gotoOptions(extensionPage, page, `${PAGES.OPTIONS}?modal=filter`); await expect(page.locator('#filter-modal.active')).toBeVisible(); await expect.poll(() => new URL(page.url()).pathname).toBe(OPTIONS_PATHNAME); await page.getByRole('button', { name: 'Close filter dialog' }).click(); - await page.goto(extensionPage(`${PAGES.OPTIONS}?modal=whitelist`)); + await gotoOptions(extensionPage, page, `${PAGES.OPTIONS}?modal=whitelist`); await expect(page.locator('#whitelist-modal.active')).toBeVisible(); await expect.poll(() => new URL(page.url()).pathname).toBe(OPTIONS_PATHNAME); await page.getByRole('button', { name: 'Close exception dialog' }).click(); - await page.goto(extensionPage(`${PAGES.OPTIONS}?editFilter=seeded-filter`)); + await gotoOptions(extensionPage, page, `${PAGES.OPTIONS}?editFilter=seeded-filter`); const filterModal = page.locator('#filter-modal.active'); await expect(filterModal).toBeVisible(); await expect.poll(() => new URL(page.url()).pathname).toBe(OPTIONS_PATHNAME); @@ -375,7 +385,7 @@ test('opens the about panel from query params and closes it when popup settings page, }) => { const optionsPage = page; - await optionsPage.goto(extensionPage(`${PAGES.OPTIONS}?info=1`)); + await gotoOptions(extensionPage, optionsPage, `${PAGES.OPTIONS}?info=1`); const infoPopover = optionsPage.locator('.info-popover'); const infoButton = optionsPage.getByRole('button', { name: 'About' }); @@ -395,7 +405,7 @@ test('edits and deletes individual filters and exceptions from options', async ( extensionPage, page, }) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); await createFilterViaOptions(page, { name: 'Editable Filter', pattern: 'editable-filter.example.test', @@ -415,9 +425,9 @@ test('edits and deletes individual filters and exceptions from options', async ( await filterItem.getByRole('button', { name: 'Edit' }).click(); const filterModal = page.locator('#filter-modal.active'); await expect(filterModal).toBeVisible(); - await filterModal.getByLabel('Name').fill('Updated Filter'); - await filterModal.getByLabel('URL Pattern').fill('https://editable-filter.example.test/focus'); - await filterModal.getByLabel('Match Mode').selectOption('exact'); + await filterModal.locator('#filter-description').fill('Updated Filter'); + await filterModal.locator('#filter-pattern').fill('https://editable-filter.example.test/focus'); + await filterModal.locator('#filter-match-mode').selectOption('exact'); await filterModal.getByRole('button', { name: 'Save' }).click(); await expect(defaultGroupCard).toContainText('Updated Filter'); await expect(defaultGroupCard).toContainText('https://editable-filter.example.test/focus'); @@ -428,11 +438,11 @@ test('edits and deletes individual filters and exceptions from options', async ( await exceptionItem.getByRole('button', { name: 'Edit' }).click(); const whitelistModal = page.locator('#whitelist-modal.active'); await expect(whitelistModal).toBeVisible(); - await whitelistModal.getByLabel('Name').fill('Updated Exception'); + await whitelistModal.locator('#whitelist-description').fill('Updated Exception'); await whitelistModal - .getByLabel('URL Pattern') + .locator('#whitelist-pattern') .fill('^https://editable-filter\\.example\\.test/docs/\\d+$'); - await whitelistModal.getByLabel('Match Mode').selectOption('regex'); + await whitelistModal.locator('#whitelist-match-mode').selectOption('regex'); await whitelistModal.getByRole('button', { name: 'Save' }).click(); await expect(defaultGroupCard).toContainText('Updated Exception'); await expect(defaultGroupCard).toContainText( @@ -473,12 +483,12 @@ test('updates selected days and supports empty schedules in the group editor', a extensionPage, page, }) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); await page.getByRole('button', { name: 'New Group' }).click(); const groupModal = page.locator('#group-modal.active'); await expect(groupModal).toBeVisible(); - await groupModal.getByLabel('Group Name').fill('Flexible Hours'); + await groupModal.locator('#group-name').fill('Flexible Hours'); await groupModal.getByRole('button', { name: 'New Schedule' }).click(); const firstSchedule = groupModal.locator('#schedules-list .schedule-item').first(); @@ -520,7 +530,7 @@ test('disabled groups start collapsed and stay readonly until re-enabled', async extensionPage, page, }) => { - await page.goto(extensionPage(PAGES.OPTIONS)); + await gotoOptions(extensionPage, page); await seedStorage( page, createStorageData({ @@ -596,6 +606,7 @@ test('disabled groups start collapsed and stay readonly until re-enabled', async .toBe(true); await page.reload(); + await waitForOptionsReady(page); const reloadedGroup = page.locator('details.group-item').filter({ hasText: 'Work Hours' }); await expect(reloadedGroup).toHaveCount(1); await expect(reloadedGroup.locator('input[data-action="toggle-group"]')).toBeChecked(); diff --git a/test/e2e/popup.spec.ts b/test/e2e/popup.spec.ts index 35a72d1..eddc913 100644 --- a/test/e2e/popup.spec.ts +++ b/test/e2e/popup.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from './fixtures'; +import type { Locator } from '@playwright/test'; import type { ClipboardCaptureGlobal } from './helpers'; import { PAGES } from '../../src/shared/constants'; import { @@ -7,6 +8,7 @@ import { defaultGroup, readStorage, seedStorage, + waitForPopupReady, } from './helpers'; const popupFilterData = createStorageData({ @@ -22,6 +24,21 @@ const popupFilterData = createStorageData({ ], }); +async function gotoPopup( + extensionPage: (relativePath: string) => string, + page: Parameters[0] +): Promise { + await page.goto(extensionPage(PAGES.POPUP)); + await waitForPopupReady(page); +} + +async function openQuickAdd(page: Parameters[0]): Promise { + await page.locator('#open-quick-add').click(); + const quickAdd = page.locator('#quick-add'); + await expect(quickAdd).toHaveClass(/is-open/); + return quickAdd; +} + async function expectTemporaryFilterExpiration( page: Parameters[0], pattern: string, @@ -40,9 +57,9 @@ async function expectTemporaryFilterExpiration( } test('adds and deletes a temporary filter from the popup', async ({ extensionPage, page }) => { - await page.goto(extensionPage(PAGES.POPUP)); + await gotoPopup(extensionPage, page); - await page.getByRole('button', { name: 'New temporary filter' }).click(); + await openQuickAdd(page); await page.getByLabel('Site or pattern').fill('quick.example.invalid'); await page.getByLabel('Block for').fill('45'); await page.getByRole('button', { name: 'Start block' }).click(); @@ -59,7 +76,7 @@ test('opens the full filter editor from the popup empty state', async ({ extensionPage, page, }) => { - await page.goto(extensionPage(PAGES.POPUP)); + await gotoPopup(extensionPage, page); await expect(page.getByText('No filters configured.')).toBeVisible(); @@ -92,7 +109,7 @@ test('shows the URL pattern when a filter name is blank', async ({ extensionPage }) ); - await page.goto(extensionPage(PAGES.POPUP)); + await gotoPopup(extensionPage, page); await expect( page.locator('.filter-item').filter({ hasText: 'github.com/notifications' }) @@ -122,7 +139,7 @@ test('supports copy, toggle, and edit actions for popup filters', async ({ await page.goto(extensionPage(PAGES.OPTIONS)); await seedStorage(page, popupFilterData); - await page.goto(extensionPage(PAGES.POPUP)); + await gotoPopup(extensionPage, page); const regularItem = page.locator('.filter-item').filter({ hasText: 'Focus Block' }); @@ -151,8 +168,8 @@ test('supports copy, toggle, and edit actions for popup filters', async ({ const filterModal = optionsPage.locator('#filter-modal.active'); await expect(filterModal).toBeVisible(); - await expect(filterModal.getByLabel('Name')).toHaveValue('Focus Block'); - await expect(filterModal.getByLabel('URL Pattern')).toHaveValue('blocked.example.invalid'); + await expect(filterModal.locator('#filter-description')).toHaveValue('Focus Block'); + await expect(filterModal.locator('#filter-pattern')).toHaveValue('blocked.example.invalid'); }); test('supports quick-add suggestions, validation, duration units, and the full editor link', async ({ @@ -180,11 +197,8 @@ test('supports quick-add suggestions, validation, duration units, and the full e }) as typeof chrome.tabs.query; }, currentTabUrl); - await page.goto(extensionPage(PAGES.POPUP)); - await page.getByRole('button', { name: 'New temporary filter' }).click(); - const quickAdd = page.locator('#quick-add'); - - await expect(quickAdd).toHaveClass(/is-open/); + await gotoPopup(extensionPage, page); + const quickAdd = await openQuickAdd(page); await expect(page.getByLabel('Site or pattern')).toHaveValue(currentTabUrl); await quickAdd.locator('button[data-duration="2"][data-unit="hours"]').click(); @@ -210,7 +224,7 @@ test('supports quick-add suggestions, validation, duration units, and the full e await hoursFilter.getByRole('button', { name: 'Delete Filter' }).click(); - await page.getByRole('button', { name: 'New temporary filter' }).click(); + await openQuickAdd(page); await page.getByLabel('Site or pattern').fill('multi-day.example.invalid'); await page.getByLabel('Block for').fill('3'); await page.getByRole('combobox').selectOption('days'); @@ -226,7 +240,7 @@ test('supports quick-add suggestions, validation, duration units, and the full e ); const optionsPagePromise = context.waitForEvent('page'); - await page.getByRole('button', { name: 'New temporary filter' }).click(); + await openQuickAdd(page); await page.locator('#quick-add button[data-action="open-full-editor"]').click(); const optionsPage = await optionsPagePromise; await optionsPage.waitForLoadState(); @@ -253,7 +267,7 @@ test('snoozes and resumes filtering from the popup', async ({ extensionPage, pag }) ); - await page.goto(extensionPage(PAGES.POPUP)); + await gotoPopup(extensionPage, page); await page.locator('#open-snooze').click(); await page.getByRole('button', { name: '15m' }).click(); diff --git a/test/unit/background/messages.test.ts b/test/unit/background/messages.test.ts index 5feb36e..e91bc97 100644 --- a/test/unit/background/messages.test.ts +++ b/test/unit/background/messages.test.ts @@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ continueFromActiveTab: vi.fn(), + continueFromBlockedPage: vi.fn(), + continueFromTab: vi.fn(), getUrlDecision: vi.fn(), getBlockedPageStateByBlockId: vi.fn(), getFreshBlockedPageState: vi.fn(), goBackFromActiveTab: vi.fn(), + goBackFromTab: vi.fn(), loadData: vi.fn(), })); @@ -15,13 +18,19 @@ vi.mock('../../../src/background/tabController', () => ({ getBlockedPageStateByBlockId: typeof mocks.getBlockedPageStateByBlockId; getFreshBlockedPageState: typeof mocks.getFreshBlockedPageState; goBackFromActiveTab: typeof mocks.goBackFromActiveTab; + goBackFromTab: typeof mocks.goBackFromTab; continueFromActiveTab: typeof mocks.continueFromActiveTab; + continueFromBlockedPage: typeof mocks.continueFromBlockedPage; + continueFromTab: typeof mocks.continueFromTab; } => ({ continueFromActiveTab: mocks.continueFromActiveTab, + continueFromBlockedPage: mocks.continueFromBlockedPage, + continueFromTab: mocks.continueFromTab, getUrlDecision: mocks.getUrlDecision, getBlockedPageStateByBlockId: mocks.getBlockedPageStateByBlockId, getFreshBlockedPageState: mocks.getFreshBlockedPageState, goBackFromActiveTab: mocks.goBackFromActiveTab, + goBackFromTab: mocks.goBackFromTab, }), })); @@ -44,12 +53,16 @@ const defaultData = { describe('handleMessage', () => { beforeEach(() => { + vi.clearAllMocks(); mocks.loadData.mockResolvedValue(defaultData); mocks.continueFromActiveTab.mockResolvedValue(false); + mocks.continueFromBlockedPage.mockResolvedValue(false); + mocks.continueFromTab.mockResolvedValue(false); mocks.getUrlDecision.mockResolvedValue({ action: 'allow', reason: 'no-match' }); mocks.getBlockedPageStateByBlockId.mockResolvedValue({ status: 'unavailable' }); mocks.getFreshBlockedPageState.mockResolvedValue({ status: 'unavailable' }); mocks.goBackFromActiveTab.mockResolvedValue(false); + mocks.goBackFromTab.mockResolvedValue(false); }); it('rejects messages from other extensions', () => { @@ -74,12 +87,69 @@ describe('handleMessage', () => { }); it('responds to continue requests', async () => { - mocks.continueFromActiveTab.mockResolvedValue(true); + mocks.continueFromTab.mockResolvedValue(true); const sendResponse = vi.fn(); expect( handleMessage( { type: MessageType.CONTINUE_ACTIVE_TAB }, + { + id: 'test-extension-id', + tab: { + id: 9, + url: 'chrome-extension://test-extension-id/src/blocked/index.html?blockId=block-9', + } as chrome.tabs.Tab, + }, + sendResponse + ) + ).toBe(true); + + await vi.waitFor(() => { + expect(sendResponse).toHaveBeenCalledWith({ continued: true }); + }); + expect(mocks.continueFromTab).toHaveBeenCalledWith( + 9, + 'chrome-extension://test-extension-id/src/blocked/index.html?blockId=block-9', + undefined + ); + expect(mocks.continueFromActiveTab).not.toHaveBeenCalled(); + }); + + it('uses the supplied block id for continue requests', async () => { + mocks.continueFromTab.mockResolvedValue(true); + const sendResponse = vi.fn(); + + expect( + handleMessage( + { type: MessageType.CONTINUE_ACTIVE_TAB, blockId: 'block-9' }, + { + id: 'test-extension-id', + tab: { + id: 9, + url: 'chrome-extension://test-extension-id/src/blocked/index.html?blockId=block-9', + } as chrome.tabs.Tab, + }, + sendResponse + ) + ).toBe(true); + + await vi.waitFor(() => { + expect(sendResponse).toHaveBeenCalledWith({ continued: true }); + }); + expect(mocks.continueFromTab).toHaveBeenCalledWith( + 9, + 'chrome-extension://test-extension-id/src/blocked/index.html?blockId=block-9', + 'block-9' + ); + }); + + it('continues by block id when the sender tab is unavailable', async () => { + mocks.continueFromBlockedPage.mockResolvedValue(true); + const sendResponse = vi.fn(); + + expect( + handleMessage( + { type: MessageType.CONTINUE_ACTIVE_TAB, blockId: 'block-9' }, { id: 'test-extension-id' }, sendResponse ) @@ -88,6 +158,8 @@ describe('handleMessage', () => { await vi.waitFor(() => { expect(sendResponse).toHaveBeenCalledWith({ continued: true }); }); + expect(mocks.continueFromBlockedPage).toHaveBeenCalledWith('block-9'); + expect(mocks.continueFromActiveTab).not.toHaveBeenCalled(); }); it('responds with blocked state for CHECK_URL messages', async () => { @@ -114,13 +186,16 @@ describe('handleMessage', () => { }); it('responds to go-back requests', async () => { - mocks.goBackFromActiveTab.mockResolvedValue(true); + mocks.goBackFromTab.mockResolvedValue(true); const sendResponse = vi.fn(); expect( handleMessage( { type: MessageType.GO_BACK_ACTIVE_TAB }, - { id: 'test-extension-id' }, + { + id: 'test-extension-id', + tab: { id: 10 } as chrome.tabs.Tab, + }, sendResponse ) ).toBe(true); @@ -128,6 +203,8 @@ describe('handleMessage', () => { await vi.waitFor(() => { expect(sendResponse).toHaveBeenCalledWith({ restored: true }); }); + expect(mocks.goBackFromTab).toHaveBeenCalledWith(10); + expect(mocks.goBackFromActiveTab).not.toHaveBeenCalled(); }); it('responds to blocked-page state requests', async () => { diff --git a/test/unit/shared/filters.test.ts b/test/unit/shared/filters.test.ts index a3128f3..d457d5c 100644 --- a/test/unit/shared/filters.test.ts +++ b/test/unit/shared/filters.test.ts @@ -4,7 +4,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { - buildBlockingIndex, getSnoozeRemainingMs, getRegexValidationError, isSnoozeActive, @@ -12,12 +11,38 @@ import { matchesPattern, isFilterActive, isFilterScheduledActive, - shouldBlockUrl, - shouldBlockUrlWithIndex, sortFiltersTemporaryFirst, } from '../../../src/shared/utils/filters'; +import { evaluateFilterDecision } from '../../../src/shared/filtering/engine'; import type { Filter, FilterGroup, Whitelist } from '../../../src/shared/types'; +function findBlockingFilter( + url: string, + filters: Filter[], + groups: FilterGroup[], + whitelist: Whitelist[], + context = { dayOfWeek: 1, time: '10:00' } +): Filter | undefined { + const decision = evaluateFilterDecision( + url, + { + groups, + filters, + whitelist, + snooze: { active: false }, + blockType: 'block', + rulesVersion: 0, + }, + { context } + ); + + if (decision.action !== 'block') { + return undefined; + } + + return filters.find((filter) => filter.id === decision.filterId); +} + describe('matchesPattern', () => { it.each([ { @@ -312,7 +337,7 @@ describe('snooze helpers', () => { }); }); -describe('blocking index', () => { +describe('runtime filtering path', () => { const groups: FilterGroup[] = [ { id: 'default', name: '24/7', schedules: [], is24x7: true }, { @@ -323,80 +348,80 @@ describe('blocking index', () => { }, ]; - it('filters out disabled and orphaned filters and whitelist entries', () => { - const blockingIndex = buildBlockingIndex( - [ - { - id: 'active', - pattern: 'blocked.com', - groupId: 'default', - enabled: true, - matchMode: 'contains', - }, - { - id: 'disabled', - pattern: 'ignored.com', - groupId: 'default', - enabled: false, - matchMode: 'contains', - }, - { - id: 'orphan', - pattern: 'orphan.com', - groupId: 'missing', - enabled: true, - matchMode: 'contains', - }, - ], - groups, - [ - { - id: 'allowed', - pattern: 'blocked.com/allowed', - groupId: 'default', - enabled: true, - matchMode: 'contains', - }, - { - id: 'disabled-whitelist', - pattern: 'disabled.com', - groupId: 'default', - enabled: false, - matchMode: 'contains', - }, - { - id: 'orphan-whitelist', - pattern: 'orphan.com', - groupId: 'missing', - enabled: true, - matchMode: 'contains', - }, - ] - ); - - expect(blockingIndex.filters.map((filter) => filter.id)).toEqual(['active']); - expect(blockingIndex.whitelistByGroup.get('default')?.map((entry) => entry.id)).toEqual([ - 'allowed', - ]); - }); + it('ignores disabled and orphaned filters and whitelist entries', () => { + const filters: Filter[] = [ + { + id: 'active', + pattern: 'blocked.com', + groupId: 'default', + enabled: true, + matchMode: 'contains', + }, + { + id: 'disabled', + pattern: 'ignored.com', + groupId: 'default', + enabled: false, + matchMode: 'contains', + }, + { + id: 'orphan', + pattern: 'orphan.com', + groupId: 'missing', + enabled: true, + matchMode: 'contains', + }, + ]; + const whitelist: Whitelist[] = [ + { + id: 'allowed', + pattern: 'blocked.com/allowed', + groupId: 'default', + enabled: true, + matchMode: 'contains', + }, + { + id: 'disabled-whitelist', + pattern: 'disabled.com', + groupId: 'default', + enabled: false, + matchMode: 'contains', + }, + { + id: 'orphan-whitelist', + pattern: 'orphan.com', + groupId: 'missing', + enabled: true, + matchMode: 'contains', + }, + ]; - it('treats invalid prepared regex patterns as non-matching', () => { - const blockingIndex = buildBlockingIndex( - [ - { - id: 'regex-filter', - pattern: '[', - groupId: 'default', - enabled: true, - matchMode: 'regex', - }, - ], - groups, - [] + expect(findBlockingFilter('https://blocked.com', filters, groups, whitelist)?.id).toBe( + 'active' ); + expect( + findBlockingFilter('https://blocked.com/allowed', filters, groups, whitelist) + ).toBeUndefined(); + expect(findBlockingFilter('https://ignored.com', filters, groups, whitelist)).toBeUndefined(); + expect(findBlockingFilter('https://orphan.com', filters, groups, whitelist)).toBeUndefined(); + }); + it('treats invalid regex patterns as non-matching', () => { expect( - shouldBlockUrlWithIndex('https://blocked.com', blockingIndex, { dayOfWeek: 1, time: '10:00' }) + findBlockingFilter( + 'https://blocked.com', + [ + { + id: 'regex-filter', + pattern: '[', + groupId: 'default', + enabled: true, + matchMode: 'regex', + }, + ], + groups, + [] + ) ).toBeUndefined(); }); }); @@ -421,7 +446,7 @@ describe('shouldBlockUrl', () => { matchMode: 'contains', }, ]; - expect(shouldBlockUrl('https://allowed.com', filters, groups, [])).toBeUndefined(); + expect(findBlockingFilter('https://allowed.com', filters, groups, [])).toBeUndefined(); }); it('should return the matching filter when URL matches', () => { @@ -434,7 +459,7 @@ describe('shouldBlockUrl', () => { matchMode: 'contains', }, ]; - const result = shouldBlockUrl('https://blocked.com/page', filters, groups, []); + const result = findBlockingFilter('https://blocked.com/page', filters, groups, []); expect(result).toBeDefined(); expect(result?.id).toBe('f1'); }); @@ -449,8 +474,8 @@ describe('shouldBlockUrl', () => { matchMode: 'exact', }, ]; - expect(shouldBlockUrl('https://blocked.com', filters, groups, [])).toBeDefined(); - expect(shouldBlockUrl('https://blocked.com/page', filters, groups, [])).toBeUndefined(); + expect(findBlockingFilter('https://blocked.com', filters, groups, [])).toBeDefined(); + expect(findBlockingFilter('https://blocked.com/page', filters, groups, [])).toBeUndefined(); }); it('should match regex filters when configured', () => { @@ -463,8 +488,8 @@ describe('shouldBlockUrl', () => { matchMode: 'regex', }, ]; - expect(shouldBlockUrl('https://blocked.com/foo', filters, groups, [])).toBeDefined(); - expect(shouldBlockUrl('https://blocked.com/baz', filters, groups, [])).toBeUndefined(); + expect(findBlockingFilter('https://blocked.com/foo', filters, groups, [])).toBeDefined(); + expect(findBlockingFilter('https://blocked.com/baz', filters, groups, [])).toBeUndefined(); }); it('should return undefined when URL matches whitelist', () => { @@ -487,7 +512,7 @@ describe('shouldBlockUrl', () => { }, ]; expect( - shouldBlockUrl('https://blocked.com/allowed', filters, groups, whitelist) + findBlockingFilter('https://blocked.com/allowed', filters, groups, whitelist) ).toBeUndefined(); }); @@ -511,7 +536,7 @@ describe('shouldBlockUrl', () => { }, ]; expect( - shouldBlockUrl('https://blocked.com/allowed/page', filters, groups, whitelist) + findBlockingFilter('https://blocked.com/allowed/page', filters, groups, whitelist) ).toBeUndefined(); }); @@ -534,7 +559,7 @@ describe('shouldBlockUrl', () => { matchMode: 'contains', }, ]; - const result = shouldBlockUrl('https://blocked.com/allowed', filters, groups, whitelist); + const result = findBlockingFilter('https://blocked.com/allowed', filters, groups, whitelist); expect(result).toBeDefined(); }); @@ -551,7 +576,7 @@ describe('shouldBlockUrl', () => { matchMode: 'contains', }, ]; - const result = shouldBlockUrl( + const result = findBlockingFilter( 'https://blocked.com/allowed', filters, [ @@ -588,7 +613,7 @@ describe('shouldBlockUrl', () => { }, ]; - const result = shouldBlockUrl('https://blocked.com', filters, groups, whitelist); + const result = findBlockingFilter('https://blocked.com', filters, groups, whitelist); expect(result).toBeDefined(); vi.useRealTimers(); @@ -610,7 +635,7 @@ describe('shouldBlockUrl', () => { }, ]; - const result = shouldBlockUrl('https://blocked.com', filters, groups, []); + const result = findBlockingFilter('https://blocked.com', filters, groups, []); expect(result).toBeUndefined(); vi.useRealTimers(); @@ -635,12 +660,17 @@ describe('shouldBlockUrl', () => { }, ]; - const blockingIndex = buildBlockingIndex(filters, scheduledGroups, []); expect( - shouldBlockUrlWithIndex('https://blocked.com', blockingIndex, { dayOfWeek: 3, time: '10:30' }) + findBlockingFilter('https://blocked.com', filters, scheduledGroups, [], { + dayOfWeek: 3, + time: '10:30', + }) ).toBeDefined(); expect( - shouldBlockUrlWithIndex('https://blocked.com', blockingIndex, { dayOfWeek: 3, time: '10:31' }) + findBlockingFilter('https://blocked.com', filters, scheduledGroups, [], { + dayOfWeek: 3, + time: '10:31', + }) ).toBeUndefined(); }); @@ -658,6 +688,6 @@ describe('shouldBlockUrl', () => { { id: 'default', name: '24/7', schedules: [], is24x7: true, enabled: false }, ]; - expect(shouldBlockUrl('https://blocked.com', filters, disabledGroups, [])).toBeUndefined(); + expect(findBlockingFilter('https://blocked.com', filters, disabledGroups, [])).toBeUndefined(); }); }); diff --git a/test/unit/shared/messages.test.ts b/test/unit/shared/messages.test.ts index 4a6cc11..71136b3 100644 --- a/test/unit/shared/messages.test.ts +++ b/test/unit/shared/messages.test.ts @@ -5,11 +5,9 @@ import { isCheckUrlMessage, isCloseInfoPanelMessage, isContinueActiveTabMessage, - isDataUpdatedMessage, isGetBlockedPageStateMessage, isGetDataMessage, isGoBackActiveTabMessage, - isUrlBlockedMessage, } from '../../../src/shared/types'; describe('shared/types/messages', () => { @@ -22,6 +20,12 @@ describe('shared/types/messages', () => { expect(isCheckUrlMessage({ type: MessageType.CHECK_URL, url: 42 })).toBe(false); expect(isGoBackActiveTabMessage({ type: MessageType.GO_BACK_ACTIVE_TAB })).toBe(true); expect(isContinueActiveTabMessage({ type: MessageType.CONTINUE_ACTIVE_TAB })).toBe(true); + expect( + isContinueActiveTabMessage({ type: MessageType.CONTINUE_ACTIVE_TAB, blockId: 'block-1' }) + ).toBe(true); + expect(isContinueActiveTabMessage({ type: MessageType.CONTINUE_ACTIVE_TAB, blockId: 42 })).toBe( + false + ); expect(isGetBlockedPageStateMessage({ type: MessageType.GET_BLOCKED_PAGE_STATE })).toBe(true); expect( isGetBlockedPageStateMessage({ @@ -34,20 +38,13 @@ describe('shared/types/messages', () => { ).toBe(false); }); - it('recognizes broadcast messages', () => { - expect(isDataUpdatedMessage({ type: MessageType.DATA_UPDATED, data: {} })).toBe(true); - expect( - isUrlBlockedMessage({ type: MessageType.URL_BLOCKED, url: 'https://blocked.com', filter: {} }) - ).toBe(true); + it('recognizes panel control messages', () => { expect(isCloseInfoPanelMessage({ type: MessageType.CLOSE_INFO_PANEL })).toBe(true); }); it('rejects malformed values', () => { expect(isGetDataMessage(null)).toBe(false); expect(isCheckUrlMessage('CHECK_URL')).toBe(false); - expect(isDataUpdatedMessage({ type: MessageType.DATA_UPDATED })).toBe(false); - expect(isUrlBlockedMessage({ type: MessageType.URL_BLOCKED, url: 'https://blocked.com' })).toBe( - false - ); + expect(isCloseInfoPanelMessage({ type: MessageType.GET_DATA })).toBe(false); }); });