From a1b0944249dbaee3ed953497d471dbc3ac10257e Mon Sep 17 00:00:00 2001 From: Kirill Lebedenko Date: Mon, 2 Nov 2026 18:14:37 +0000 Subject: [PATCH 1/4] fix: version header config and apply DNR rules in order Store a monotonic headers config meta and run background applies through a single-flight queue so an older storage snapshot cannot overwrite a newer one. Retry transient declarativeNetRequest failures and fall back to session rules when dynamic updates fail. Co-authored-by: Kirill Lebedenko --- src/background.ts | 392 ++++++++++++++++-- src/entities/is-paused/model.ts | 3 + src/entities/is-paused/utils/save.ts | 7 +- .../request-profile/model/request-profiles.ts | 16 +- .../model/selected-request-profile.ts | 22 +- src/entities/request-profile/utils/save.ts | 19 +- src/shared/constants.ts | 6 +- src/shared/utils/headersConfigMeta.ts | 53 +++ src/shared/utils/setBrowserHeaders.ts | 280 ++++++++++++- src/shared/utils/updateOverrideHeaders.ts | 7 - tests/e2e/header-toggle-dnr.spec.ts | 278 +++++++++++++ 11 files changed, 998 insertions(+), 85 deletions(-) create mode 100644 src/shared/utils/headersConfigMeta.ts delete mode 100644 src/shared/utils/updateOverrideHeaders.ts create mode 100644 tests/e2e/header-toggle-dnr.spec.ts diff --git a/src/background.ts b/src/background.ts index a999ab8b..67488cdb 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,6 +1,8 @@ import browser from 'webextension-polyfill'; -import { BrowserStorageKey, ServiceWorkerEvent } from './shared/constants'; +import type { Profile, RequestHeader } from '#entities/request-profile/types'; + +import { BrowserStorageKey } from './shared/constants'; import { browserAction } from './shared/utils/browserAPI'; import { logger, LogLevel } from './shared/utils/logger'; import { setBrowserCookies } from './shared/utils/setBrowserCookies'; @@ -41,12 +43,36 @@ async function getCurrentTabUrl(): Promise { logger.info(' - Profiles:', result[BrowserStorageKey.Profiles] ? 'Present' : 'Missing'); logger.info(' - Selected Profile:', result[BrowserStorageKey.SelectedProfile] || 'None'); logger.info(' - Is Paused:', result[BrowserStorageKey.IsPaused] || false); - logger.groupEnd(); + + // Log profile count if present + let activeHeadersCount = 0; + if (result[BrowserStorageKey.Profiles]) { + try { + const profiles = JSON.parse(result[BrowserStorageKey.Profiles] as string); + logger.info(` - Profiles count: ${profiles.length}`); + if (profiles.length > 0) { + logger.info(' - Profile names:', profiles.map((p: Profile) => p.name || p.id).join(', ')); + + // Count active headers for the badge + const selectedProfile = profiles.find((p: Profile) => p.id === result[BrowserStorageKey.SelectedProfile]); + if (selectedProfile) { + activeHeadersCount = selectedProfile.requestHeaders?.filter((h: RequestHeader) => !h.disabled).length || 0; + logger.info(` - Active headers count: ${activeHeadersCount}`); + } + } + } catch (error) { + logger.warn(' - Failed to parse profiles:', error); + } + } logger.debug('Background script load storage data:', JSON.stringify(result, null, 2)); + logger.groupEnd(); const currentTabUrl = await getCurrentTabUrl(); - await setBrowserHeaders(result, currentTabUrl); + await Promise.all([ + setBrowserHeaders(result, { reason: 'background-script-load', currentTabUrl }), + setBrowserCookies(result), + ]); logger.info(`🏷️ Initial badge set for URL: ${currentTabUrl}`); } catch (error) { logger.error('Failed to check storage on background script load:', error); @@ -61,22 +87,188 @@ if (process.env.NODE_ENV === 'development') { const BADGE_COLOR = '#ffffff'; -async function notify(message: ServiceWorkerEvent) { - logger.debug('Received message:', message); +function storageFingerprint(result: Record): string { + const profiles = result[BrowserStorageKey.Profiles]; + const selected = result[BrowserStorageKey.SelectedProfile]; + const paused = result[BrowserStorageKey.IsPaused]; + + // Keep it cheap and stable: correlate across logs without huge payloads. + // If profiles is a big JSON string, we don't want to log it fully. + let profilesStr = ''; + if (typeof profiles === 'string') { + profilesStr = profiles; + } else if (profiles !== undefined) { + profilesStr = JSON.stringify(profiles); + } + const selectedStr = typeof selected === 'string' ? selected : String(selected ?? ''); + const pausedStr = paused === undefined ? '' : String(Boolean(paused)); - if (message === ServiceWorkerEvent.Reload) { - logger.info('🔄 Reloading headers configuration'); + // Simple FNV-1a 32-bit hash for correlation (no deps). + const input = `${selectedStr}|${pausedStr}|${profilesStr}`; + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); - const result = await browser.storage.local.get([ - BrowserStorageKey.Profiles, - BrowserStorageKey.SelectedProfile, - BrowserStorageKey.IsPaused, - ]); + hash = (hash * 0x01000193) >>> 0; + } + return `fnv1a32:${hash.toString(16)}:len:${input.length}`; +} + +let applyInProgress = false; +let applyPending = false; +let applyCounter = 0; +let lastRequestedReason = 'unknown'; +let lastAppliedStorageFingerprint: string | null = null; +let lastAppliedMeta: { seq: number; updatedAt: number } = { seq: 0, updatedAt: 0 }; + +function normalizeHeadersConfigMeta(value: unknown): { seq: number; updatedAt: number } { + if (value && typeof value === 'object') { + const obj = value as Record; + const seq = typeof obj.seq === 'number' && Number.isFinite(obj.seq) ? obj.seq : 0; + const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : 0; + return { seq, updatedAt }; + } + if (typeof value === 'string') { + try { + return normalizeHeadersConfigMeta(JSON.parse(value) as unknown); + } catch { + return { seq: 0, updatedAt: 0 }; + } + } + return { seq: 0, updatedAt: 0 }; +} + +function isNewerMeta(next: { seq: number; updatedAt: number }, prev: { seq: number; updatedAt: number }) { + if (next.seq !== prev.seq) return next.seq > prev.seq; + return next.updatedAt > prev.updatedAt; +} + +async function applyHeadersFromStorageQueue(reason: string) { + lastRequestedReason = reason; + applyPending = true; + + logger.debug('📥 applyHeadersFromStorageQueue called:', { + reason, + applyInProgress, + applyPending, + lastAppliedStorageFingerprint, + lastAppliedMeta, + }); + + if (applyInProgress) { + logger.debug('⏳ Apply already in progress, queued for later'); + return; + } + applyInProgress = true; + logger.debug('🔒 Apply lock acquired'); + + try { + while (applyPending) { + applyPending = false; + const applyId = ++applyCounter; + + const startedAt = Date.now(); + const result = await browser.storage.local.get([ + BrowserStorageKey.Profiles, + BrowserStorageKey.SelectedProfile, + BrowserStorageKey.IsPaused, + BrowserStorageKey.HeadersConfigMeta, + ]); + const fp = storageFingerprint(result); + const meta = normalizeHeadersConfigMeta(result[BrowserStorageKey.HeadersConfigMeta]); + + logger.group('🧵 Headers apply (queued)', true); + logger.info('Apply request:', { + applyId, + reason: lastRequestedReason, + startedAt, + elapsedMsBeforeApply: Date.now() - startedAt, + storageFingerprint: fp, + headersConfigMeta: meta, + lastAppliedMeta, + }); - logger.info('📦 Storage data for reload:', result); - await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]); + try { + const isNewer = isNewerMeta(meta, lastAppliedMeta); + const isSameFingerprint = lastAppliedStorageFingerprint === fp; + + logger.debug('🔍 Apply decision check:', { + applyId, + isNewerMeta: isNewer, + isSameFingerprint, + meta, + lastAppliedMeta, + fp, + lastAppliedStorageFingerprint, + }); + + if (!isNewer) { + logger.warn('⏭️ Apply skipped (stale meta):', { + applyId, + reason: `meta.seq=${meta.seq} <= lastApplied.seq=${lastAppliedMeta.seq}, meta.updatedAt=${meta.updatedAt} <= lastApplied.updatedAt=${lastAppliedMeta.updatedAt}`, + headersConfigMeta: meta, + lastAppliedMeta, + }); + continue; + } + if (isSameFingerprint) { + // Meta changed but effective config didn't. Still advance meta to avoid replaying. + const prevMeta = { ...lastAppliedMeta }; + lastAppliedMeta = meta; + logger.info('⏭️ Apply skipped (no effective changes):', { + applyId, + storageFingerprint: fp, + headersConfigMeta: meta, + prevMeta, + note: 'Meta advanced to prevent replay', + }); + } else { + const prevFp = lastAppliedStorageFingerprint; + const prevMeta = { ...lastAppliedMeta }; + const currentTabUrl = await getCurrentTabUrl(); + + await Promise.all([ + setBrowserHeaders(result, { + applyId, + reason: lastRequestedReason, + storageFingerprint: fp, + currentTabUrl, + }), + setBrowserCookies(result), + ]); + + lastAppliedStorageFingerprint = fp; + lastAppliedMeta = meta; + + logger.info('✅ Apply done:', { + applyId, + elapsedMsTotal: Date.now() - startedAt, + fingerprintChange: `${prevFp} → ${fp}`, + metaChange: `seq:${prevMeta.seq}→${meta.seq}, updatedAt:${prevMeta.updatedAt}→${meta.updatedAt}`, + }); + } + } catch (error) { + logger.error('❌ Apply failed (state NOT updated, will retry on next change):', { + applyId, + error, + stateRemains: { + lastAppliedStorageFingerprint, + lastAppliedMeta, + }, + attemptedFingerprint: fp, + attemptedMeta: meta, + }); + } finally { + logger.groupEnd(); + } + } + } finally { + applyInProgress = false; + logger.debug('🔓 Apply lock released:', { + lastAppliedStorageFingerprint, + lastAppliedMeta, + }); } - return undefined; } browser.runtime.onStartup.addListener(async function () { @@ -86,6 +278,7 @@ browser.runtime.onStartup.addListener(async function () { BrowserStorageKey.Profiles, BrowserStorageKey.SelectedProfile, BrowserStorageKey.IsPaused, + BrowserStorageKey.HeadersConfigMeta, ]); // Detailed logging of storage contents on startup @@ -95,14 +288,64 @@ browser.runtime.onStartup.addListener(async function () { logger.info(' - Is Paused:', result[BrowserStorageKey.IsPaused] || false); logger.debug('Startup storage data:', JSON.stringify(result, null, 2)); + // Log profile count if present + if (result[BrowserStorageKey.Profiles]) { + try { + const profiles = JSON.parse(result[BrowserStorageKey.Profiles] as string); + logger.info(` - Profiles count: ${profiles.length}`); + if (profiles.length > 0) { + logger.info(' - Profile names:', profiles.map((p: Profile) => p.name || p.id).join(', ')); + } + } catch (error) { + logger.warn(' - Failed to parse profiles:', error); + } + } + logger.debug('Startup storage data:', result); if (Object.keys(result).length) { logger.info('🚀 Storage data found, setting browser headers on startup'); try { - await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]); + const fp = storageFingerprint(result); + const applyId = ++applyCounter; + const currentTabUrl = await getCurrentTabUrl(); + + logger.debug('🔧 Direct apply (onStartup) starting:', { + applyId, + fp, + prevFp: lastAppliedStorageFingerprint, + prevMeta: lastAppliedMeta, + }); + + await Promise.all([ + setBrowserHeaders(result, { + applyId, + reason: 'runtime.onStartup', + storageFingerprint: fp, + currentTabUrl, + }), + setBrowserCookies(result), + ]); + + // Sync queue state after direct call to prevent duplicate applies + const prevFp = lastAppliedStorageFingerprint; + const prevMeta = { ...lastAppliedMeta }; + lastAppliedStorageFingerprint = fp; + const meta = normalizeHeadersConfigMeta(result[BrowserStorageKey.HeadersConfigMeta]); + if (isNewerMeta(meta, lastAppliedMeta)) { + lastAppliedMeta = meta; + } + + logger.info('🔄 Queue state synced after onStartup:', { + applyId, + fingerprintChange: `${prevFp} → ${lastAppliedStorageFingerprint}`, + metaChange: `seq:${prevMeta.seq}→${lastAppliedMeta.seq}, updatedAt:${prevMeta.updatedAt}→${lastAppliedMeta.updatedAt}`, + }); } catch (error) { - logger.error('Failed to set browser headers on startup:', error); + logger.error('❌ Failed to set browser headers on startup (queue state NOT synced):', { + error, + queueStateRemains: { lastAppliedStorageFingerprint, lastAppliedMeta }, + }); } } else { logger.info('📭 No storage data found on startup - extension will start with default settings'); @@ -110,28 +353,41 @@ browser.runtime.onStartup.addListener(async function () { }); browser.storage.onChanged.addListener(async (changes, areaName) => { - logger.debug('Storage changes detected in area:', areaName, changes); + logger.debug('Storage changes detected in area:', areaName); if (areaName === 'local') { - const relevantChanges = [ + const relevantKeys = [ BrowserStorageKey.Profiles, BrowserStorageKey.SelectedProfile, BrowserStorageKey.IsPaused, - ].some(key => Object.keys(changes).includes(key)); - - if (relevantChanges) { - logger.info('📝 Relevant storage changes detected, updating headers'); - const result = await browser.storage.local.get([ - BrowserStorageKey.Profiles, - BrowserStorageKey.SelectedProfile, - BrowserStorageKey.IsPaused, - ]); - logger.debug('Storage changes data:', result); - try { - await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]); - } catch (error) { - logger.error('Failed to set browser headers on storage change:', error); + BrowserStorageKey.HeadersConfigMeta, + ]; + const changedKeys = Object.keys(changes); + const relevantChangedKeys = relevantKeys.filter(key => changedKeys.includes(key)); + + if (relevantChangedKeys.length > 0) { + // Log details about what changed + const changeDetails: Record = {}; + for (const key of relevantChangedKeys) { + const change = changes[key]; + changeDetails[key] = { + hadOldValue: change?.oldValue !== undefined, + hasNewValue: change?.newValue !== undefined, + }; } + + logger.info('📝 Relevant storage changes detected:', { + changedKeys: relevantChangedKeys, + changeDetails, + currentQueueState: { + lastAppliedStorageFingerprint, + lastAppliedMeta, + applyInProgress, + applyPending, + }, + }); + + await applyHeadersFromStorageQueue('storage.onChanged'); } } }); @@ -143,6 +399,7 @@ browser.runtime.onInstalled.addListener(async details => { BrowserStorageKey.Profiles, BrowserStorageKey.SelectedProfile, BrowserStorageKey.IsPaused, + BrowserStorageKey.HeadersConfigMeta, ]); // Detailed logging of storage contents on install/update @@ -153,20 +410,72 @@ browser.runtime.onInstalled.addListener(async details => { logger.debug('Install/update storage data:', JSON.stringify(result, null, 2)); logger.groupEnd(); + // Log profile count if present + if (result[BrowserStorageKey.Profiles]) { + try { + const profiles = JSON.parse(result[BrowserStorageKey.Profiles] as string); + logger.info(` - Profiles count: ${profiles.length}`); + if (profiles.length > 0) { + logger.info(' - Profile names:', profiles.map((p: Profile) => p.name || p.id).join(', ')); + } + } catch (error) { + logger.warn(' - Failed to parse profiles:', error); + } + } + logger.debug('Install/update storage data:', result); if (Object.keys(result).length) { logger.info('🔧 Storage data found, initializing browser headers on install/update'); try { - await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]); + const fp = storageFingerprint(result); + const applyId = ++applyCounter; + const currentTabUrl = await getCurrentTabUrl(); + + logger.debug('🔧 Direct apply (onInstalled) starting:', { + applyId, + reason: details.reason, + fp, + prevFp: lastAppliedStorageFingerprint, + prevMeta: lastAppliedMeta, + }); + + await Promise.all([ + setBrowserHeaders(result, { + applyId, + reason: `runtime.onInstalled:${details.reason}`, + storageFingerprint: fp, + currentTabUrl, + }), + setBrowserCookies(result), + ]); + + // Sync queue state after direct call to prevent duplicate applies + const prevFp = lastAppliedStorageFingerprint; + const prevMeta = { ...lastAppliedMeta }; + lastAppliedStorageFingerprint = fp; + const meta = normalizeHeadersConfigMeta(result[BrowserStorageKey.HeadersConfigMeta]); + if (isNewerMeta(meta, lastAppliedMeta)) { + lastAppliedMeta = meta; + } + + logger.info('🔄 Queue state synced after onInstalled:', { + applyId, + fingerprintChange: `${prevFp} → ${lastAppliedStorageFingerprint}`, + metaChange: `seq:${prevMeta.seq}→${lastAppliedMeta.seq}, updatedAt:${prevMeta.updatedAt}→${lastAppliedMeta.updatedAt}`, + }); } catch (error) { - logger.error('Failed to set browser headers on install/update:', error); + logger.error('❌ Failed to set browser headers on install/update (queue state NOT synced):', { + error, + queueStateRemains: { lastAppliedStorageFingerprint, lastAppliedMeta }, + }); } } else { logger.info('📭 No storage data found on install/update - extension will start with default settings'); } }); +// Badge depends on the active tab URL when URL filters are configured. browser.tabs.onActivated.addListener(async activeInfo => { logger.debug('Tab activated:', activeInfo); @@ -179,10 +488,10 @@ browser.tabs.onActivated.addListener(async activeInfo => { logger.debug('Tab activation storage data:', result); if (Object.keys(result).length) { - logger.info('📱 Tab activated, updating headers'); + logger.info('📱 Tab activated, updating badge'); try { const tab = await browser.tabs.get(activeInfo.tabId); - await setBrowserHeaders(result, tab.url); + await setBrowserHeaders(result, { reason: 'tabs.onActivated', currentTabUrl: tab.url }); } catch (error) { logger.error('Failed to set browser headers on tab activation:', error); } @@ -207,7 +516,7 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if (Object.keys(result).length) { try { - await setBrowserHeaders(result, tab.url); + await setBrowserHeaders(result, { reason: 'tabs.onUpdated', currentTabUrl: tab.url }); } catch (error) { logger.error('Failed to set browser headers on tab URL update:', error); } @@ -215,10 +524,3 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { }); browserAction.setBadgeBackgroundColor({ color: BADGE_COLOR }); - -browser.runtime.onMessage.addListener((message: unknown) => { - notify(message as ServiceWorkerEvent).catch(err => { - logger.error('Error handling message:', err); - }); - return undefined; -}); diff --git a/src/entities/is-paused/model.ts b/src/entities/is-paused/model.ts index 6182234b..a776ef4a 100644 --- a/src/entities/is-paused/model.ts +++ b/src/entities/is-paused/model.ts @@ -19,7 +19,10 @@ export const $isPaused = createStore(false) .on(toggleIsPaused, state => !state) .on(loadIsPausedFromStorageFx.doneData, (_, isPaused) => Boolean(isPaused)); +// Save only on explicit user toggle, not on initial load from storage. +// Same race-condition fix as in selected-request-profile.ts. sample({ + clock: toggleIsPaused, source: $isPaused, target: saveIsPausedToStorageFx, }); diff --git a/src/entities/is-paused/utils/save.ts b/src/entities/is-paused/utils/save.ts index 7bd5733a..0e0fe88b 100644 --- a/src/entities/is-paused/utils/save.ts +++ b/src/entities/is-paused/utils/save.ts @@ -1,11 +1,8 @@ -import browser from 'webextension-polyfill'; - import { BrowserStorageKey } from '#shared/constants'; -import { updateOverrideHeaders } from '#shared/utils/updateOverrideHeaders'; +import { setWithBumpedHeadersConfigMeta } from '#shared/utils/headersConfigMeta'; export async function saveIsPausedToBrowserApi(isPaused: boolean) { - await browser.storage.local.set({ + await setWithBumpedHeadersConfigMeta({ [BrowserStorageKey.IsPaused]: isPaused, }); - await updateOverrideHeaders(); } diff --git a/src/entities/request-profile/model/request-profiles.ts b/src/entities/request-profile/model/request-profiles.ts index 3180b56d..3731a30e 100644 --- a/src/entities/request-profile/model/request-profiles.ts +++ b/src/entities/request-profile/model/request-profiles.ts @@ -54,8 +54,6 @@ sample({ target: selectedRequestProfileIdChanged, }); -sample({ source: $requestProfiles, target: profilesSavedToBrowserFx }); - const profileAddedFx = attach({ source: $requestProfiles, effect: profiles => { @@ -133,6 +131,20 @@ const profileMultiRemovedFx = attach({ sample({ clock: profileMultiRemoved, target: profileMultiRemovedFx }); sample({ clock: profileMultiRemovedFx.doneData, target: $requestProfiles }); +// Save profiles only on explicit user actions, not on initial load from storage. +// This prevents a save→onChanged→apply cycle when loading from storage. +sample({ + clock: [ + profileAddedFx.doneData, + profileMultiAddedFx.doneData, + profileUpdatedFx.doneData, + profileRemovedFx.doneData, + profileMultiRemovedFx.doneData, + ], + source: $requestProfiles, + target: profilesSavedToBrowserFx, +}); + // loading from browser cache sample({ clock: initApp, target: profilesLoadedFromStorageFx }); sample({ clock: profilesLoadedFromStorageFx.doneData, target: $requestProfiles }); diff --git a/src/entities/request-profile/model/selected-request-profile.ts b/src/entities/request-profile/model/selected-request-profile.ts index 8970b1e8..ac345ab5 100644 --- a/src/entities/request-profile/model/selected-request-profile.ts +++ b/src/entities/request-profile/model/selected-request-profile.ts @@ -1,12 +1,19 @@ import { createEffect, createEvent, createStore, sample } from 'effector'; import { Profile } from '../types'; -import { loadSelectedProfileFromStorageApi, saveSelectedProfileToBrowserApi } from '../utils'; +import { + ensureSelectedProfileInStorage, + loadSelectedProfileFromStorageApi, + saveSelectedProfileToBrowserApi, +} from '../utils'; export const selectedRequestProfileIdChanged = createEvent(); export const loadSelectedProfileFromStorage = createEvent(); const saveSelectedProfileToBrowserFx = createEffect(saveSelectedProfileToBrowserApi); +// Writes SelectedProfile to storage WITHOUT bumping meta — safe to call on load. +const ensureSelectedProfileInStorageFx = createEffect(ensureSelectedProfileInStorage); + export const $selectedRequestProfile = createStore('').on( selectedRequestProfileIdChanged, (_, profileId) => profileId, @@ -14,6 +21,17 @@ export const $selectedRequestProfile = createStore('').on( const loadSelectedProfileFromStorageFx = createEffect(loadSelectedProfileFromStorageApi); -sample({ source: $selectedRequestProfile, target: saveSelectedProfileToBrowserFx }); +// Save WITH meta bump only on explicit user actions (profile switch, add, delete). +// This prevents the meta-bump race condition with concurrent user-initiated saves. +sample({ + clock: selectedRequestProfileIdChanged, + source: $selectedRequestProfile, + target: saveSelectedProfileToBrowserFx, +}); + sample({ clock: loadSelectedProfileFromStorage, target: loadSelectedProfileFromStorageFx }); sample({ clock: loadSelectedProfileFromStorageFx.doneData, target: $selectedRequestProfile }); + +// Ensure SelectedProfile key exists in storage so the background script can always resolve +// the active profile. This does NOT bump HeadersConfigMeta, so it cannot race with user saves. +sample({ clock: loadSelectedProfileFromStorageFx.doneData, target: ensureSelectedProfileInStorageFx }); diff --git a/src/entities/request-profile/utils/save.ts b/src/entities/request-profile/utils/save.ts index 389c6684..7b969616 100644 --- a/src/entities/request-profile/utils/save.ts +++ b/src/entities/request-profile/utils/save.ts @@ -1,20 +1,29 @@ import browser from 'webextension-polyfill'; import { BrowserStorageKey } from '#shared/constants'; -import { updateOverrideHeaders } from '#shared/utils/updateOverrideHeaders'; +import { setWithBumpedHeadersConfigMeta } from '#shared/utils/headersConfigMeta'; import { Profile } from '../types'; export async function saveProfilesToBrowserApi(profiles: Profile[]) { - await browser.storage.local.set({ + await setWithBumpedHeadersConfigMeta({ [BrowserStorageKey.Profiles]: JSON.stringify(profiles), }); - await updateOverrideHeaders(); } export async function saveSelectedProfileToBrowserApi(requestHeader: string) { - await browser.storage.local.set({ + await setWithBumpedHeadersConfigMeta({ [BrowserStorageKey.SelectedProfile]: requestHeader, }); - await updateOverrideHeaders(); +} + +/** + * Write SelectedProfile to storage WITHOUT bumping HeadersConfigMeta. + * Used on initial load to ensure the background script can always find the selected profile, + * without creating a meta bump that would race with user-initiated saves. + */ +export async function ensureSelectedProfileInStorage(profileId: string) { + await browser.storage.local.set({ + [BrowserStorageKey.SelectedProfile]: profileId, + }); } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0decf3a0..aa0ed32d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -2,13 +2,11 @@ export enum BrowserStorageKey { Profiles = 'requestHeaderProfilesV1', SelectedProfile = 'selectedHeaderProfileV1', IsPaused = 'isPausedV1', + /** Monotonic version meta for any header-config change */ + HeadersConfigMeta = 'headersConfigMetaV1', ThemeMode = 'themeMode', } -export enum ServiceWorkerEvent { - Reload = 'reload', -} - export enum Extensions { ModHeader = 'modheader', Requestly = 'requestly', diff --git a/src/shared/utils/headersConfigMeta.ts b/src/shared/utils/headersConfigMeta.ts new file mode 100644 index 00000000..e577b014 --- /dev/null +++ b/src/shared/utils/headersConfigMeta.ts @@ -0,0 +1,53 @@ +import browser from 'webextension-polyfill'; + +import { BrowserStorageKey } from '#shared/constants'; + +export type HeadersConfigMeta = { + /** Monotonic counter (best-effort) */ + seq: number; + /** Monotonic timestamp in ms (enforced even if clock moves backwards) */ + updatedAt: number; +}; + +function normalizeMeta(value: unknown): HeadersConfigMeta { + if (value && typeof value === 'object') { + const obj = value as Record; + const seq = typeof obj.seq === 'number' && Number.isFinite(obj.seq) ? obj.seq : 0; + const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : 0; + return { seq, updatedAt }; + } + + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown; + return normalizeMeta(parsed); + } catch { + return { seq: 0, updatedAt: 0 }; + } + } + + return { seq: 0, updatedAt: 0 }; +} + +export async function bumpHeadersConfigMeta(): Promise { + const current = await browser.storage.local.get([BrowserStorageKey.HeadersConfigMeta]); + const prev = normalizeMeta(current[BrowserStorageKey.HeadersConfigMeta]); + + // Ensure monotonicity even if system clock moves backwards or updates are very close. + const now = Date.now(); + const nextUpdatedAt = Math.max(now, prev.updatedAt + 1); + + // Best-effort monotonic seq. It can race across concurrent writers; updatedAt remains authoritative. + const nextSeq = prev.seq + 1; + + return { seq: nextSeq, updatedAt: nextUpdatedAt }; +} + +export async function setWithBumpedHeadersConfigMeta(patch: Record) { + const meta = await bumpHeadersConfigMeta(); + await browser.storage.local.set({ + ...patch, + [BrowserStorageKey.HeadersConfigMeta]: meta, + }); + return meta; +} diff --git a/src/shared/utils/setBrowserHeaders.ts b/src/shared/utils/setBrowserHeaders.ts index 463a5930..90663751 100644 --- a/src/shared/utils/setBrowserHeaders.ts +++ b/src/shared/utils/setBrowserHeaders.ts @@ -10,6 +10,117 @@ import { validateHeader } from './headers'; import { logger } from './logger'; import { setIconBadge } from './setIconBadge'; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 200; +const RECOVERY_DELAY_MS = 500; + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Attempts to recover from DNR API errors by clearing all rules first, + * then applying new rules in a separate call. + * This handles cases where Chrome has corrupted internal state or "ghost" rules. + */ +async function recoveryUpdateDynamicRules( + removeRuleIds: number[], + addRules: browser.DeclarativeNetRequest.Rule[], +): Promise<{ success: boolean; error?: unknown }> { + logger.warn('🔧 DNR Recovery: Attempting recovery strategy...'); + + try { + // Step 1: Get current rules (for diagnostics) + const currentRules = await browser.declarativeNetRequest.getDynamicRules(); + logger.info('🔧 DNR Recovery: Current rules before clear:', { + count: currentRules.length, + ids: currentRules.map(r => r.id), + }); + + // Step 2: Try to remove ALL rules we know about (both from getDynamicRules and our removeRuleIds) + const allIdsToRemove = [...new Set([...currentRules.map(r => r.id), ...removeRuleIds])]; + + if (allIdsToRemove.length > 0) { + logger.info('🔧 DNR Recovery: Removing all known rules:', { ids: allIdsToRemove }); + try { + await browser.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: allIdsToRemove, + addRules: [], + }); + logger.info('🔧 DNR Recovery: Rules removed successfully'); + } catch (removeErr) { + logger.warn('🔧 DNR Recovery: Failed to remove rules, continuing anyway:', removeErr); + } + } + + // Step 3: Wait for Chrome to stabilize + await sleep(RECOVERY_DELAY_MS); + + // Step 4: Verify rules are cleared + const rulesAfterClear = await browser.declarativeNetRequest.getDynamicRules(); + logger.info('🔧 DNR Recovery: Rules after clear:', { + count: rulesAfterClear.length, + ids: rulesAfterClear.map(r => r.id), + }); + + // Step 5: Add new rules in a separate call + if (addRules.length > 0) { + logger.info('🔧 DNR Recovery: Adding new rules:', { + count: addRules.length, + ids: addRules.map(r => r.id), + }); + await browser.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [], + addRules, + }); + logger.info('🔧 DNR Recovery: New rules added successfully'); + } + + // Step 6: Verify final state + const finalRules = await browser.declarativeNetRequest.getDynamicRules(); + logger.info('🔧 DNR Recovery: Final rules state:', { + count: finalRules.length, + ids: finalRules.map(r => r.id), + expected: addRules.length, + }); + + return { success: true }; + } catch (error) { + logger.error('🔧 DNR Recovery: Recovery failed:', error); + return { success: false, error }; + } +} + +/** + * Last-resort fallback: use session rules when dynamic rules API is completely broken. + * Session rules are ephemeral (lost on browser restart / SW termination) but use a + * different internal Chrome storage path, so they may work when dynamic rules don't. + */ +async function sessionRulesFallback( + removeSessionRuleIds: number[], + addRules: browser.DeclarativeNetRequest.Rule[], +): Promise<{ success: boolean; error?: unknown }> { + logger.warn('🔄 Session rules fallback: dynamic rules API broken, trying session rules...'); + + try { + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: removeSessionRuleIds, + addRules, + }); + + const finalRules = await browser.declarativeNetRequest.getSessionRules(); + logger.info('✅ Session rules fallback succeeded:', { + activeRulesCount: finalRules.length, + ids: finalRules.map(r => r.id), + }); + + return { success: true }; + } catch (error) { + logger.error('❌ Session rules fallback also failed:', error); + return { success: false, error }; + } +} + const allResourceTypes = [ 'main_frame', 'sub_frame', @@ -59,8 +170,20 @@ function getRulesForHeader(header: RequestHeader, urlFilters: string[]): browser }); } -export async function setBrowserHeaders(result: Record, currentTabUrl?: string) { +type SetBrowserHeadersMeta = { + /** Monotonic apply id from background queue */ + applyId?: number; + /** Why this apply happened (storage.onChanged, onActivated, reload, startup, etc) */ + reason?: string; + /** Optional storage fingerprint computed by caller for correlation */ + storageFingerprint?: string; + /** Active tab URL — used for badge count when URL filters are set */ + currentTabUrl?: string; +}; + +export async function setBrowserHeaders(result: Record, meta: SetBrowserHeadersMeta = {}) { const isPaused = result[BrowserStorageKey.IsPaused] as boolean; + const currentTabUrl = meta.currentTabUrl; // Validate data from storage let profiles: Profile[] = []; @@ -84,6 +207,10 @@ export async function setBrowserHeaders(result: Record, current } logger.debug('Storage data validation:', { + applyId: meta.applyId, + reason: meta.reason, + storageFingerprint: meta.storageFingerprint, + currentTabUrl, profilesCount: profiles.length, selectedProfile, isPaused, @@ -92,13 +219,25 @@ export async function setBrowserHeaders(result: Record, current hasIsPausedData: result[BrowserStorageKey.IsPaused] !== undefined, }); - const currentRules = await browser.declarativeNetRequest.getDynamicRules(); + const currentDynamicRules = await browser.declarativeNetRequest.getDynamicRules(); + + // Also read session rules — we may have leftover session rules from a previous fallback + let currentSessionRules: browser.DeclarativeNetRequest.Rule[] = []; + try { + currentSessionRules = await browser.declarativeNetRequest.getSessionRules(); + } catch { + // getSessionRules may be unavailable in older browsers; ignore + } - const profile = profiles.find(p => p.id === selectedProfile); + let profile = profiles.find(p => p.id === selectedProfile); - if (!profile && selectedProfile) { + // Fallback to the first profile when selectedProfile is missing or doesn't match. + // This happens when SelectedProfile hasn't been written to storage yet (e.g. first run) + // or when it points to a deleted profile. + if (!profile && profiles.length > 0) { + profile = profiles[0]; logger.warn( - `Profile with id "${selectedProfile}" not found in storage. Available profiles:`, + `Profile with id "${selectedProfile}" not found in storage, falling back to first profile "${profile.id}". Available profiles:`, profiles.map(p => ({ id: p.id, name: p.name })), ); } @@ -164,25 +303,132 @@ export async function setBrowserHeaders(result: Record, current const addRules = [...headerRules, ...cookieRules]; - const removeRuleIds = currentRules.map(item => item.id); + const removeDynamicRuleIds = currentDynamicRules.map(item => item.id); + const removeSessionRuleIds = currentSessionRules.map(item => item.id); try { logger.group('Updating dynamic rules', true); - logger.info('Remove rule IDs:', removeRuleIds); + logger.info('Apply meta:', meta); + logger.info('Remove dynamic rule IDs:', removeDynamicRuleIds); + if (removeSessionRuleIds.length > 0) { + logger.info('Remove session rule IDs (leftover from previous fallback):', removeSessionRuleIds); + } logger.info('Add rules:', addRules); - if (removeRuleIds.length > 0 || addRules.length > 0) { - await browser.declarativeNetRequest.updateDynamicRules({ - removeRuleIds, - addRules, + // IMPORTANT: + // Apply rules atomically in a single updateDynamicRules call. + // Two-step remove→add is prone to race conditions when multiple triggers fire concurrently + // (often more noticeable on Windows due to timing/latency differences). + let usedSessionFallback = false; + + if (removeDynamicRuleIds.length > 0 || removeSessionRuleIds.length > 0 || addRules.length > 0) { + // Enhanced diagnostics: log full rule details before update + logger.info('📊 DNR Update diagnostics:', { + removeDynamicRuleIds, + addRulesCount: addRules.length, + addRulesDetails: addRules.map(r => ({ + id: r.id, + actionType: r.action.type, + hasUrlFilter: 'urlFilter' in (r.condition || {}), + hasRegexFilter: 'regexFilter' in (r.condition || {}), + condition: r.condition, + })), }); - logger.debug('Rules updated'); + + // Retry logic for transient DNR API errors (e.g., "Internal error while updating dynamic rules") + let lastError: unknown = null; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + await browser.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: removeDynamicRuleIds, + addRules, + }); + logger.debug(`Dynamic rules updated (atomic, attempt ${attempt})`); + lastError = null; + break; + } catch (err) { + lastError = err; + logger.warn(`DNR updateDynamicRules failed (attempt ${attempt}/${MAX_RETRIES}):`, err); + if (attempt < MAX_RETRIES) { + await sleep(RETRY_DELAY_MS * attempt); // Exponential backoff + } + } + } + + // If atomic update failed, try recovery strategy + if (lastError) { + logger.warn('⚠️ Atomic DNR update failed after retries, attempting recovery...'); + const recoveryResult = await recoveryUpdateDynamicRules(removeDynamicRuleIds, addRules); + + if (recoveryResult.success) { + logger.info('✅ DNR Recovery successful!'); + lastError = null; + } else { + logger.error('❌ DNR Recovery also failed:', recoveryResult.error); + } + } + + // Last resort: session rules fallback when Chrome's dynamic rules DB is corrupted + if (lastError) { + const sessionResult = await sessionRulesFallback(removeSessionRuleIds, addRules); + + if (sessionResult.success) { + usedSessionFallback = true; + lastError = null; + } + } + + if (lastError) { + throw lastError; + } + } else { + logger.debug('No dynamic rules to update'); } - const updatedRules = await browser.declarativeNetRequest.getDynamicRules(); - logger.debug('Current active rules after update:', updatedRules); + // If dynamic rules succeeded, clean up any leftover session rules from a previous fallback + if (!usedSessionFallback && removeSessionRuleIds.length > 0) { + try { + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: removeSessionRuleIds, + addRules: [], + }); + logger.info('🧹 Cleaned up leftover session rules'); + } catch { + // Non-critical: ignore cleanup failures + } + } + + // Verify final state (check both dynamic and session rules) + const updatedDynamicRules = await browser.declarativeNetRequest.getDynamicRules(); + let updatedSessionRules: browser.DeclarativeNetRequest.Rule[] = []; + try { + updatedSessionRules = await browser.declarativeNetRequest.getSessionRules(); + } catch { + // ignore + } + const allActiveRules = [...updatedDynamicRules, ...updatedSessionRules]; - logger.info('Rules updated successfully'); + logger.info('📊 Final DNR state:', { + dynamicRulesCount: updatedDynamicRules.length, + sessionRulesCount: updatedSessionRules.length, + totalActiveRulesCount: allActiveRules.length, + expectedRulesCount: addRules.length, + match: allActiveRules.length === addRules.length, + usedSessionFallback, + activeRuleIds: allActiveRules.map(r => r.id), + activeRulesDetails: allActiveRules.map(r => ({ + id: r.id, + actionType: r.action.type, + condition: r.condition, + requestHeaders: r.action.requestHeaders, + })), + }); + + logger.info( + usedSessionFallback + ? '⚠️ Rules applied via session fallback (will be lost on browser restart — restart Chrome to fix dynamic rules)' + : '✅ Rules updated successfully', + ); logger.groupEnd(); const tabUrl = currentTabUrl; @@ -194,5 +440,9 @@ export async function setBrowserHeaders(result: Record, current await setIconBadge({ isPaused, activeRulesCount }); } catch (err) { logger.error('Failed to update dynamic rules:', err); + logger.groupEnd(); + // CRITICAL: Re-throw to prevent caller from updating lastAppliedStorageFingerprint/lastAppliedMeta + // If we swallow the error, the queue state becomes desynchronized with actual DNR rules + throw err; } } diff --git a/src/shared/utils/updateOverrideHeaders.ts b/src/shared/utils/updateOverrideHeaders.ts deleted file mode 100644 index 3ad71026..00000000 --- a/src/shared/utils/updateOverrideHeaders.ts +++ /dev/null @@ -1,7 +0,0 @@ -import browser from 'webextension-polyfill'; - -import { ServiceWorkerEvent } from '../constants'; - -export async function updateOverrideHeaders() { - await browser.runtime.sendMessage(ServiceWorkerEvent.Reload); -} diff --git a/tests/e2e/header-toggle-dnr.spec.ts b/tests/e2e/header-toggle-dnr.spec.ts new file mode 100644 index 00000000..093e89e5 --- /dev/null +++ b/tests/e2e/header-toggle-dnr.spec.ts @@ -0,0 +1,278 @@ +import { expect, test } from './fixtures'; + +/** + * E2E тесты для проверки применения DNR правил при включении/выключении заголовков. + * + * Эти тесты проверяют исправление бага, когда при повторном включении заголовков + * DNR правила не создавались из-за рассинхронизации состояния очереди в background.ts. + */ +test.describe('Header Toggle DNR Rules', () => { + /** + * Вспомогательная функция для получения DNR правил из service worker + */ + async function getDynamicRules(context: ReturnType['context']) { + const serviceWorkers = context.serviceWorkers(); + if (serviceWorkers.length === 0) { + throw new Error('No service workers found'); + } + const sw = serviceWorkers[0]; + + // Выполняем код в контексте service worker через evaluate + const rules = await sw.evaluate(async () => { + // @ts-expect-error - chrome API доступен в service worker + const browser = globalThis.chrome || globalThis.browser; + if (browser?.declarativeNetRequest?.getDynamicRules) { + return browser.declarativeNetRequest.getDynamicRules(); + } + return []; + }); + + return rules as Array<{ id: number; action: unknown; condition: unknown }>; + } + + /** + * Вспомогательная функция для ожидания изменения количества DNR правил + */ + async function waitForRulesCount( + context: ReturnType['context'], + expectedCount: number, + timeout = 5000, + ) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const rules = await getDynamicRules(context); + if (rules.length === expectedCount) { + return rules; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + const currentRules = await getDynamicRules(context); + throw new Error(`Expected ${expectedCount} rules, but got ${currentRules.length} after ${timeout}ms`); + } + + /** + * Тест-кейс: Повторное включение заголовка должно создавать DNR правило + * + * Цель: Проверить, что при повторном включении заголовка (после выключения) + * DNR правило корректно создаётся. + * + * Это регрессионный тест для бага, когда lastAppliedStorageFingerprint + * не синхронизировался при прямых вызовах setBrowserHeaders. + * + * Сценарий: + * 1. Открываем popup расширения + * 2. Добавляем заголовок и заполняем его + * 3. Проверяем, что DNR правило создано + * 4. Выключаем заголовок через checkbox + * 5. Проверяем, что DNR правило удалено + * 6. Включаем заголовок обратно + * 7. Проверяем, что DNR правило снова создано + */ + test('should apply DNR rules when re-enabling header', async ({ context, page, extensionId }) => { + // Шаг 1: Открываем popup расширения + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + // Получаем начальное количество правил + const initialRules = await getDynamicRules(context); + const initialRulesCount = initialRules.length; + + // Шаг 2: Добавляем заголовок и заполняем его + const addHeaderButton = page.locator('[data-test-id="add-request-header-button"]'); + await addHeaderButton.click(); + await page.waitForTimeout(500); + + const headerNameField = page.locator('[data-test-id="header-name-input"] input').first(); + const headerValueField = page.locator('[data-test-id="header-value-input"] input').first(); + + await headerNameField.fill('X-Toggle-Test'); + await headerValueField.fill('toggle-value'); + + // Шаг 3: Проверяем, что DNR правило создано + await waitForRulesCount(context, initialRulesCount + 1); + const rulesAfterAdd = await getDynamicRules(context); + expect(rulesAfterAdd.length).toBe(initialRulesCount + 1); + + // Шаг 4: Выключаем заголовок через checkbox + const headerCheckbox = page.locator('[data-test-id="request-header-checkbox"]').first(); + await expect(headerCheckbox).toHaveAttribute('data-checked', 'true'); + await headerCheckbox.click(); + await expect(headerCheckbox).toHaveAttribute('data-checked', 'false'); + + // Шаг 5: Проверяем, что DNR правило удалено + await waitForRulesCount(context, initialRulesCount); + const rulesAfterDisable = await getDynamicRules(context); + expect(rulesAfterDisable.length).toBe(initialRulesCount); + + // Шаг 6: Включаем заголовок обратно + await headerCheckbox.click(); + await expect(headerCheckbox).toHaveAttribute('data-checked', 'true'); + + // Шаг 7: Проверяем, что DNR правило снова создано + // Это ключевая проверка — ранее правило НЕ создавалось при повторном включении + await waitForRulesCount(context, initialRulesCount + 1); + const rulesAfterReEnable = await getDynamicRules(context); + expect(rulesAfterReEnable.length).toBe(initialRulesCount + 1); + }); + + /** + * Тест-кейс: Множественные toggle операции должны корректно обновлять DNR правила + * + * Цель: Проверить стабильность системы при многократном включении/выключении. + * + * Сценарий: + * 1. Открываем popup расширения + * 2. Добавляем заголовок + * 3. Выполняем 3 цикла включения/выключения + * 4. Проверяем корректность DNR правил после каждого цикла + */ + test('should handle multiple toggle operations correctly', async ({ context, page, extensionId }) => { + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const initialRules = await getDynamicRules(context); + const initialRulesCount = initialRules.length; + + // Добавляем заголовок + const addHeaderButton = page.locator('[data-test-id="add-request-header-button"]'); + await addHeaderButton.click(); + await page.waitForTimeout(500); + + const headerNameField = page.locator('[data-test-id="header-name-input"] input').first(); + const headerValueField = page.locator('[data-test-id="header-value-input"] input').first(); + + await headerNameField.fill('X-Multi-Toggle'); + await headerValueField.fill('multi-toggle-value'); + + // Ждём создания правила + await waitForRulesCount(context, initialRulesCount + 1); + + const headerCheckbox = page.locator('[data-test-id="request-header-checkbox"]').first(); + + // Выполняем 3 цикла toggle + for (let i = 0; i < 3; i++) { + // Выключаем + await headerCheckbox.click(); + await expect(headerCheckbox).toHaveAttribute('data-checked', 'false'); + await waitForRulesCount(context, initialRulesCount); + + // Включаем + await headerCheckbox.click(); + await expect(headerCheckbox).toHaveAttribute('data-checked', 'true'); + await waitForRulesCount(context, initialRulesCount + 1); + } + + // Финальная проверка + const finalRules = await getDynamicRules(context); + expect(finalRules.length).toBe(initialRulesCount + 1); + }); + + /** + * Тест-кейс: Быстрые toggle операции не должны вызывать race conditions + * + * Цель: Проверить, что быстрые клики не вызывают race conditions + * благодаря очереди applyHeadersFromStorageQueue. + * + * Сценарий: + * 1. Открываем popup расширения + * 2. Добавляем заголовок + * 3. Быстро кликаем по checkbox несколько раз + * 4. Ждём стабилизации + * 5. Проверяем, что финальное состояние корректно + */ + test('should handle rapid toggle clicks without race conditions', async ({ context, page, extensionId }) => { + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const initialRules = await getDynamicRules(context); + const initialRulesCount = initialRules.length; + + // Добавляем заголовок + const addHeaderButton = page.locator('[data-test-id="add-request-header-button"]'); + await addHeaderButton.click(); + await page.waitForTimeout(500); + + const headerNameField = page.locator('[data-test-id="header-name-input"] input').first(); + const headerValueField = page.locator('[data-test-id="header-value-input"] input').first(); + + await headerNameField.fill('X-Rapid-Toggle'); + await headerValueField.fill('rapid-value'); + + // Ждём создания правила + await waitForRulesCount(context, initialRulesCount + 1); + + const headerCheckbox = page.locator('[data-test-id="request-header-checkbox"]').first(); + + // Быстро кликаем 5 раз (нечётное количество = финальное состояние "выключено") + for (let i = 0; i < 5; i++) { + await headerCheckbox.click(); + // Минимальная задержка, чтобы клик успел обработаться UI + await page.waitForTimeout(50); + } + + // Ждём стабилизации (даём время очереди обработать все изменения) + await page.waitForTimeout(1000); + + // Проверяем финальное состояние UI + const finalChecked = await headerCheckbox.getAttribute('data-checked'); + + // Проверяем, что DNR правила соответствуют UI состоянию + const finalRules = await getDynamicRules(context); + + if (finalChecked === 'true') { + // Если включено — должно быть правило + expect(finalRules.length).toBe(initialRulesCount + 1); + } else { + // Если выключено — правило должно быть удалено + expect(finalRules.length).toBe(initialRulesCount); + } + }); + + /** + * Тест-кейс: Изменение значения заголовка должно обновлять DNR правило + * + * Цель: Проверить, что при изменении значения заголовка DNR правило + * обновляется корректно. + * + * Сценарий: + * 1. Открываем popup расширения + * 2. Добавляем заголовок с начальным значением + * 3. Проверяем создание DNR правила + * 4. Изменяем значение заголовка + * 5. Проверяем, что DNR правило обновилось + */ + test('should update DNR rule when header value changes', async ({ context, page, extensionId }) => { + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const initialRules = await getDynamicRules(context); + const initialRulesCount = initialRules.length; + + // Добавляем заголовок + const addHeaderButton = page.locator('[data-test-id="add-request-header-button"]'); + await addHeaderButton.click(); + await page.waitForTimeout(500); + + const headerNameField = page.locator('[data-test-id="header-name-input"] input').first(); + const headerValueField = page.locator('[data-test-id="header-value-input"] input').first(); + + await headerNameField.fill('X-Value-Change'); + await headerValueField.fill('initial-value'); + + // Ждём создания правила + await waitForRulesCount(context, initialRulesCount + 1); + + // Изменяем значение + await headerValueField.clear(); + await headerValueField.fill('updated-value'); + + // Ждём обновления (правило должно остаться, но с новым значением) + await page.waitForTimeout(500); + + const finalRules = await getDynamicRules(context); + expect(finalRules.length).toBe(initialRulesCount + 1); + + // Проверяем, что правило содержит новое значение + // (структура правила зависит от реализации, но мы можем проверить, что правило существует) + }); +}); From a01c9ef2d4fcddaa16fe8171377feec0897dd692 Mon Sep 17 00:00:00 2001 From: Kirill Lebedenko Date: Mon, 2 Nov 2026 18:14:37 +0000 Subject: [PATCH 2/4] fix: reconcile DNR rules when the service worker restarts Apply stored headers on service worker startup so disabled headers are not left active after Chrome kills the worker. Restart tests drive the same apply path through a storage fingerprint change. Co-authored-by: Kirill Lebedenko --- src/background.ts | 13 ++ tests/e2e/sw-restart.spec.ts | 240 +++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 tests/e2e/sw-restart.spec.ts diff --git a/src/background.ts b/src/background.ts index 67488cdb..892a9430 100644 --- a/src/background.ts +++ b/src/background.ts @@ -524,3 +524,16 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { }); browserAction.setBadgeBackgroundColor({ color: BADGE_COLOR }); + +// Sync DNR rules on every service worker startup. +// +// MV3 service workers are killed by Chrome after ~30s of inactivity and restarted on demand. +// onStartup only fires on browser start; onInstalled only fires on extension install/update. +// Neither fires on a plain SW restart, so stale dynamic rules from the previous session +// (which persist across SW restarts) would never be cleaned up until storage changes. +// +// By triggering an apply here, we reconcile DNR state with storage on every SW start. +// The meta/fingerprint deduplication in applyHeadersFromStorageQueue prevents redundant applies. +applyHeadersFromStorageQueue('sw-init').catch(err => { + logger.error('❌ Failed to apply headers on SW init:', err); +}); diff --git a/tests/e2e/sw-restart.spec.ts b/tests/e2e/sw-restart.spec.ts new file mode 100644 index 00000000..83cc325c --- /dev/null +++ b/tests/e2e/sw-restart.spec.ts @@ -0,0 +1,240 @@ +import type { Worker } from '@playwright/test'; + +import { expect, test } from './fixtures'; + +/** + * E2E tests verifying that stale DNR rules are cleared when headers are disabled. + * + * Background: MV3 service workers are killed by Chrome after ~30 s of inactivity. + * Dynamic DNR rules survive across SW sessions (they are persistent). When the SW + * restarts, neither onStartup nor onInstalled fires, so without an explicit + * initialisation apply, stale rules (left over from a previous SW session) stay + * active — meaning disabled headers keep being injected into requests. + * + * The fix is `applyHeadersFromStorageQueue('sw-init')` called at module load time in + * background.ts. Both sw-init and storage.onChanged call the same + * `applyHeadersFromStorageQueue` function, so these tests verify the invariant + * through a storage-triggered apply (which is fully observable via Playwright's + * service-worker evaluate API). + * + * NOTE: Directly simulating a plain SW restart via `chrome.runtime.reload()` is not + * feasible in Playwright's `launchPersistentContext`: after calling reload() the + * extension becomes permanently unreachable and context.serviceWorkers() stops + * reflecting the new worker. We therefore test the underlying behaviour instead of + * the exact startup trigger. + */ +test.describe('SW restart – stale DNR rule cleanup', () => { + async function getDynamicRules(sw: Worker): Promise> { + const rules = await sw.evaluate(async () => { + // @ts-expect-error – chrome API is available in the service worker + const browser = globalThis.chrome || globalThis.browser; + if (browser?.declarativeNetRequest?.getDynamicRules) { + return browser.declarativeNetRequest.getDynamicRules(); + } + return []; + }); + return rules as Array<{ id: number; action: unknown; condition: unknown }>; + } + + async function waitForRulesCount(sw: Worker, expectedCount: number, timeout = 5000) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const rules = await getDynamicRules(sw); + if (rules.length === expectedCount) return rules; + await new Promise(resolve => setTimeout(resolve, 100)); + } + const current = await getDynamicRules(sw); + throw new Error(`Expected ${expectedCount} rules but got ${current.length} after ${timeout} ms`); + } + + async function injectStaleRule(sw: Worker, ruleId: number, headerName: string, headerValue: string) { + await sw.evaluate( + async ({ ruleId, headerName, headerValue }) => { + // @ts-expect-error – chrome API is available in the service worker context + const browser = globalThis.chrome || globalThis.browser; + await browser.declarativeNetRequest.updateDynamicRules({ + addRules: [ + { + id: ruleId, + priority: 1, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: headerName, operation: 'set', value: headerValue }], + }, + condition: { + resourceTypes: ['main_frame', 'sub_frame', 'xmlhttprequest'], + }, + }, + ], + removeRuleIds: [], + }); + }, + { ruleId, headerName, headerValue }, + ); + } + + /** + * Forces applyHeadersFromStorageQueue to run by changing the storage fingerprint. + * + * The queue skips applies when the storage fingerprint (derived from profiles + + * selectedProfile + isPaused) hasn't changed since the last apply. Simply bumping + * headersConfigMetaV1 is not enough – the fingerprint must change too. + * + * Strategy: toggle `isPaused` to true, then wait until the apply has actually + * cleared all rules (we can observe this via DNR API), then restore pause state. + * Writing both storage changes in rapid succession causes them to collapse into a + * single apply that sees the final state (isPaused=false = original fingerprint), + * which would be skipped as "no effective changes". + */ + async function pauseToTriggerApply(sw: Worker) { + const { seq } = await sw.evaluate(async () => { + // @ts-expect-error – chrome API is available in the service worker context + const browser = globalThis.chrome || globalThis.browser; + const data = await browser.storage.local.get(['headersConfigMetaV1']); + const m = (data['headersConfigMetaV1'] as { seq?: number; updatedAt?: number }) || { seq: 0, updatedAt: 0 }; + return { seq: m.seq ?? 0 }; + }); + + // Pause: changes fingerprint → queue runs apply → all DNR rules cleared + await sw.evaluate( + async ({ seq }) => { + // @ts-expect-error – chrome API is available in the service worker context + const browser = globalThis.chrome || globalThis.browser; + await browser.storage.local.set({ + isPausedV1: true, + headersConfigMetaV1: { seq: seq + 1, updatedAt: Date.now() }, + }); + }, + { seq }, + ); + } + + async function unpause(sw: Worker) { + const { seq } = await sw.evaluate(async () => { + // @ts-expect-error – chrome API is available in the service worker context + const browser = globalThis.chrome || globalThis.browser; + const data = await browser.storage.local.get(['headersConfigMetaV1']); + const m = (data.headersConfigMetaV1 as { seq?: number; updatedAt?: number }) || { seq: 0, updatedAt: 0 }; + return { seq: m.seq ?? 0 }; + }); + + await sw.evaluate( + async ({ seq }) => { + // @ts-expect-error – chrome API is available in the service worker context + const browser = globalThis.chrome || globalThis.browser; + await browser.storage.local.set({ + isPausedV1: false, + headersConfigMetaV1: { seq: seq + 1, updatedAt: Date.now() }, + }); + }, + { seq }, + ); + } + + /** + * Stale rule for a DISABLED header must be cleared when an apply runs. + * + * This tests the invariant that applyHeadersFromStorageQueue (called by sw-init on + * every SW startup, and also by storage.onChanged) removes DNR rules that no longer + * correspond to active, enabled headers in storage. + * + * Scenario: + * 1. Add a header and fill it in → DNR rule created. + * 2. Disable the header via UI → DNR rule removed. + * 3. Inject a synthetic stale rule directly into Chrome's rule store + * (simulates a rule that survived from a prior SW session). + * 4. Trigger applyHeadersFromStorageQueue via a storage meta bump. + * 5. Assert: stale rule is gone. + */ + test('should clear stale DNR rule for disabled header when apply runs', async ({ context, page, extensionId }) => { + const STALE_RULE_ID = 998877; + const HEADER_NAME = 'X-SW-Restart-Test'; + + // Step 1 – open popup and create an enabled header + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const addHeaderButton = page.locator('[data-test-id="add-request-header-button"]'); + await addHeaderButton.click(); + await page.waitForTimeout(300); + + const headerNameField = page.locator('[data-test-id="header-name-input"] input').first(); + const headerValueField = page.locator('[data-test-id="header-value-input"] input').first(); + await headerNameField.fill(HEADER_NAME); + await headerValueField.fill('test-value'); + + const sw = context.serviceWorkers()[0]; + await waitForRulesCount(sw, 1); + + // Step 2 – disable the header → rule must be removed + const checkbox = page.locator('[data-test-id="request-header-checkbox"]').first(); + await expect(checkbox).toHaveAttribute('data-checked', 'true'); + await checkbox.click(); + await expect(checkbox).toHaveAttribute('data-checked', 'false'); + + await waitForRulesCount(sw, 0); + + // Step 3 – inject a stale rule directly (bypasses storage, simulates a rule + // that was left over from a previous SW session when the header was still enabled) + await injectStaleRule(sw, STALE_RULE_ID, HEADER_NAME, 'stale-value'); + await waitForRulesCount(sw, 1); + + const rulesBeforeApply = await getDynamicRules(sw); + expect(rulesBeforeApply[0]).toMatchObject({ id: STALE_RULE_ID }); + + // Step 4 – pause the extension: changes the storage fingerprint so the queue + // runs a genuine apply (isPaused=true → addRules=[], all current rules removed) + await pauseToTriggerApply(sw); + + // Step 5 – wait for the apply to clear all rules, then restore state + await waitForRulesCount(sw, 0, 8000); + await unpause(sw); + + const finalRules = await getDynamicRules(sw); + expect(finalRules.length).toBe(0); + }); + + /** + * Rules for ENABLED headers must survive an apply (regression guard). + * + * Ensures that applyHeadersFromStorageQueue only removes rules that are no + * longer backed by active, enabled headers – not rules that are still needed. + * + * Scenario: + * 1. Add and enable a header → DNR rule created. + * 2. Trigger applyHeadersFromStorageQueue via a storage meta bump. + * 3. Assert: the rule is still present after the apply. + */ + test('should preserve DNR rules for enabled headers when apply runs', async ({ context, page, extensionId }) => { + // Step 1 – create an enabled header + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const addHeaderButton = page.locator('[data-test-id="add-request-header-button"]'); + await addHeaderButton.click(); + await page.waitForTimeout(300); + + const headerNameField = page.locator('[data-test-id="header-name-input"] input').first(); + const headerValueField = page.locator('[data-test-id="header-value-input"] input').first(); + await headerNameField.fill('X-Enabled-After-Apply'); + await headerValueField.fill('should-survive'); + + const sw = context.serviceWorkers()[0]; + await waitForRulesCount(sw, 1); + + const checkbox = page.locator('[data-test-id="request-header-checkbox"]').first(); + await expect(checkbox).toHaveAttribute('data-checked', 'true'); + + // Step 2 – pause and unpause: triggers a genuine apply via fingerprint change, + // same code path as sw-init + await pauseToTriggerApply(sw); + await waitForRulesCount(sw, 0, 5000); // paused → rules cleared temporarily + await unpause(sw); + + // Step 3 – rule must be restored for the enabled header after unpause + await waitForRulesCount(sw, 1, 5000); + + const finalRules = await getDynamicRules(sw); + expect(finalRules.length).toBe(1); + }); +}); From 5716356dfea280003aa0fd19c6c1e5e31b0c4bc5 Mon Sep 17 00:00:00 2001 From: Kirill Lebedenko Date: Mon, 2 Nov 2026 18:14:37 +0000 Subject: [PATCH 3/4] fix: keep disabled headers off when dynamic rules stick Clear stale dynamic rules as soon as the worker boots, give counteracting session rules priority over stuck dynamic rules, and serialize rule updates. Show a warning in the popup when Chrome reports that a header was not applied. Co-authored-by: Kirill Lebedenko --- src/background.ts | 349 +++++++++----- src/entities/dnr-health/model.ts | 29 ++ src/entities/dnr-health/utils/load.ts | 21 + src/entities/dnr-health/utils/parse.ts | 12 + src/pages/main/Main.tsx | 12 +- src/pages/main/styled.ts | 16 + src/shared/constants.ts | 6 + .../setBrowserHeaders.recovery.spec.ts | 435 ++++++++++++++++++ src/shared/utils/headersConfigMeta.ts | 44 +- src/shared/utils/logger.ts | 37 ++ src/shared/utils/setBrowserHeaders.ts | 107 ++++- src/shared/utils/setIconBadge.ts | 9 +- .../request-headers/RequestHeaders.tsx | 13 +- .../RequestHeaderRow/RequestHeaderRow.tsx | 17 +- tests/e2e/dnr-warning.spec.ts | 172 +++++++ .../factories/screenshot-test.factory.ts | 1 + .../specs/profiles.screenshots.ts | 4 + 17 files changed, 1141 insertions(+), 143 deletions(-) create mode 100644 src/entities/dnr-health/model.ts create mode 100644 src/entities/dnr-health/utils/load.ts create mode 100644 src/entities/dnr-health/utils/parse.ts create mode 100644 src/shared/utils/__tests__/setBrowserHeaders.recovery.spec.ts create mode 100644 tests/e2e/dnr-warning.spec.ts diff --git a/src/background.ts b/src/background.ts index 892a9430..04f06fd4 100644 --- a/src/background.ts +++ b/src/background.ts @@ -2,11 +2,14 @@ import browser from 'webextension-polyfill'; import type { Profile, RequestHeader } from '#entities/request-profile/types'; -import { BrowserStorageKey } from './shared/constants'; -import { browserAction } from './shared/utils/browserAPI'; +import { BrowserStorageKey, RuntimeMessageType } from './shared/constants'; +import { validateCookie } from './shared/utils/cookies'; +import { countActiveHeadersForUrl, doesUrlMatchFilter } from './shared/utils/countActiveHeadersForUrl'; +import { validateHeader } from './shared/utils/headers'; import { logger, LogLevel } from './shared/utils/logger'; import { setBrowserCookies } from './shared/utils/setBrowserCookies'; import { setBrowserHeaders } from './shared/utils/setBrowserHeaders'; +import { setIconBadge } from './shared/utils/setIconBadge'; import { enableExtensionReload } from './utils/extension-reload'; logger.configure({ @@ -68,12 +71,10 @@ async function getCurrentTabUrl(): Promise { logger.debug('Background script load storage data:', JSON.stringify(result, null, 2)); logger.groupEnd(); - const currentTabUrl = await getCurrentTabUrl(); - await Promise.all([ - setBrowserHeaders(result, { reason: 'background-script-load', currentTabUrl }), - setBrowserCookies(result), - ]); - logger.info(`🏷️ Initial badge set for URL: ${currentTabUrl}`); + // DNR mutations are deliberately performed only by applyHeadersFromStorageQueue. + // sw-init below owns the queue during worker startup, so a direct update here + // could race with its stale-rule cleanup on slower machines (notably Windows). + logger.info('🏷️ Initial DNR sync is scheduled by sw-init queue'); } catch (error) { logger.error('Failed to check storage on background script load:', error); } @@ -85,7 +86,111 @@ if (process.env.NODE_ENV === 'development') { logger.debug('Extension auto-reload enabled for development mode'); } -const BADGE_COLOR = '#ffffff'; +const MAX_DEBUG_LOGS = 3000; + +const workerBootId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const workerBootAt = Date.now(); +let debugLogSeq = 0; +const debugLogsBuffer: Array<{ seq: number; timestamp: number; level: string; message: string; args: string[] }> = []; + +let applyInProgress = false; +let applyPending = false; +let applyCounter = 0; +let lastRequestedReason = 'unknown'; +let lastAppliedStorageFingerprint: string | null = null; +let lastAppliedMeta: { seq: number; updatedAt: number } = { seq: 0, updatedAt: 0 }; + +function safeStringify(value: unknown): string { + const seen = new WeakSet(); + return JSON.stringify( + value, + (_, currentValue) => { + if (currentValue instanceof Error) { + return { + name: currentValue.name, + message: currentValue.message, + stack: currentValue.stack, + }; + } + if (currentValue && typeof currentValue === 'object') { + if (seen.has(currentValue as object)) { + return '[Circular]'; + } + seen.add(currentValue as object); + } + return currentValue; + }, + 2, + ); +} + +function appendDebugLog(entry: { timestamp: number; level: string; message: string; args: string[] }): void { + debugLogSeq += 1; + debugLogsBuffer.push({ seq: debugLogSeq, ...entry }); + if (debugLogsBuffer.length > MAX_DEBUG_LOGS) { + debugLogsBuffer.splice(0, debugLogsBuffer.length - MAX_DEBUG_LOGS); + } +} + +logger.setExternalSink(({ level, message, args, timestamp }) => { + const serializedArgs = args.map(item => safeStringify(item)); + appendDebugLog({ timestamp, level, message, args: serializedArgs }); +}); + +function getApplyHealthSnapshot() { + const now = Date.now(); + return { + workerBootId, + workerUptimeMs: now - workerBootAt, + applyInProgress, + applyPending, + applyCounter, + lastRequestedReason, + lastAppliedStorageFingerprint, + lastAppliedMeta, + }; +} + +async function buildDebugLogsExportPayload() { + const now = Date.now(); + const storage = await browser.storage.local.get([ + BrowserStorageKey.Profiles, + BrowserStorageKey.SelectedProfile, + BrowserStorageKey.IsPaused, + BrowserStorageKey.HeadersConfigMeta, + ]); + + let dynamicRules: unknown[] = []; + let sessionRules: unknown[] = []; + try { + dynamicRules = await browser.declarativeNetRequest.getDynamicRules(); + } catch { + dynamicRules = []; + } + try { + sessionRules = await browser.declarativeNetRequest.getSessionRules(); + } catch { + sessionRules = []; + } + + return { + exportedAt: new Date(now).toISOString(), + worker: { + bootId: workerBootId, + bootedAt: new Date(workerBootAt).toISOString(), + uptimeMs: now - workerBootAt, + }, + health: getApplyHealthSnapshot(), + storage, + dnr: { + dynamicRulesCount: dynamicRules.length, + sessionRulesCount: sessionRules.length, + dynamicRules, + sessionRules, + }, + logs: debugLogsBuffer, + }; +} function storageFingerprint(result: Record): string { const profiles = result[BrowserStorageKey.Profiles]; @@ -114,13 +219,6 @@ function storageFingerprint(result: Record): string { return `fnv1a32:${hash.toString(16)}:len:${input.length}`; } -let applyInProgress = false; -let applyPending = false; -let applyCounter = 0; -let lastRequestedReason = 'unknown'; -let lastAppliedStorageFingerprint: string | null = null; -let lastAppliedMeta: { seq: number; updatedAt: number } = { seq: 0, updatedAt: 0 }; - function normalizeHeadersConfigMeta(value: unknown): { seq: number; updatedAt: number } { if (value && typeof value === 'object') { const obj = value as Record; @@ -143,6 +241,51 @@ function isNewerMeta(next: { seq: number; updatedAt: number }, prev: { seq: numb return next.updatedAt > prev.updatedAt; } +/** + * Refreshes the badge for the active tab without touching DNR rules. + * + * Tab activation/navigation used to call setBrowserHeaders directly just to refresh + * the badge. That bypassed the apply queue and could overlap with a storage-driven + * DNR update or the sw-init stale-rule cleanup. + */ +async function refreshBadgeFromStorage(result: Record, currentTabUrl: string | undefined) { + const isPaused = result[BrowserStorageKey.IsPaused] === true; + let profiles: Profile[] = []; + + try { + const profilesData = result[BrowserStorageKey.Profiles]; + if (typeof profilesData === 'string') { + profiles = JSON.parse(profilesData) as Profile[]; + } else if (Array.isArray(profilesData)) { + profiles = profilesData as Profile[]; + } + } catch (error) { + logger.warn('Failed to parse profiles while refreshing badge:', error); + } + + const selectedProfileId = result[BrowserStorageKey.SelectedProfile]; + const selectedProfile = + profiles.find(profile => profile.id === selectedProfileId) ?? (profiles.length > 0 ? profiles[0] : undefined); + const activeHeaders = (selectedProfile?.requestHeaders ?? []).filter( + ({ disabled, name, value }) => !disabled && validateHeader(name, value), + ); + const activeUrlFilters = (selectedProfile?.urlFilters ?? []) + .filter(({ disabled, value }) => !disabled && value.trim()) + .map(({ value }) => value.trim()); + const activeCookies = (selectedProfile?.requestCookies ?? []).filter( + ({ disabled, name, value }) => !disabled && validateCookie(name, value), + ); + const urlMatchesCurrentTab = + activeUrlFilters.length === 0 || + !currentTabUrl || + activeUrlFilters.some(filter => doesUrlMatchFilter(currentTabUrl, filter)); + const activeRulesCount = + countActiveHeadersForUrl(activeHeaders, activeUrlFilters, currentTabUrl) + + (urlMatchesCurrentTab ? activeCookies.length : 0); + + await setIconBadge({ isPaused, activeRulesCount }); +} + async function applyHeadersFromStorageQueue(reason: string) { lastRequestedReason = reason; applyPending = true; @@ -202,7 +345,10 @@ async function applyHeadersFromStorageQueue(reason: string) { lastAppliedStorageFingerprint, }); - if (!isNewer) { + // On the first worker run old installations may not yet have config meta. + // The fingerprint still proves there is an unapplied configuration, so do + // not leave its valid headers absent just because meta is { seq: 0 }. + if (!isNewer && lastAppliedStorageFingerprint !== null) { logger.warn('⏭️ Apply skipped (stale meta):', { applyId, reason: `meta.seq=${meta.seq} <= lastApplied.seq=${lastAppliedMeta.seq}, meta.updatedAt=${meta.updatedAt} <= lastApplied.updatedAt=${lastAppliedMeta.updatedAt}`, @@ -227,7 +373,7 @@ async function applyHeadersFromStorageQueue(reason: string) { const prevMeta = { ...lastAppliedMeta }; const currentTabUrl = await getCurrentTabUrl(); - await Promise.all([ + const [{ stuckRuleIds }] = await Promise.all([ setBrowserHeaders(result, { applyId, reason: lastRequestedReason, @@ -236,6 +382,9 @@ async function applyHeadersFromStorageQueue(reason: string) { }), setBrowserCookies(result), ]); + await browser.storage.local.set({ + [BrowserStorageKey.DnrHealth]: { ok: stuckRuleIds.length === 0, stuckRuleIds, updatedAt: Date.now() }, + }); lastAppliedStorageFingerprint = fp; lastAppliedMeta = meta; @@ -305,53 +454,21 @@ browser.runtime.onStartup.addListener(async function () { if (Object.keys(result).length) { logger.info('🚀 Storage data found, setting browser headers on startup'); - try { - const fp = storageFingerprint(result); - const applyId = ++applyCounter; - const currentTabUrl = await getCurrentTabUrl(); - - logger.debug('🔧 Direct apply (onStartup) starting:', { - applyId, - fp, - prevFp: lastAppliedStorageFingerprint, - prevMeta: lastAppliedMeta, - }); - - await Promise.all([ - setBrowserHeaders(result, { - applyId, - reason: 'runtime.onStartup', - storageFingerprint: fp, - currentTabUrl, - }), - setBrowserCookies(result), - ]); - - // Sync queue state after direct call to prevent duplicate applies - const prevFp = lastAppliedStorageFingerprint; - const prevMeta = { ...lastAppliedMeta }; - lastAppliedStorageFingerprint = fp; - const meta = normalizeHeadersConfigMeta(result[BrowserStorageKey.HeadersConfigMeta]); - if (isNewerMeta(meta, lastAppliedMeta)) { - lastAppliedMeta = meta; - } - - logger.info('🔄 Queue state synced after onStartup:', { - applyId, - fingerprintChange: `${prevFp} → ${lastAppliedStorageFingerprint}`, - metaChange: `seq:${prevMeta.seq}→${lastAppliedMeta.seq}, updatedAt:${prevMeta.updatedAt}→${lastAppliedMeta.updatedAt}`, - }); - } catch (error) { - logger.error('❌ Failed to set browser headers on startup (queue state NOT synced):', { - error, - queueStateRemains: { lastAppliedStorageFingerprint, lastAppliedMeta }, - }); - } + await applyHeadersFromStorageQueue('runtime.onStartup'); } else { logger.info('📭 No storage data found on startup - extension will start with default settings'); } }); +browser.runtime.onMessage.addListener((message: unknown) => { + if (!message || typeof message !== 'object') return undefined; + const payload = message as { type?: string }; + if (payload.type !== RuntimeMessageType.ExportDebugLogs) return undefined; + return buildDebugLogsExportPayload() + .then(result => ({ ok: true, result })) + .catch(error => ({ ok: false, error: safeStringify(error) })); +}); + browser.storage.onChanged.addListener(async (changes, areaName) => { logger.debug('Storage changes detected in area:', areaName); @@ -427,49 +544,7 @@ browser.runtime.onInstalled.addListener(async details => { if (Object.keys(result).length) { logger.info('🔧 Storage data found, initializing browser headers on install/update'); - try { - const fp = storageFingerprint(result); - const applyId = ++applyCounter; - const currentTabUrl = await getCurrentTabUrl(); - - logger.debug('🔧 Direct apply (onInstalled) starting:', { - applyId, - reason: details.reason, - fp, - prevFp: lastAppliedStorageFingerprint, - prevMeta: lastAppliedMeta, - }); - - await Promise.all([ - setBrowserHeaders(result, { - applyId, - reason: `runtime.onInstalled:${details.reason}`, - storageFingerprint: fp, - currentTabUrl, - }), - setBrowserCookies(result), - ]); - - // Sync queue state after direct call to prevent duplicate applies - const prevFp = lastAppliedStorageFingerprint; - const prevMeta = { ...lastAppliedMeta }; - lastAppliedStorageFingerprint = fp; - const meta = normalizeHeadersConfigMeta(result[BrowserStorageKey.HeadersConfigMeta]); - if (isNewerMeta(meta, lastAppliedMeta)) { - lastAppliedMeta = meta; - } - - logger.info('🔄 Queue state synced after onInstalled:', { - applyId, - fingerprintChange: `${prevFp} → ${lastAppliedStorageFingerprint}`, - metaChange: `seq:${prevMeta.seq}→${lastAppliedMeta.seq}, updatedAt:${prevMeta.updatedAt}→${lastAppliedMeta.updatedAt}`, - }); - } catch (error) { - logger.error('❌ Failed to set browser headers on install/update (queue state NOT synced):', { - error, - queueStateRemains: { lastAppliedStorageFingerprint, lastAppliedMeta }, - }); - } + await applyHeadersFromStorageQueue(`runtime.onInstalled:${details.reason}`); } else { logger.info('📭 No storage data found on install/update - extension will start with default settings'); } @@ -491,9 +566,9 @@ browser.tabs.onActivated.addListener(async activeInfo => { logger.info('📱 Tab activated, updating badge'); try { const tab = await browser.tabs.get(activeInfo.tabId); - await setBrowserHeaders(result, { reason: 'tabs.onActivated', currentTabUrl: tab.url }); + await refreshBadgeFromStorage(result, tab.url); } catch (error) { - logger.error('Failed to set browser headers on tab activation:', error); + logger.error('Failed to refresh badge on tab activation:', error); } } else { logger.debug('No storage data found on tab activation'); @@ -516,15 +591,13 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if (Object.keys(result).length) { try { - await setBrowserHeaders(result, { reason: 'tabs.onUpdated', currentTabUrl: tab.url }); + await refreshBadgeFromStorage(result, tab.url); } catch (error) { - logger.error('Failed to set browser headers on tab URL update:', error); + logger.error('Failed to refresh badge on tab URL update:', error); } } }); -browserAction.setBadgeBackgroundColor({ color: BADGE_COLOR }); - // Sync DNR rules on every service worker startup. // // MV3 service workers are killed by Chrome after ~30s of inactivity and restarted on demand. @@ -534,6 +607,60 @@ browserAction.setBadgeBackgroundColor({ color: BADGE_COLOR }); // // By triggering an apply here, we reconcile DNR state with storage on every SW start. // The meta/fingerprint deduplication in applyHeadersFromStorageQueue prevents redundant applies. -applyHeadersFromStorageQueue('sw-init').catch(err => { - logger.error('❌ Failed to apply headers on SW init:', err); +// +// IMPORTANT: Before starting the full apply (which reads storage first, ~200ms total), +// we eagerly remove all current dynamic rules. This eliminates the window where a disabled +// header from a previous SW session is still active as a stale DNR rule (e.g. after computer +// unlock). The apply that follows will re-add only the currently enabled headers. +const SW_INIT_MAX_RETRIES = 3; +const SW_INIT_RETRY_DELAY_MS = 200; + +async function clearDynamicRulesOnSwInit(): Promise { + try { + const staleRules = await browser.declarativeNetRequest.getDynamicRules(); + if (staleRules.length === 0) { + logger.debug('🧹 sw-init: no stale dynamic rules'); + return; + } + logger.info('🧹 sw-init: clearing stale dynamic rules:', { + count: staleRules.length, + ids: staleRules.map(r => r.id), + }); + + let lastError: unknown = null; + for (let attempt = 1; attempt <= SW_INIT_MAX_RETRIES; attempt++) { + try { + await browser.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: staleRules.map(r => r.id), + }); + logger.info('🧹 sw-init: stale dynamic rules cleared'); + return; + } catch (err) { + lastError = err; + logger.warn( + `⚠️ sw-init: failed to clear stale dynamic rules (attempt ${attempt}/${SW_INIT_MAX_RETRIES}):`, + err, + ); + if (attempt < SW_INIT_MAX_RETRIES) { + const delay = SW_INIT_RETRY_DELAY_MS * attempt; + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + logger.warn('⚠️ sw-init: failed to clear stale dynamic rules after retries (apply will handle it):', lastError); + } catch (err) { + logger.warn('⚠️ sw-init: failed to clear stale dynamic rules (apply will handle it):', err); + } +} + +// Hold the apply lock during sw-init clear so any concurrent storage.onChanged events +// queue up (applyPending=true) instead of racing with the DNR clear call. +// Concurrent updateDynamicRules calls cause Chrome to throw "Internal error while updating +// dynamic rules" and leave the DNR API broken for the rest of the session. +applyInProgress = true; +clearDynamicRulesOnSwInit().finally(() => { + applyInProgress = false; + applyHeadersFromStorageQueue('sw-init').catch(err => { + logger.error('❌ Failed to apply headers on SW init:', err); + }); }); diff --git a/src/entities/dnr-health/model.ts b/src/entities/dnr-health/model.ts new file mode 100644 index 00000000..14b248b5 --- /dev/null +++ b/src/entities/dnr-health/model.ts @@ -0,0 +1,29 @@ +import { createEffect, createEvent, createStore, sample } from 'effector'; +import browser from 'webextension-polyfill'; + +import { BrowserStorageKey } from '#shared/constants'; +import { initApp } from '#shared/model'; + +import { type DnrHealth, loadDnrHealthFromStorageApi } from './utils/load'; +import { parseDnrHealth } from './utils/parse'; + +export type { DnrHealth }; + +const loadDnrHealthFx = createEffect({ handler: loadDnrHealthFromStorageApi }); + +export const dnrHealthUpdated = createEvent(); + +export const $dnrHealth = createStore(null) + .on(loadDnrHealthFx.doneData, (_, health) => health) + .on(dnrHealthUpdated, (_, health) => health); + +sample({ source: initApp, target: loadDnrHealthFx }); + +// Keep $dnrHealth in sync when background writes DnrHealth to storage. +browser.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local') return; + if (!(BrowserStorageKey.DnrHealth in changes)) return; + + const newValue = changes[BrowserStorageKey.DnrHealth]?.newValue; + dnrHealthUpdated(parseDnrHealth(newValue)); +}); diff --git a/src/entities/dnr-health/utils/load.ts b/src/entities/dnr-health/utils/load.ts new file mode 100644 index 00000000..abd7fffa --- /dev/null +++ b/src/entities/dnr-health/utils/load.ts @@ -0,0 +1,21 @@ +import browser from 'webextension-polyfill'; + +import { BrowserStorageKey } from '#shared/constants'; + +import { parseDnrHealth } from './parse'; + +export type DnrHealth = { + ok: boolean; + stuckRuleIds: number[]; + updatedAt: number; +}; + +export async function loadDnrHealthFromStorageApi(): Promise { + try { + const response = await browser.storage.local.get([BrowserStorageKey.DnrHealth]); + const value = response[BrowserStorageKey.DnrHealth]; + return parseDnrHealth(value); + } catch { + return null; + } +} diff --git a/src/entities/dnr-health/utils/parse.ts b/src/entities/dnr-health/utils/parse.ts new file mode 100644 index 00000000..410c2cfc --- /dev/null +++ b/src/entities/dnr-health/utils/parse.ts @@ -0,0 +1,12 @@ +import type { DnrHealth } from './load'; + +export function parseDnrHealth(value: unknown): DnrHealth | null { + if (!value || typeof value !== 'object') return null; + + const raw = value as Record; + return { + ok: Boolean(raw.ok), + stuckRuleIds: Array.isArray(raw.stuckRuleIds) ? (raw.stuckRuleIds as number[]) : [], + updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : 0, + }; +} diff --git a/src/pages/main/Main.tsx b/src/pages/main/Main.tsx index dc7c5a37..5c3843bb 100644 --- a/src/pages/main/Main.tsx +++ b/src/pages/main/Main.tsx @@ -2,7 +2,9 @@ import { useUnit } from 'effector-react'; import { useMemo } from 'react'; import { Divider } from '@cloud-ru/ds-divider'; +import { WarningSVG } from '@cloud-ru/ds-icons/interface/system'; +import { $dnrHealth } from '#entities/dnr-health/model'; import { $selectedProfileIndex } from '#entities/request-profile/model'; import { profileColorList } from '#shared/assets/colors'; import { Header } from '#widgets/header'; @@ -13,7 +15,8 @@ import { ProfileActions } from './components/ProfileActions'; import * as S from './styled'; export function MainPage() { - const [selectedProfileIndex] = useUnit([$selectedProfileIndex]); + const [selectedProfileIndex, dnrHealth] = useUnit([$selectedProfileIndex, $dnrHealth]); + const hasDnrMismatch = dnrHealth !== null && !dnrHealth.ok; const colorMap = useMemo( () => profileColorList[selectedProfileIndex % profileColorList.length], @@ -33,6 +36,13 @@ export function MainPage() { + {hasDnrMismatch && ( + + + Some headers couldn't be applied by Chrome. Try toggling them or restarting the browser. + + )} + diff --git a/src/pages/main/styled.ts b/src/pages/main/styled.ts index b7b4f1a5..baee9e46 100644 --- a/src/pages/main/styled.ts +++ b/src/pages/main/styled.ts @@ -29,3 +29,19 @@ export const ColorLine = styled.div<{ color: string }>` width: 4px; background: ${({ color }) => color}; `; + +export const DnrWarningBanner = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 12px; + background: ${themeVars.sn.theme.color.orange.background}; + color: ${themeVars.sn.theme.color.orange.text}; + font-size: 12px; + line-height: 16px; + + svg { + color: ${themeVars.sn.theme.color.orange.accent}; + flex-shrink: 0; + } +`; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index aa0ed32d..b10e029f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -5,6 +5,12 @@ export enum BrowserStorageKey { /** Monotonic version meta for any header-config change */ HeadersConfigMeta = 'headersConfigMetaV1', ThemeMode = 'themeMode', + /** DNR health state written by background after each apply */ + DnrHealth = 'dnrHealthV1', +} + +export enum RuntimeMessageType { + ExportDebugLogs = 'export-debug-logs', } export enum Extensions { diff --git a/src/shared/utils/__tests__/setBrowserHeaders.recovery.spec.ts b/src/shared/utils/__tests__/setBrowserHeaders.recovery.spec.ts new file mode 100644 index 00000000..a50aab63 --- /dev/null +++ b/src/shared/utils/__tests__/setBrowserHeaders.recovery.spec.ts @@ -0,0 +1,435 @@ +/** + * Unit tests for setBrowserHeaders DNR recovery behavior. + * + * Covers the bug where recoveryUpdateDynamicRules() always returned { success: true } + * even when the final DNR rule count didn't match the expected count. This caused + * disabled headers to keep being injected into requests because the stale DNR rule + * was never actually removed from Chrome's internal state. + * + * Reproduction scenario (from real bug report): + * - Profile has a header (cp-front-billing) marked as disabled: true + * - Chrome DNR still has an active rule for that header (id: 178804364) + * - apply() tries to remove the rule → Chrome returns "Internal error while updating dynamic rules" + * - Recovery strategy also fails → rule count still 1, expected 0 + * - Bug: recovery claimed success anyway → "✅ Rules updated successfully" logged + * - Fix: recovery now returns { success: false } on count mismatch → session fallback is tried + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BrowserStorageKey } from '#shared/constants'; + +vi.mock('webextension-polyfill', () => ({ + default: { + declarativeNetRequest: { + getDynamicRules: vi.fn(), + updateDynamicRules: vi.fn(), + getSessionRules: vi.fn(), + updateSessionRules: vi.fn(), + }, + runtime: { + id: 'test-extension-id', + getURL: vi.fn(() => ''), + }, + }, +})); + +vi.mock('../setIconBadge', () => ({ + setIconBadge: vi.fn(), +})); + +// These imports must come after vi.mock() calls +import browser from 'webextension-polyfill'; + +import { setBrowserHeaders } from '../setBrowserHeaders'; + +// Cast to access vi.fn() methods — these ARE vi.fn() instances because of vi.mock above +const mockDnr = browser.declarativeNetRequest as unknown as { + getDynamicRules: ReturnType; + updateDynamicRules: ReturnType; + getSessionRules: ReturnType; + updateSessionRules: ReturnType; +}; + +// Matches the header id from the real bug report +const STALE_RULE_ID = 178804364; + +function makeStaleRule() { + return { + id: STALE_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-billing', operation: 'set', value: 'RM-3967' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; +} + +/** Storage snapshot with one disabled header — addRules should be [] on apply */ +function makeStorageWithDisabledHeader() { + const profile = { + id: 'profile-1', + requestHeaders: [{ id: STALE_RULE_ID, name: 'cp-front-billing', value: 'RM-3945', disabled: true }], + urlFilters: [], + }; + return { + [BrowserStorageKey.IsPaused]: false, + [BrowserStorageKey.Profiles]: JSON.stringify([profile]), + [BrowserStorageKey.SelectedProfile]: 'profile-1', + [BrowserStorageKey.HeadersConfigMeta]: { seq: 10, updatedAt: Date.now() }, + }; +} + +const CHROME_INTERNAL_ERROR = new Error('Internal error while updating dynamic rules.'); + +describe('setBrowserHeaders – DNR recovery', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + // Sensible defaults: no session rules, session updates succeed + mockDnr.getSessionRules.mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('happy path – disabled header rule is removed', () => { + it('calls updateDynamicRules to remove the stale rule when Chrome API succeeds', async () => { + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + expect(mockDnr.updateDynamicRules).toHaveBeenCalledWith( + expect.objectContaining({ removeRuleIds: [STALE_RULE_ID], addRules: [] }), + ); + }); + + it('does not add any DNR rules when all headers are disabled', async () => { + mockDnr.getDynamicRules + .mockResolvedValueOnce([makeStaleRule()]) // initial state: stale rule present + .mockResolvedValue([]); // after removal: cleared + + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + // Every updateDynamicRules call must have addRules: [] — no new rules added + for (const call of mockDnr.updateDynamicRules.mock.calls) { + const args = call[0] as { addRules: unknown[] }; + expect(args.addRules).toHaveLength(0); + } + }); + }); + + describe('Chrome "Internal error while updating dynamic rules" recovery', () => { + it('triggers session rules fallback when all dynamic rule removals fail and recovery count still mismatches', async () => { + // Chrome refuses to clear the stale rule — updateDynamicRules always throws, + // getDynamicRules always returns the stale rule (it was never removed) + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + // Should NOT throw — session fallback handles it gracefully + await promise; + + // Session rules fallback must have been tried as last resort + expect(mockDnr.updateSessionRules).toHaveBeenCalled(); + }); + + it('tries one-by-one rule removal in recovery when batch removal fails', async () => { + // Batch removal in recovery fails, but individual removal succeeds + mockDnr.getDynamicRules + .mockResolvedValueOnce([makeStaleRule()]) // initial state + .mockResolvedValueOnce([makeStaleRule()]) // recovery: before clear + .mockResolvedValueOnce([]) // recovery: after clear (one-by-one worked) + .mockResolvedValueOnce([]) // recovery: final check (0 === 0 → success) + .mockResolvedValue([]); // final state logging + + mockDnr.updateDynamicRules + .mockRejectedValueOnce(CHROME_INTERNAL_ERROR) // main retry 1 + .mockRejectedValueOnce(CHROME_INTERNAL_ERROR) // main retry 2 + .mockRejectedValueOnce(CHROME_INTERNAL_ERROR) // main retry 3 + .mockRejectedValueOnce(CHROME_INTERNAL_ERROR) // recovery: batch removal + .mockResolvedValue(undefined); // recovery: one-by-one removal succeeds + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + // There must be a single-rule removal call (removeRuleIds has exactly 1 entry) + const singleRuleCall = mockDnr.updateDynamicRules.mock.calls.find(call => { + const args = call[0] as { removeRuleIds: number[]; addRules: unknown[] }; + return ( + args.removeRuleIds?.length === 1 && args.removeRuleIds[0] === STALE_RULE_ID && args.addRules?.length === 0 + ); + }); + expect(singleRuleCall).toBeDefined(); + + // Recovery succeeded without session fallback + expect(mockDnr.updateSessionRules).not.toHaveBeenCalled(); + }); + + it('throws when both dynamic rules and session rules APIs are broken', async () => { + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + mockDnr.updateSessionRules.mockRejectedValue(new Error('Session rules API also broken')); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + // Suppress the unhandled rejection that fires during timer advancement + // (the rejection is properly asserted below via .rejects.toThrow()) + promise.catch(() => {}); + await vi.runAllTimersAsync(); + + await expect(promise).rejects.toThrow(); + }); + }); + + describe('stuck rule detection – only dynamic rules count', () => { + it('does not report session rule IDs as stuck even if they are not in addRules', async () => { + // A leftover counteracting session rule (id >= 1_000_000_000) exists from a previous apply. + // Dynamic rules are clean, so the apply should succeed and clean it up — stuckRuleIds must be []. + const counteractRule = { + id: 1_000_000_000, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-service-modals', operation: 'remove' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([]); // no stuck dynamic rules + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + mockDnr.getSessionRules + .mockResolvedValueOnce([counteractRule]) // initial read — will be cleaned up + .mockResolvedValue([]); // after cleanup + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.stuckRuleIds).toHaveLength(0); + expect(result.stuckRuleIds).not.toContain(1_000_000_000); + }); + + it('returns stuckRuleIds only for dynamic rules that were not removed', async () => { + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + // Session rules: empty before and after (fallback applies []) + mockDnr.getSessionRules.mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.stuckRuleIds).toEqual([STALE_RULE_ID]); + }); + }); + + describe('counteracting session rules for stuck disabled headers', () => { + it('adds a session remove rule to suppress a stuck disabled dynamic rule', async () => { + // All dynamic rule operations fail → stuck rule stays. + // Since the header is disabled (addRules=[]), the stuck rule has no session counterpart → + // a counteracting session rule with operation:"remove" must be added. + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + mockDnr.getSessionRules.mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + const counteractCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => { + const arg = call[0] as { + addRules?: Array<{ + id: number; + priority?: number; + action: { requestHeaders?: Array<{ operation: string; header: string }> }; + }>; + }; + const { addRules } = arg; + return addRules?.some( + rule => + rule.id >= 1_000_000_000 && + rule.priority === 2 && + rule.action.requestHeaders?.some(h => h.operation === 'remove' && h.header === 'cp-front-billing'), + ); + }); + expect(counteractCall).toBeDefined(); + }); + + it('does not remove an existing counteracting session rule when session fallback is invoked', async () => { + // Regression: sessionRulesFallback used to receive ALL removeSessionRuleIds, including + // counteracting rules (id >= 1_000_000_000). It would delete them while the stuck + // dynamic rule remained, opening a window where the disabled header was transmitted. + // Fix: counteracting rules are excluded from fallbackRemoveSessionRuleIds. + // The fallback is the one that receives addRules: [] — it must not remove the counteracting rule. + const COUNTERACT_RULE_ID = 1_000_000_000; + const existingCounteractRule = { + id: COUNTERACT_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-billing', operation: 'remove' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + // Counteracting rule already exists from a previous apply + mockDnr.getSessionRules.mockResolvedValue([existingCounteractRule]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + // The fallback calls updateSessionRules with addRules: [] — that call must NOT remove the counteracting rule + const fallbackCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => { + const arg = call[0] as { removeRuleIds?: number[]; addRules?: unknown[] }; + return (arg.addRules?.length ?? 0) === 0; + }); + expect(fallbackCall).toBeDefined(); + const fallbackArg = (fallbackCall as [unknown])[0] as { removeRuleIds?: number[] }; + expect(fallbackArg.removeRuleIds).not.toContain(COUNTERACT_RULE_ID); + }); + + it('removes stale counteracting rule when the previously-disabled header becomes enabled', async () => { + // Scenario: header was disabled (counteracting rule added), user re-enables it. + // Dynamic rules succeed this time, so stuckRuleIds=[]. We must remove the old + // counteracting rule since the header is now enabled and no longer needs counteracting. + const COUNTERACT_RULE_ID = 1_000_000_000; + const existingCounteractRule = { + id: COUNTERACT_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-billing', operation: 'remove' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + const profile = { + id: 'profile-1', + requestHeaders: [{ id: STALE_RULE_ID, name: 'cp-front-billing', value: 'RM-3945', disabled: false }], + urlFilters: [], + }; + const storage = { + [BrowserStorageKey.IsPaused]: false, + [BrowserStorageKey.Profiles]: JSON.stringify([profile]), + [BrowserStorageKey.SelectedProfile]: 'profile-1', + [BrowserStorageKey.HeadersConfigMeta]: { seq: 11, updatedAt: Date.now() }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([]); + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + mockDnr.getSessionRules.mockResolvedValueOnce([existingCounteractRule]).mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(storage); + await vi.runAllTimersAsync(); + await promise; + + const cleanupCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => { + const arg = call[0] as { removeRuleIds?: number[]; addRules?: unknown[] }; + return arg.removeRuleIds?.includes(COUNTERACT_RULE_ID) && (arg.addRules?.length ?? 0) === 0; + }); + expect(cleanupCall).toBeDefined(); + }); + + it('does not add a counteracting rule when the stuck header is already covered by the session fallback', async () => { + // Scenario: the user deleted a header (id=100, name="x-service") and added a new one + // with the same name but a fresh id=200. The old dynamic rule (id=100) is stuck. + // The session fallback applies the new enabled rule (id=200). Because "x-service" is + // already present in session rules, no additional counteract rule should be added. + const OLD_RULE_ID = 100; + const NEW_RULE_ID = 200; + const HEADER_NAME = 'x-service'; + + const stuckDynamicRule = { + id: OLD_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: HEADER_NAME, operation: 'set', value: 'old-value' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + const profile = { + id: 'profile-1', + requestHeaders: [{ id: NEW_RULE_ID, name: HEADER_NAME, value: 'new-value', disabled: false }], + urlFilters: [], + }; + const storage = { + [BrowserStorageKey.IsPaused]: false, + [BrowserStorageKey.Profiles]: JSON.stringify([profile]), + [BrowserStorageKey.SelectedProfile]: 'profile-1', + [BrowserStorageKey.HeadersConfigMeta]: { seq: 10, updatedAt: Date.now() }, + }; + + const sessionFallbackRule = { + id: NEW_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: HEADER_NAME, operation: 'set', value: 'new-value' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([stuckDynamicRule]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + mockDnr.getSessionRules + .mockResolvedValueOnce([]) // initial read + .mockResolvedValue([sessionFallbackRule]); // after session fallback applied new-value rule + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(storage); + await vi.runAllTimersAsync(); + await promise; + + // No counteracting rule (id >= 1_000_000_000) should have been added + const counteractCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => + (call[0] as { addRules?: Array<{ id: number }> })?.addRules?.some(rule => rule.id >= 1_000_000_000), + ); + expect(counteractCall).toBeUndefined(); + }); + + it('removes stale counteracting rules when stuckRuleIds is empty (recovered from previous broken session)', async () => { + // Scenario: previous apply left a counteracting rule, but dynamic rules have since been + // cleared (e.g. browser restart). Now stuckRuleIds=[], we must remove the stale counteracting rule. + const COUNTERACT_RULE_ID = 1_000_000_000; + const existingCounteractRule = { + id: COUNTERACT_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-billing', operation: 'remove' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([]); + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + mockDnr.getSessionRules.mockResolvedValueOnce([existingCounteractRule]).mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + const cleanupCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => { + const arg = call[0] as { removeRuleIds?: number[] }; + return arg.removeRuleIds?.includes(COUNTERACT_RULE_ID); + }); + expect(cleanupCall).toBeDefined(); + }); + }); +}); diff --git a/src/shared/utils/headersConfigMeta.ts b/src/shared/utils/headersConfigMeta.ts index e577b014..11c84005 100644 --- a/src/shared/utils/headersConfigMeta.ts +++ b/src/shared/utils/headersConfigMeta.ts @@ -29,25 +29,33 @@ function normalizeMeta(value: unknown): HeadersConfigMeta { return { seq: 0, updatedAt: 0 }; } -export async function bumpHeadersConfigMeta(): Promise { - const current = await browser.storage.local.get([BrowserStorageKey.HeadersConfigMeta]); - const prev = normalizeMeta(current[BrowserStorageKey.HeadersConfigMeta]); - - // Ensure monotonicity even if system clock moves backwards or updates are very close. - const now = Date.now(); - const nextUpdatedAt = Math.max(now, prev.updatedAt + 1); - - // Best-effort monotonic seq. It can race across concurrent writers; updatedAt remains authoritative. - const nextSeq = prev.seq + 1; - - return { seq: nextSeq, updatedAt: nextUpdatedAt }; -} +/** + * Serialised meta chain. + * + * Each call to setWithBumpedHeadersConfigMeta chains onto the previous promise so that + * the next seq/updatedAt is always derived from the most recently committed value — + * even when multiple saves are launched concurrently (e.g. rapid keystrokes or + * concurrent Effector effect chains). Without this serialisation two concurrent callers + * both read the same prev.seq from storage, compute the same next.seq, and the second + * write silently overwrites the first; the background script then sees the same meta on + * both storage.onChanged events and skips the second apply, leaving headers stuck. + */ +let _metaChain: Promise = browser.storage.local + .get([BrowserStorageKey.HeadersConfigMeta]) + .then(current => normalizeMeta(current[BrowserStorageKey.HeadersConfigMeta])); export async function setWithBumpedHeadersConfigMeta(patch: Record) { - const meta = await bumpHeadersConfigMeta(); - await browser.storage.local.set({ - ...patch, - [BrowserStorageKey.HeadersConfigMeta]: meta, + _metaChain = _metaChain.then(async prev => { + const now = Date.now(); + const meta: HeadersConfigMeta = { + seq: prev.seq + 1, + updatedAt: Math.max(now, prev.updatedAt + 1), + }; + await browser.storage.local.set({ + ...patch, + [BrowserStorageKey.HeadersConfigMeta]: meta, + }); + return meta; }); - return meta; + return _metaChain; } diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts index 1a8dc971..c0787bb4 100644 --- a/src/shared/utils/logger.ts +++ b/src/shared/utils/logger.ts @@ -48,28 +48,64 @@ function shouldLog(level: LogLevel): boolean { return levels.indexOf(level) >= levels.indexOf(currentConfig.minLevel); } +type LogSinkEntry = { + level: LogLevel; + message: string; + args: unknown[]; + timestamp: number; +}; + +type LogSink = (entry: LogSinkEntry) => void | Promise; + +let externalLogSink: LogSink | null = null; + +export function setExternalLogSink(sink: LogSink | null): void { + externalLogSink = sink; +} + +function emitToExternalSink(level: LogLevel, message: string, args: unknown[]): void { + if (!externalLogSink) return; + try { + const maybePromise = externalLogSink({ + level, + message, + args, + timestamp: Date.now(), + }); + if (maybePromise && typeof (maybePromise as Promise).then === 'function') { + (maybePromise as Promise).catch(() => {}); + } + } catch { + // ignore sink errors + } +} + export function logDebug(message: string, ...args: unknown[]): void { if (shouldLog(LogLevel.DEBUG)) { // eslint-disable-next-line no-console console.log(formatLogMessage(LogLevel.DEBUG, message), ...args); + emitToExternalSink(LogLevel.DEBUG, message, args); } } export function logInfo(message: string, ...args: unknown[]): void { if (shouldLog(LogLevel.INFO)) { console.info(formatLogMessage(LogLevel.INFO, message), ...args); + emitToExternalSink(LogLevel.INFO, message, args); } } export function logWarn(message: string, ...args: unknown[]): void { if (shouldLog(LogLevel.WARN)) { console.warn(formatLogMessage(LogLevel.WARN, message), ...args); + emitToExternalSink(LogLevel.WARN, message, args); } } export function logError(message: string, ...args: unknown[]): void { if (shouldLog(LogLevel.ERROR)) { console.error(formatLogMessage(LogLevel.ERROR, message), ...args); + emitToExternalSink(LogLevel.ERROR, message, args); } } @@ -102,4 +138,5 @@ export const logger = { configure: configureLogger, enable: enableLogger, disable: disableLogger, + setExternalSink: setExternalLogSink, }; diff --git a/src/shared/utils/setBrowserHeaders.ts b/src/shared/utils/setBrowserHeaders.ts index 90663751..7bed9f21 100644 --- a/src/shared/utils/setBrowserHeaders.ts +++ b/src/shared/utils/setBrowserHeaders.ts @@ -14,6 +14,10 @@ const MAX_RETRIES = 3; const RETRY_DELAY_MS = 200; const RECOVERY_DELAY_MS = 500; +// IDs >= COUNTERACT_BASE_ID are reserved for counteracting stuck dynamic rules. +// Header IDs are generated in range [0, 999_999_999) — see generateId.ts. +const COUNTERACT_BASE_ID = 1_000_000_000; + async function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -49,7 +53,15 @@ async function recoveryUpdateDynamicRules( }); logger.info('🔧 DNR Recovery: Rules removed successfully'); } catch (removeErr) { - logger.warn('🔧 DNR Recovery: Failed to remove rules, continuing anyway:', removeErr); + logger.warn('🔧 DNR Recovery: Batch removal failed, trying one-by-one:', removeErr); + for (const id of allIdsToRemove) { + try { + await browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [id], addRules: [] }); + logger.info('🔧 DNR Recovery: Removed rule:', id); + } catch (singleErr) { + logger.warn('🔧 DNR Recovery: Failed to remove rule:', { id, error: singleErr }); + } + } } } @@ -76,14 +88,25 @@ async function recoveryUpdateDynamicRules( logger.info('🔧 DNR Recovery: New rules added successfully'); } - // Step 6: Verify final state + // Step 6: Verify final state actually matches expected const finalRules = await browser.declarativeNetRequest.getDynamicRules(); + const expectedCount = addRules.length; + const actualCount = finalRules.length; logger.info('🔧 DNR Recovery: Final rules state:', { - count: finalRules.length, + count: actualCount, ids: finalRules.map(r => r.id), - expected: addRules.length, + expected: expectedCount, }); + if (actualCount !== expectedCount) { + return { + success: false, + error: new Error( + `DNR Recovery: rules count mismatch after recovery: got ${actualCount}, expected ${expectedCount}`, + ), + }; + } + return { success: true }; } catch (error) { logger.error('🔧 DNR Recovery: Recovery failed:', error); @@ -181,7 +204,15 @@ type SetBrowserHeadersMeta = { currentTabUrl?: string; }; -export async function setBrowserHeaders(result: Record, meta: SetBrowserHeadersMeta = {}) { +export type SetBrowserHeadersResult = { + /** Rule IDs that are still active in Chrome DNR but should have been removed (disabled headers) */ + stuckRuleIds: number[]; +}; + +export async function setBrowserHeaders( + result: Record, + meta: SetBrowserHeadersMeta = {}, +): Promise { const isPaused = result[BrowserStorageKey.IsPaused] as boolean; const currentTabUrl = meta.currentTabUrl; @@ -370,7 +401,13 @@ export async function setBrowserHeaders(result: Record, meta: S // Last resort: session rules fallback when Chrome's dynamic rules DB is corrupted if (lastError) { - const sessionResult = await sessionRulesFallback(removeSessionRuleIds, addRules); + // Exclude counteracting rules (id >= COUNTERACT_BASE_ID) from the fallback removal. + // Counteracting rules cancel stuck dynamic rules that couldn't be removed — if we pass + // them to sessionRulesFallback it will delete them while the stuck dynamic rules remain, + // opening a window where the disabled header is transmitted. They are cleaned up later + // (in the counteracting-rules section below) once we know the full stuck-rule picture. + const fallbackRemoveSessionRuleIds = removeSessionRuleIds.filter(id => id < COUNTERACT_BASE_ID); + const sessionResult = await sessionRulesFallback(fallbackRemoveSessionRuleIds, addRules); if (sessionResult.success) { usedSessionFallback = true; @@ -408,12 +445,18 @@ export async function setBrowserHeaders(result: Record, meta: S } const allActiveRules = [...updatedDynamicRules, ...updatedSessionRules]; + // Rules that are still active in Chrome but should have been removed (disabled headers stuck due to DNR API errors). + // Only check dynamic rules — session rules are entirely under our control (fallback + counteracting). + const expectedRuleIds = new Set(addRules.map(r => r.id)); + const stuckRuleIds = updatedDynamicRules.filter(r => !expectedRuleIds.has(r.id)).map(r => r.id); + logger.info('📊 Final DNR state:', { dynamicRulesCount: updatedDynamicRules.length, sessionRulesCount: updatedSessionRules.length, totalActiveRulesCount: allActiveRules.length, expectedRulesCount: addRules.length, match: allActiveRules.length === addRules.length, + stuckRuleIds, usedSessionFallback, activeRuleIds: allActiveRules.map(r => r.id), activeRulesDetails: allActiveRules.map(r => ({ @@ -437,7 +480,57 @@ export async function setBrowserHeaders(result: Record, meta: S const activeCookiesCount = urlMatchesCurrentTab ? activeCookies.length : 0; const activeRulesCount = countActiveHeadersForUrl(activeHeaders, activeUrlFilters, currentTabUrl) + activeCookiesCount; - await setIconBadge({ isPaused, activeRulesCount }); + await setIconBadge({ isPaused, activeRulesCount, hasDnrMismatch: stuckRuleIds.length > 0 }); + + // Counteract stuck dynamic rules that are injecting headers which should be disabled. + // On the next successful apply these session rules are cleaned up automatically because + // removeSessionRuleIds (computed from getSessionRules at the top) includes them. + const prevCounteractIds = updatedSessionRules.filter(r => r.id >= COUNTERACT_BASE_ID).map(r => r.id); + const counteractingRules: browser.DeclarativeNetRequest.Rule[] = []; + + if (stuckRuleIds.length > 0) { + const sessionRuleHeaderNames = new Set( + updatedSessionRules.flatMap(r => r.action.requestHeaders?.map(h => h.header) ?? []), + ); + for (let i = 0; i < stuckRuleIds.length; i++) { + const stuckRule = updatedDynamicRules.find(r => r.id === stuckRuleIds[i]); + if (!stuckRule?.action.requestHeaders) continue; + const headersToRemove = stuckRule.action.requestHeaders.filter( + h => h.operation === 'set' && !sessionRuleHeaderNames.has(h.header), + ); + if (headersToRemove.length === 0) continue; + counteractingRules.push({ + id: COUNTERACT_BASE_ID + i, + priority: 2, + action: { + type: 'modifyHeaders' as const, + requestHeaders: headersToRemove.map(h => ({ header: h.header, operation: 'remove' as const })), + }, + condition: stuckRule.condition, + }); + } + } + + if (prevCounteractIds.length > 0 || counteractingRules.length > 0) { + try { + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: prevCounteractIds, + addRules: counteractingRules, + }); + if (counteractingRules.length > 0) { + logger.info('🛡️ Added counteracting session rules for stuck dynamic rules:', { + count: counteractingRules.length, + headers: counteractingRules.flatMap(r => r.action.requestHeaders?.map(h => h.header) ?? []), + }); + } else if (prevCounteractIds.length > 0) { + logger.info('🧹 Cleaned up stale counteracting session rules'); + } + } catch (err) { + logger.warn('⚠️ Failed to update counteracting session rules:', err); + } + } + + return { stuckRuleIds }; } catch (err) { logger.error('Failed to update dynamic rules:', err); logger.groupEnd(); diff --git a/src/shared/utils/setIconBadge.ts b/src/shared/utils/setIconBadge.ts index 34a38b0c..38dbdce8 100644 --- a/src/shared/utils/setIconBadge.ts +++ b/src/shared/utils/setIconBadge.ts @@ -6,11 +6,14 @@ import { logger } from './logger'; type SetIconBadgeParams = { isPaused: boolean; activeRulesCount: number; + /** When true, badge background turns orange to warn that some DNR rules could not be applied */ + hasDnrMismatch?: boolean; }; -export async function setIconBadge({ isPaused, activeRulesCount }: SetIconBadgeParams) { +export async function setIconBadge({ isPaused, activeRulesCount, hasDnrMismatch = false }: SetIconBadgeParams) { const iconPath = isPaused ? 'img/paused-icon-38.png' : 'img/main-icon-38.png'; const badgeText = !isPaused && activeRulesCount > 0 ? activeRulesCount.toString() : ''; + const badgeColor = hasDnrMismatch ? '#f59e0b' : '#ffffff'; // Log extension info logger.debug('Extension info:', { @@ -20,9 +23,10 @@ export async function setIconBadge({ isPaused, activeRulesCount }: SetIconBadgeP badgeText, isPaused, activeRulesCount, + hasDnrMismatch, }); - logger.debug('Setting icon badge:', { isPaused, activeRulesCount, iconPath, badgeText }); + logger.debug('Setting icon badge:', { isPaused, activeRulesCount, iconPath, badgeText, hasDnrMismatch }); try { // In Chrome Manifest V3, use an object with icon sizes @@ -36,6 +40,7 @@ export async function setIconBadge({ isPaused, activeRulesCount }: SetIconBadgeP await browserAction.setIcon(iconDetails); await browserAction.setBadgeText({ text: badgeText }); + await browserAction.setBadgeBackgroundColor({ color: badgeColor }); logger.debug('Icon badge set successfully'); } catch (err) { logger.error('Error setting icon badge:', err); diff --git a/src/widgets/request-headers/RequestHeaders.tsx b/src/widgets/request-headers/RequestHeaders.tsx index 712b947d..a874b5b1 100644 --- a/src/widgets/request-headers/RequestHeaders.tsx +++ b/src/widgets/request-headers/RequestHeaders.tsx @@ -1,5 +1,6 @@ import { useUnit } from 'effector-react'; +import { $dnrHealth } from '#entities/dnr-health/model'; import { $isPaused } from '#entities/is-paused/model'; import { $selectedProfileRequestHeaders } from '#entities/request-profile/model/selected-request-headers'; import { useSortableList } from '#entities/sortable-list'; @@ -9,18 +10,26 @@ import { RequestHeaderRow } from './components/RequestHeaderRow'; import * as S from './styled'; export function RequestHeaders() { - const { isPaused, requestHeaders, onReorder } = useUnit({ + const { isPaused, requestHeaders, dnrHealth, onReorder } = useUnit({ isPaused: $isPaused, requestHeaders: $selectedProfileRequestHeaders, + dnrHealth: $dnrHealth, onReorder: requestHeadersReordered, }); + const stuckRuleIds = new Set(dnrHealth?.stuckRuleIds ?? []); + const { listRef, moveByKeyboard } = useSortableList({ disabled: isPaused, items: requestHeaders, onReorder }); return ( {requestHeaders.map(header => ( - moveByKeyboard(header.id, direction)} /> + moveByKeyboard(header.id, direction)} + /> ))} ); diff --git a/src/widgets/request-headers/components/RequestHeaderRow/RequestHeaderRow.tsx b/src/widgets/request-headers/components/RequestHeaderRow/RequestHeaderRow.tsx index 24d98b82..2d78bade 100644 --- a/src/widgets/request-headers/components/RequestHeaderRow/RequestHeaderRow.tsx +++ b/src/widgets/request-headers/components/RequestHeaderRow/RequestHeaderRow.tsx @@ -2,7 +2,8 @@ import { useUnit } from 'effector-react/effector-react.mjs'; import { type ClipboardEvent, type KeyboardEvent, useState } from 'react'; import { Button } from '@cloud-ru/ds-button'; -import { CrossSVG } from '@cloud-ru/ds-icons/interface/system'; +import { themeVars } from '@cloud-ru/ds-figma-variables'; +import { CrossSVG, WarningSVG } from '@cloud-ru/ds-icons/interface/system'; import { Checkbox, CheckboxProps } from '@cloud-ru/ds-toggles'; import { Tooltip } from '@cloud-ru/ds-tooltip'; @@ -20,9 +21,10 @@ import * as S from './styled'; type RequestHeaderRowProps = RequestHeader & { onMove: (direction: -1 | 1) => void; + isStuck?: boolean; }; -export function RequestHeaderRow({ onMove, ...props }: RequestHeaderRowProps) { +export function RequestHeaderRow({ onMove, isStuck, ...props }: RequestHeaderRowProps) { const { disabled, name, value, id } = props; const { isPaused, onRequestHeadersPasted, onRequestHeadersUpdated, onRequestHeadersRemoved } = useUnit({ isPaused: $isPaused, @@ -78,6 +80,17 @@ export function RequestHeaderRow({ onMove, ...props }: RequestHeaderRowProps) { /> + {isStuck && ( + + + + + + )} + 0 && !isNameFormatVerified} diff --git a/tests/e2e/dnr-warning.spec.ts b/tests/e2e/dnr-warning.spec.ts new file mode 100644 index 00000000..0af5e5cd --- /dev/null +++ b/tests/e2e/dnr-warning.spec.ts @@ -0,0 +1,172 @@ +import type { Page, Worker } from '@playwright/test'; + +import { expect, test } from './fixtures'; + +/** + * E2E tests verifying the DNR warning indicator feature. + * + * When Chrome's declarativeNetRequest API fails to apply some rules (e.g. due to + * an internal Chrome error), background.ts writes `dnrHealthV1` to storage with + * `ok: false` and a list of stuck rule IDs. The popup reads this value and shows: + * 1. An orange warning banner at the top of the content area. + * 2. A per-header warning icon next to any header whose rule ID is stuck. + * + * In these tests we write `dnrHealthV1` directly to storage (bypassing the real + * Chrome DNR error path) to drive the popup UI reactively. This is the same + * approach used in sw-restart.spec.ts – we test the observable behaviour, not + * the exact trigger. + * + * We set storage from the popup page (via page.evaluate) rather than the + * service worker so that storage.onChanged reliably fires in the same context + * that renders the banner – cross-context storage events can be flaky in CI. + */ +test.describe('DNR warning indicator', () => { + async function setDnrHealth(page: Page, health: { ok: boolean; stuckRuleIds: number[]; updatedAt: number }) { + await page.evaluate(async health => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const browser = (globalThis as any).chrome ?? (globalThis as any).browser; + await browser.storage.local.set({ dnrHealthV1: health }); + }, health); + } + + async function getDynamicRules(sw: Worker): Promise> { + const rules = await sw.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const browser = (globalThis as any).chrome ?? (globalThis as any).browser; + return browser.declarativeNetRequest.getDynamicRules(); + }); + return rules as Array<{ id: number }>; + } + + async function waitForRulesCount(sw: Worker, expectedCount: number, timeout = 5000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + const rules = await getDynamicRules(sw); + if (rules.length === expectedCount) return rules; + await new Promise(resolve => setTimeout(resolve, 100)); + } + const rules = await getDynamicRules(sw); + throw new Error(`Expected ${expectedCount} rules, got ${rules.length} after ${timeout}ms`); + } + + /** + * When `dnrHealthV1` is written with `ok: false`, the popup must reactively + * show the orange warning banner – without needing a page reload. + */ + test('shows warning banner when dnrHealth has stuck rules', async ({ page, extensionId }) => { + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const banner = page.locator('[data-test-id="dnr-warning-banner"]'); + + // No banner in healthy state + await expect(banner).not.toBeVisible(); + + // Simulate stuck rules by writing dnrHealth directly to storage + await setDnrHealth(page, { ok: false, stuckRuleIds: [99001], updatedAt: Date.now() }); + + // The popup subscribes to storage.onChanged – banner should appear without reload + await expect(banner).toBeVisible({ timeout: 5000 }); + }); + + /** + * When `dnrHealthV1` is updated to `ok: true`, the warning banner must + * disappear reactively. + */ + test('hides warning banner when dnrHealth recovers', async ({ page, extensionId }) => { + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + // First put the popup into warning state + await setDnrHealth(page, { ok: false, stuckRuleIds: [99002], updatedAt: Date.now() }); + + const banner = page.locator('[data-test-id="dnr-warning-banner"]'); + await expect(banner).toBeVisible({ timeout: 5000 }); + + // Recover – banner must disappear reactively + await setDnrHealth(page, { ok: true, stuckRuleIds: [], updatedAt: Date.now() }); + + await expect(banner).not.toBeVisible({ timeout: 5000 }); + }); + + /** + * The per-header warning icon must appear on the row whose rule ID is in + * `stuckRuleIds`, and must be absent for other rows. + * + * Scenario: + * 1. Add an enabled header → DNR rule created (rule.id = header.id). + * 2. Write dnrHealth with that rule ID as stuck. + * 3. Assert: warning icon visible on the header row. + * 4. Recover dnrHealth. + * 5. Assert: warning icon gone. + */ + test('shows per-header warning icon for stuck rule ID', async ({ context, page, extensionId }) => { + const sw = context.serviceWorkers()[0]; + + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + // Create a header so we have a known rule ID + const addHeaderButton = page.locator('[data-test-id="add-request-header-button"]'); + await addHeaderButton.click(); + await page.waitForTimeout(300); + + const headerNameField = page.locator('[data-test-id="header-name-input"] input').first(); + const headerValueField = page.locator('[data-test-id="header-value-input"] input').first(); + await headerNameField.fill('X-Warning-Test'); + await headerValueField.fill('warn-value'); + + // Wait for the DNR rule to appear so we can read its ID + const rules = await waitForRulesCount(sw, 1); + const stuckRuleId = rules[0].id; + + // No warning icon initially + const warningIcon = page.locator('[data-test-id="header-stuck-warning"]'); + await expect(warningIcon).not.toBeVisible(); + + // Mark this header's rule as stuck + await setDnrHealth(page, { ok: false, stuckRuleIds: [stuckRuleId], updatedAt: Date.now() }); + + // Warning icon must appear on the header row + await expect(warningIcon).toBeVisible({ timeout: 5000 }); + + // Recover – icon must disappear + await setDnrHealth(page, { ok: true, stuckRuleIds: [], updatedAt: Date.now() }); + await expect(warningIcon).not.toBeVisible({ timeout: 5000 }); + }); + + /** + * Warning icon must NOT appear for a header whose rule ID is not in stuckRuleIds, + * even when another rule is stuck. + */ + test('does not show warning icon for healthy headers when other rules are stuck', async ({ + context, + page, + extensionId, + }) => { + const sw = context.serviceWorkers()[0]; + + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + // Create an enabled header + const addHeaderButton = page.locator('[data-test-id="add-request-header-button"]'); + await addHeaderButton.click(); + await page.waitForTimeout(300); + + const headerNameField = page.locator('[data-test-id="header-name-input"] input').first(); + const headerValueField = page.locator('[data-test-id="header-value-input"] input').first(); + await headerNameField.fill('X-Healthy-Header'); + await headerValueField.fill('healthy-value'); + + await waitForRulesCount(sw, 1); + + // Report a *different* rule ID as stuck (not the one belonging to our header) + const unrelatedStuckId = 999888; + await setDnrHealth(page, { ok: false, stuckRuleIds: [unrelatedStuckId], updatedAt: Date.now() }); + + // Banner appears (global mismatch), but our header's row has no warning icon + await expect(page.locator('[data-test-id="dnr-warning-banner"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('[data-test-id="header-stuck-warning"]')).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/screenshot-tests/factories/screenshot-test.factory.ts b/tests/e2e/screenshot-tests/factories/screenshot-test.factory.ts index c8cbd7e1..5b12acd9 100644 --- a/tests/e2e/screenshot-tests/factories/screenshot-test.factory.ts +++ b/tests/e2e/screenshot-tests/factories/screenshot-test.factory.ts @@ -5,6 +5,7 @@ import { getSnapshotName } from '../utils'; type SnapshotOptions = { maxDiffPixels?: number; + maxDiffPixelRatio?: number; timeout?: number; threshold?: number; }; diff --git a/tests/e2e/screenshot-tests/specs/profiles.screenshots.ts b/tests/e2e/screenshot-tests/specs/profiles.screenshots.ts index 8b9b36ae..5a19ecc5 100644 --- a/tests/e2e/screenshot-tests/specs/profiles.screenshots.ts +++ b/tests/e2e/screenshot-tests/specs/profiles.screenshots.ts @@ -48,4 +48,8 @@ createScreenshotTest({ await popup.headersTab.activate(); await popup.sidebar.openProfileActionsMenu(); }, + snapshotOptions: { + // Dropdown menus have subtle rendering differences across platforms (fonts, shadows, anti-aliasing) + maxDiffPixelRatio: 0.02, + }, }); From 5f71a536d98340ef920e794b2ae8c2130d1554de Mon Sep 17 00:00:00 2001 From: Kirill Lebedenko Date: Mon, 2 Nov 2026 18:46:51 +0000 Subject: [PATCH 4/4] feat: open extension settings on the browser options page --- manifest.chromium.json | 4 + manifest.dev.json | 4 + manifest.firefox.json | 4 + scripts/check-extension-builds.mjs | 9 + scripts/firefox-e2e.mjs | 18 +- scripts/firefox-screenshots.mjs | 46 +++-- src/app/App.tsx | 23 +-- src/app/OptionsApp.tsx | 21 +++ src/app/ThemeRoot.tsx | 29 ++++ src/app/options.css | 6 + src/app/options.styled.ts | 17 ++ src/background.ts | 40 ++++- .../export-debug-logs/__tests__/model.spec.ts | 120 +++++++++++++ src/features/export-debug-logs/index.ts | 1 + src/features/export-debug-logs/model.ts | 82 +++++++++ .../utils/__tests__/downloadDebugLogs.spec.ts | 17 ++ .../utils/downloadDebugLogs.ts | 29 ++++ src/features/export-debug-logs/utils/index.ts | 1 + src/index.tsx | 40 +---- src/mountExtensionPage.tsx | 35 ++++ src/options.html | 12 ++ src/options.tsx | 6 + .../utils/__tests__/debugLogsExport.spec.ts | 84 ++++++++++ src/shared/utils/debugLogsExport.ts | 157 ++++++++++++++++++ src/shared/utils/setIconBadge.ts | 54 ++++-- src/widgets/settings/Settings.tsx | 113 +++++++++++++ src/widgets/settings/index.ts | 1 + src/widgets/settings/styled.ts | 47 ++++++ src/widgets/sidebar/Sidebar.tsx | 26 ++- tests/e2e/dnr-warning.spec.ts | 15 ++ .../config/screenshot.config.ts | 2 + .../factories/screenshot-test.factory.ts | 1 - .../page-objects/popup.page.ts | 13 ++ .../specs/profiles.screenshots.ts | 4 - .../specs/settings.screenshots.ts | 10 ++ .../specs/url-filters.screenshots.ts | 3 +- .../cookies-all-disabled-dark.png | Bin 30661 -> 31475 bytes .../cookies-all-disabled-light.png | Bin 31312 -> 32189 bytes .../cookies-cookie-menu-open-dark.png | Bin 30737 -> 31550 bytes .../cookies-cookie-menu-open-light.png | Bin 32785 -> 33622 bytes .../cookies-disabled-cookie-dark.png | Bin 27165 -> 27950 bytes .../cookies-disabled-cookie-light.png | Bin 27810 -> 28577 bytes .../cookies-empty-state-dark.png | Bin 22516 -> 23340 bytes .../cookies-empty-state-light.png | Bin 23060 -> 23874 bytes .../cookies-multiple-cookies-dark.png | Bin 30705 -> 31523 bytes .../cookies-multiple-cookies-light.png | Bin 31010 -> 31872 bytes .../cookies-name-validation-error-dark.png | Bin 26477 -> 27262 bytes .../cookies-name-validation-error-light.png | Bin 27006 -> 27798 bytes .../cookies-single-cookie-dark.png | Bin 27768 -> 28568 bytes .../cookies-single-cookie-light.png | Bin 28135 -> 28919 bytes .../cookies-value-validation-error-dark.png | Bin 30726 -> 31544 bytes .../cookies-value-validation-error-light.png | Bin 31263 -> 32148 bytes .../general-empty-popup-dark.png | Bin 20951 -> 21782 bytes .../general-empty-popup-light.png | Bin 21510 -> 22322 bytes .../general-paused-state-dark.png | Bin 20045 -> 20902 bytes .../general-paused-state-light.png | Bin 20658 -> 21503 bytes .../general-theme-menu-open-dark.png | Bin 20924 -> 21759 bytes .../general-theme-menu-open-light.png | Bin 21492 -> 22307 bytes .../headers-all-disabled-dark.png | Bin 27700 -> 28499 bytes .../headers-all-disabled-light.png | Bin 28347 -> 29124 bytes .../headers-disabled-header-dark.png | Bin 27202 -> 27993 bytes .../headers-disabled-header-light.png | Bin 27860 -> 28634 bytes .../headers-empty-state-dark.png | Bin 20951 -> 21782 bytes .../headers-empty-state-light.png | Bin 21510 -> 22322 bytes .../headers-header-menu-open-dark.png | Bin 28867 -> 29629 bytes .../headers-header-menu-open-light.png | Bin 30659 -> 31519 bytes .../headers-multiple-headers-dark.png | Bin 31379 -> 32252 bytes .../headers-multiple-headers-light.png | Bin 31775 -> 32632 bytes .../headers-single-header-dark.png | Bin 27795 -> 28593 bytes .../headers-single-header-light.png | Bin 28198 -> 28972 bytes .../headers-validation-error-dark.png | Bin 26085 -> 26864 bytes .../headers-validation-error-light.png | Bin 26640 -> 27417 bytes ...odals-cookies-delete-confirmation-dark.png | Bin 37675 -> 38426 bytes ...dals-cookies-delete-confirmation-light.png | Bin 38617 -> 39358 bytes .../modals-delete-confirmation-dark.png | Bin 33870 -> 34635 bytes .../modals-delete-confirmation-light.png | Bin 34293 -> 35057 bytes .../modals-export-modal-dark.png | Bin 38326 -> 39097 bytes .../modals-export-modal-light.png | Bin 40007 -> 40775 bytes .../modals-import-from-extension-dark.png | Bin 23890 -> 24685 bytes .../modals-import-from-extension-light.png | Bin 24341 -> 25132 bytes .../modals-import-modal-dark.png | Bin 21255 -> 22024 bytes .../modals-import-modal-light.png | Bin 21787 -> 22545 bytes .../profiles-multiple-profiles-dark.png | Bin 23159 -> 23980 bytes .../profiles-multiple-profiles-light.png | Bin 23198 -> 24007 bytes .../profiles-profile-actions-menu-dark.png | Bin 29470 -> 30272 bytes .../profiles-profile-actions-menu-light.png | Bin 31895 -> 32799 bytes .../profiles-profile-editing-name-dark.png | Bin 20740 -> 21573 bytes .../profiles-profile-editing-name-light.png | Bin 21280 -> 22091 bytes .../profiles-profile-selected-dark.png | Bin 23159 -> 23980 bytes .../profiles-profile-selected-light.png | Bin 23198 -> 24007 bytes .../profiles-single-profile-dark.png | Bin 20951 -> 21782 bytes .../profiles-single-profile-light.png | Bin 21510 -> 22322 bytes .../settings-settings-page-dark.png | Bin 0 -> 33435 bytes .../settings-settings-page-light.png | Bin 0 -> 33827 bytes .../url-filters-disabled-filter-dark.png | Bin 23273 -> 24088 bytes .../url-filters-disabled-filter-light.png | Bin 23869 -> 24688 bytes .../url-filters-empty-state-dark.png | Bin 20460 -> 21288 bytes .../url-filters-empty-state-light.png | Bin 20979 -> 21794 bytes .../url-filters-filter-menu-open-dark.png | Bin 28681 -> 29435 bytes .../url-filters-filter-menu-open-light.png | Bin 30361 -> 31178 bytes .../url-filters-multiple-filters-dark.png | Bin 29034 -> 29801 bytes .../url-filters-multiple-filters-light.png | Bin 29548 -> 30368 bytes .../url-filters-single-filter-dark.png | Bin 22970 -> 23786 bytes .../url-filters-single-filter-light.png | Bin 23073 -> 23888 bytes tests/e2e/screenshots.spec.ts | 1 + .../general-theme-menu-open-dark.png | Bin 20618 -> 21365 bytes .../general-theme-menu-open-light.png | Bin 20261 -> 21016 bytes .../settings-settings-page-dark.png | Bin 0 -> 24541 bytes .../settings-settings-page-light.png | Bin 0 -> 23377 bytes tests/e2e/settings.spec.ts | 85 ++++++++++ vite.config.ts | 1 + 111 files changed, 1082 insertions(+), 99 deletions(-) create mode 100644 src/app/OptionsApp.tsx create mode 100644 src/app/ThemeRoot.tsx create mode 100644 src/app/options.css create mode 100644 src/app/options.styled.ts create mode 100644 src/features/export-debug-logs/__tests__/model.spec.ts create mode 100644 src/features/export-debug-logs/index.ts create mode 100644 src/features/export-debug-logs/model.ts create mode 100644 src/features/export-debug-logs/utils/__tests__/downloadDebugLogs.spec.ts create mode 100644 src/features/export-debug-logs/utils/downloadDebugLogs.ts create mode 100644 src/features/export-debug-logs/utils/index.ts create mode 100644 src/mountExtensionPage.tsx create mode 100644 src/options.html create mode 100644 src/options.tsx create mode 100644 src/shared/utils/__tests__/debugLogsExport.spec.ts create mode 100644 src/shared/utils/debugLogsExport.ts create mode 100644 src/widgets/settings/Settings.tsx create mode 100644 src/widgets/settings/index.ts create mode 100644 src/widgets/settings/styled.ts create mode 100644 tests/e2e/screenshot-tests/specs/settings.screenshots.ts create mode 100644 tests/e2e/screenshots.firefox.spec.ts-snapshots/settings-settings-page-dark.png create mode 100644 tests/e2e/screenshots.firefox.spec.ts-snapshots/settings-settings-page-light.png create mode 100644 tests/e2e/screenshots.spec.ts-snapshots/settings-settings-page-dark.png create mode 100644 tests/e2e/screenshots.spec.ts-snapshots/settings-settings-page-light.png create mode 100644 tests/e2e/settings.spec.ts diff --git a/manifest.chromium.json b/manifest.chromium.json index 2c9982f5..db1bdebd 100644 --- a/manifest.chromium.json +++ b/manifest.chromium.json @@ -10,6 +10,10 @@ }, "default_title": "Modify Outgoing XHR/Fetch Request Headers" }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, "icons": { "16": "img/main-icon-16.jpg", "32": "img/main-icon-32.jpg", diff --git a/manifest.dev.json b/manifest.dev.json index c654b9e5..f4d0a961 100644 --- a/manifest.dev.json +++ b/manifest.dev.json @@ -9,6 +9,10 @@ }, "default_title": "Modify Outgoing XHR/Fetch Request Headers" }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, "icons": { "16": "img/main-icon-16.jpg", "32": "img/main-icon-32.jpg", diff --git a/manifest.firefox.json b/manifest.firefox.json index d1cd8743..d7753f26 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -9,6 +9,10 @@ }, "default_title": "Modify Outgoing XHR/Fetch Request Headers" }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, "icons": { "16": "img/main-icon-16.jpg", "32": "img/main-icon-32.jpg", diff --git a/scripts/check-extension-builds.mjs b/scripts/check-extension-builds.mjs index fd7df055..e2c06c11 100644 --- a/scripts/check-extension-builds.mjs +++ b/scripts/check-extension-builds.mjs @@ -27,11 +27,20 @@ function readManifest(browser) { function checkCommonBuild(browser, manifest) { const backgroundPath = resolve(rootDir, `build/${browser}/background.bundle.js`); + const optionsPath = resolve(rootDir, `build/${browser}/options.html`); if (!existsSync(backgroundPath)) { errors.push(`Missing build/${browser}/background.bundle.js.`); } + if (!existsSync(optionsPath)) { + errors.push(`Missing build/${browser}/options.html.`); + } + + if (manifest?.options_ui?.page !== 'options.html') { + errors.push(`${browser} manifest must register options_ui.page as options.html.`); + } + if (manifest?.manifest_version !== 3) { errors.push(`${browser} manifest must use manifest_version 3.`); } diff --git a/scripts/firefox-e2e.mjs b/scripts/firefox-e2e.mjs index 6a13896a..e9c8bc09 100644 --- a/scripts/firefox-e2e.mjs +++ b/scripts/firefox-e2e.mjs @@ -209,6 +209,14 @@ async function waitForValue(browser, selector, expected, index = 0) { await waitUntil(async () => (await browser.value(selector, index)) === expected, `${selector} value "${expected}"`); } +async function waitForStoredText(browser, text) { + await waitUntil(async () => { + const data = await browser.storage(); + const profiles = data[STORAGE_KEYS.profiles]; + return typeof profiles === 'string' && profiles.includes(text); + }, `storage to contain "${text}"`); +} + async function waitForCount(browser, selector, expected) { await waitUntil(async () => (await browser.count(selector)) === expected, `${selector} count ${expected}`); } @@ -366,6 +374,8 @@ async function main() { async () => { await browser.fill(selectors.headerNameInput, 'X-Persisted'); await browser.fill(selectors.headerValueInput, 'persisted-value'); + await waitForStoredText(browser, 'X-Persisted'); + await waitForStoredText(browser, 'persisted-value'); await browser.refresh(); await waitForValue(browser, selectors.headerNameInput, 'X-Persisted'); await waitForValue(browser, selectors.headerValueInput, 'persisted-value'); @@ -424,6 +434,7 @@ async function main() { async () => { await browser.clickTab('URL Filters'); await browser.fill(selectors.urlFilterInput, 'https://persisted.example.com/*'); + await waitForStoredText(browser, 'https://persisted.example.com/*'); await browser.refresh(); await browser.clickTab('URL Filters'); await waitForValue(browser, selectors.urlFilterInput, 'https://persisted.example.com/*'); @@ -526,6 +537,7 @@ async function main() { assert.equal(await browser.count(selectors.urlFilterInput), 0); await browser.click(selectors.addUrlFilterButton); await browser.fill(selectors.urlFilterInput, 'https://legacy.example.com/*'); + await waitForStoredText(browser, 'https://legacy.example.com/*'); await browser.refresh(); await browser.clickTab('URL Filters'); await waitForValue(browser, selectors.urlFilterInput, 'https://legacy.example.com/*'); @@ -536,7 +548,8 @@ async function main() { async () => { await browser.fill(selectors.headerNameInput, 'X-Cloudhood-Firefox'); await browser.fill(selectors.headerValueInput, 'active'); - await waitUntil(async () => (await browser.dynamicRules()).length === 1, 'one Firefox DNR rule'); + await waitForStoredText(browser, 'X-Cloudhood-Firefox'); + await waitUntil(async () => (await browser.dynamicRules()).length === 1, 'one Firefox DNR rule', 15_000); await expectEchoedHeader(driver, `${echoServer.url}/active`, 'X-Cloudhood-Firefox', 'active'); await browser.open(); await browser.click(selectors.pauseButton); @@ -554,7 +567,8 @@ async function main() { await browser.fill(selectors.headerValueInput, 'matched'); await browser.clickTab('URL Filters'); await browser.fill(selectors.urlFilterInput, `${echoServer.url}/matched*`); - await waitUntil(async () => (await browser.dynamicRules()).length === 1, 'one filtered Firefox DNR rule'); + await waitForStoredText(browser, `${echoServer.url}/matched*`); + await waitUntil(async () => (await browser.dynamicRules()).length === 1, 'one filtered Firefox DNR rule', 15_000); await expectEchoedHeader(driver, `${echoServer.url}/matched-path`, 'X-Cloudhood-Filtered', 'matched'); await expectEchoedHeader(driver, `${echoServer.url}/other-path`, 'X-Cloudhood-Filtered', undefined); }, diff --git a/scripts/firefox-screenshots.mjs b/scripts/firefox-screenshots.mjs index 7c4b71fa..07e8d63a 100644 --- a/scripts/firefox-screenshots.mjs +++ b/scripts/firefox-screenshots.mjs @@ -38,6 +38,8 @@ const selectors = { urlFilterMenuButton: '[data-test-id="url-filter-menu-button"]', pauseButton: '[data-test-id="pause-button"]', themeToggle: '[data-test-id="theme-toggle-button"]', + settingsButton: '[data-test-id="settings-button"]', + settingsPage: '[data-test-id="settings-page"]', profileActionsButton: '[data-test-id="profile-actions-menu-button"]', addProfileButton: '[data-test-id="add-profile-button"]', profileSelect: '[data-test-id="profile-select"]', @@ -56,6 +58,9 @@ const scenarios = [ scenario('general', 'theme-menu-open', async ({ click }) => { await click(selectors.themeToggle); }), + scenario('settings', 'settings-page', async ({ openOptions }) => { + await openOptions(); + }), scenario('headers', 'empty-state', async ({ activateHeaders }) => { await activateHeaders(); }), @@ -150,12 +155,16 @@ const scenarios = [ await removeAllUrlFilters(); await waitText('Remove all URL filters'); }), - scenario('modals', 'cookies-delete-confirmation', async ({ activateCookies, addCookie, removeAllCookies, waitText }) => { - await activateCookies(); - await addCookie('session_id', 'abc123'); - await removeAllCookies(); - await waitText('Remove all request cookies'); - }), + scenario( + 'modals', + 'cookies-delete-confirmation', + async ({ activateCookies, addCookie, removeAllCookies, waitText }) => { + await activateCookies(); + await addCookie('session_id', 'abc123'); + await removeAllCookies(); + await waitText('Remove all request cookies'); + }, + ), scenario('profiles', 'single-profile', async ({ activateHeaders }) => { await activateHeaders(); }), @@ -213,7 +222,7 @@ async function main() { try { const browser = createBrowserHelpers(driver, popupUrl); const selectedScenarios = scenarios - .flatMap((item) => ['light', 'dark'].map((theme) => ({ ...item, theme }))) + .flatMap(item => ['light', 'dark'].map(theme => ({ ...item, theme }))) .filter(({ area, name, theme }) => !FILTER || `${area}-${name}-${theme}`.includes(FILTER)); if (selectedScenarios.length === 0) { @@ -236,7 +245,7 @@ async function main() { } function createBrowserHelpers(driver, popupUrl) { - const elements = async (selector) => driver.findElements(By.css(selector)); + const elements = async selector => driver.findElements(By.css(selector)); const element = async (selector, index = 0) => { const matches = await elements(selector); if (!matches[index]) { @@ -266,21 +275,21 @@ function createBrowserHelpers(driver, popupUrl) { await waitUntil(() => visible(selector, index), `${selector} to become visible`); await (await element(selector, index)).sendKeys(Key.TAB); }; - const waitText = async (text) => { + const waitText = async text => { await waitUntil(async () => { const body = await driver.findElement(By.css('body')); return (await body.getText()).includes(text); }, `text "${text}"`); }; - const clickMenuItem = async (text) => { + const clickMenuItem = async text => { const xpath = `//*[@role="menuitem" and contains(normalize-space(.), "${text}")]`; await waitUntil(async () => { const matches = await driver.findElements(By.xpath(xpath)); - return matches.length > 0 && await matches[0].isDisplayed(); + return matches.length > 0 && (await matches[0].isDisplayed()); }, `menu item "${text}"`); await (await driver.findElements(By.xpath(xpath)))[0].click(); }; - const clickTab = async (text) => { + const clickTab = async text => { const xpath = `//*[@role="tab" and contains(normalize-space(.), "${text}")]`; await waitUntil(async () => (await driver.findElements(By.xpath(xpath))).length > 0, `tab "${text}"`); await (await driver.findElements(By.xpath(xpath)))[0].click(); @@ -312,6 +321,15 @@ function createBrowserHelpers(driver, popupUrl) { click, clickMenuItem, fill, + openOptions: async () => { + const optionsUrl = popupUrl.replace(/popup\.html$/, 'options.html'); + await driver.get(optionsUrl); + await waitUntil(() => visible(selectors.settingsPage), 'settings page'); + await waitUntil(async () => { + const className = await driver.findElement(By.css('body')).getAttribute('class'); + return className.includes('light') || className.includes('dark'); + }, 'options theme'); + }, removeAllCookies: async () => { await click(selectors.removeAllCookiesButton); }, @@ -333,7 +351,7 @@ function createBrowserHelpers(driver, popupUrl) { await driver.navigate().refresh(); await waitUntil(() => visible(selectors.headerNameInput), 'popup to reload'); }, - setTheme: async (theme) => { + setTheme: async theme => { await click(selectors.themeToggle); await clickMenuItem(theme === 'light' ? 'Light' : 'Dark'); await waitUntil( @@ -382,7 +400,7 @@ async function assertScreenshot(driver, snapshotName) { } } -main().catch((error) => { +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/src/app/App.tsx b/src/app/App.tsx index f1e4caf6..92b56a07 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,29 +2,14 @@ import '@cloud-ru/ds-figma-variables/build/css/tokens.css'; import './styles.css'; -import { useUnit } from 'effector-react'; -import { useEffect, useRef } from 'react'; - -import { BRAND, DENSITY, PLATFORM, RootThemeProvider } from '@cloud-ru/ds-theme'; - -import { $colorScheme } from '#entities/themeMode/model'; import { MainPage } from '#pages/main'; -import { initApp } from '#shared/model'; -export function App() { - const [handleInitApp, colorScheme] = useUnit([initApp, $colorScheme]); - const bodyRef = useRef(document.body); - - useEffect(() => { - handleInitApp(); - }, [handleInitApp]); +import { ThemeRoot } from './ThemeRoot'; +export function App() { return ( - + - + ); } diff --git a/src/app/OptionsApp.tsx b/src/app/OptionsApp.tsx new file mode 100644 index 00000000..accb55ab --- /dev/null +++ b/src/app/OptionsApp.tsx @@ -0,0 +1,21 @@ +import '@cloud-ru/ds-figma-variables/build/css/tokens.css'; + +import './styles.css'; +import './options.css'; + +import { Settings } from '#widgets/settings'; + +import * as S from './options.styled'; +import { ThemeRoot } from './ThemeRoot'; + +export function OptionsApp() { + return ( + + + + + + + + ); +} diff --git a/src/app/ThemeRoot.tsx b/src/app/ThemeRoot.tsx new file mode 100644 index 00000000..85801446 --- /dev/null +++ b/src/app/ThemeRoot.tsx @@ -0,0 +1,29 @@ +import { useUnit } from 'effector-react'; +import { ReactNode, useEffect, useRef } from 'react'; + +import { BRAND, DENSITY, PLATFORM, RootThemeProvider } from '@cloud-ru/ds-theme'; + +import { $colorScheme } from '#entities/themeMode/model'; +import { initApp } from '#shared/model'; + +type ThemeRootProps = { + children: ReactNode; +}; + +export function ThemeRoot({ children }: ThemeRootProps) { + const [handleInitApp, colorScheme] = useUnit([initApp, $colorScheme]); + const bodyRef = useRef(document.body); + + useEffect(() => { + handleInitApp(); + }, [handleInitApp]); + + return ( + + {children} + + ); +} diff --git a/src/app/options.css b/src/app/options.css new file mode 100644 index 00000000..bbd1ce2b --- /dev/null +++ b/src/app/options.css @@ -0,0 +1,6 @@ +body.options-page, +body.options-page #app-container { + width: 100%; + height: auto; + min-height: 100vh; +} diff --git a/src/app/options.styled.ts b/src/app/options.styled.ts new file mode 100644 index 00000000..14a52264 --- /dev/null +++ b/src/app/options.styled.ts @@ -0,0 +1,17 @@ +import styled from '@emotion/styled'; + +import { themeVars } from '@cloud-ru/ds-figma-variables'; + +export const Page = styled.main` + display: flex; + justify-content: center; + min-height: 100vh; + background: ${themeVars.sn.theme.color.neutral.background1Level}; + color: ${themeVars.sn.theme.color.available.version.textMain}; +`; + +export const Panel = styled.div` + width: 100%; + max-width: 480px; + padding: 8px 0 32px; +`; diff --git a/src/background.ts b/src/background.ts index 04f06fd4..055eef98 100644 --- a/src/background.ts +++ b/src/background.ts @@ -5,6 +5,11 @@ import type { Profile, RequestHeader } from '#entities/request-profile/types'; import { BrowserStorageKey, RuntimeMessageType } from './shared/constants'; import { validateCookie } from './shared/utils/cookies'; import { countActiveHeadersForUrl, doesUrlMatchFilter } from './shared/utils/countActiveHeadersForUrl'; +import { + filterDebugLogsForProfile, + parseProfileFromUnknown, + parseProfilesFromStorage, +} from './shared/utils/debugLogsExport'; import { validateHeader } from './shared/utils/headers'; import { logger, LogLevel } from './shared/utils/logger'; import { setBrowserCookies } from './shared/utils/setBrowserCookies'; @@ -151,7 +156,7 @@ function getApplyHealthSnapshot() { }; } -async function buildDebugLogsExportPayload() { +async function buildDebugLogsExportPayload(options?: { profileId?: string; profile?: unknown }) { const now = Date.now(); const storage = await browser.storage.local.get([ BrowserStorageKey.Profiles, @@ -173,8 +178,9 @@ async function buildDebugLogsExportPayload() { sessionRules = []; } - return { + const payload = { exportedAt: new Date(now).toISOString(), + scope: 'general' as const, worker: { bootId: workerBootId, bootedAt: new Date(workerBootAt).toISOString(), @@ -190,6 +196,20 @@ async function buildDebugLogsExportPayload() { }, logs: debugLogsBuffer, }; + + if (!options) { + return payload; + } + + const profileId = options.profileId; + const profile = + parseProfileFromUnknown(options.profile) ?? parseProfilesFromStorage(storage).find(item => item.id === profileId); + + if (!profile) { + throw new Error(`Profile "${profileId ?? ''}" not found`); + } + + return filterDebugLogsForProfile(payload, profile); } function storageFingerprint(result: Record): string { @@ -462,9 +482,11 @@ browser.runtime.onStartup.addListener(async function () { browser.runtime.onMessage.addListener((message: unknown) => { if (!message || typeof message !== 'object') return undefined; - const payload = message as { type?: string }; + const payload = message as { type?: string; scope?: unknown; profileId?: unknown; profile?: unknown }; if (payload.type !== RuntimeMessageType.ExportDebugLogs) return undefined; - return buildDebugLogsExportPayload() + const isProfileScope = payload.scope === 'profile'; + const profileId = typeof payload.profileId === 'string' ? payload.profileId : undefined; + return buildDebugLogsExportPayload(isProfileScope ? { profileId, profile: payload.profile } : undefined) .then(result => ({ ok: true, result })) .catch(error => ({ ok: false, error: safeStringify(error) })); }); @@ -504,6 +526,16 @@ browser.storage.onChanged.addListener(async (changes, areaName) => { }, }); + // storage.local.clear() removes meta. The next popup starts seq from 0, which looks + // older than the cursor kept in this long-lived background script. Reset the cursor + // so the fresh configuration is applied instead of skipped as stale. + const metaChange = changes[BrowserStorageKey.HeadersConfigMeta]; + if (metaChange && metaChange.newValue === undefined) { + lastAppliedMeta = { seq: 0, updatedAt: 0 }; + lastAppliedStorageFingerprint = null; + logger.info('Headers config meta removed, resetting apply cursor'); + } + await applyHeadersFromStorageQueue('storage.onChanged'); } } diff --git a/src/features/export-debug-logs/__tests__/model.spec.ts b/src/features/export-debug-logs/__tests__/model.spec.ts new file mode 100644 index 00000000..fae440f2 --- /dev/null +++ b/src/features/export-debug-logs/__tests__/model.spec.ts @@ -0,0 +1,120 @@ +import { allSettled, fork } from 'effector'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import browser from 'webextension-polyfill'; + +import { $requestProfiles, $selectedRequestProfile } from '#entities/request-profile/model'; + +import { generalDebugLogsExported, profileDebugLogsExported } from '../model'; +import { downloadDebugLogs } from '../utils'; + +vi.mock('webextension-polyfill', () => ({ + default: { + runtime: { + sendMessage: vi.fn(), + }, + storage: { + local: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined), + }, + onChanged: { + addListener: vi.fn(), + }, + }, + }, +})); + +vi.mock('#entities/notification/utils', () => ({ + showToast: vi.fn(), +})); + +vi.mock('../utils', () => ({ + downloadDebugLogs: vi.fn(), +})); + +const sendMessage = browser.runtime.sendMessage as unknown as ReturnType; + +describe('export-debug-logs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('downloads general debug logs', async () => { + const payload = { scope: 'general', logs: [{ message: 'hello' }] }; + sendMessage.mockResolvedValue({ ok: true, result: payload }); + + const scope = fork(); + await allSettled(generalDebugLogsExported, { scope }); + + expect(sendMessage).toHaveBeenCalledWith({ + type: 'export-debug-logs', + scope: 'general', + profileId: undefined, + profile: undefined, + }); + expect(downloadDebugLogs).toHaveBeenCalledWith(payload); + }); + + it('downloads profile debug logs for the selected profile', async () => { + const selectedProfile = { + id: 'profile-1', + name: 'Work', + requestHeaders: [], + requestCookies: [], + urlFilters: [], + }; + const payload = { scope: 'profile', profileId: 'profile-1' }; + sendMessage.mockResolvedValue({ ok: true, result: payload }); + + const scope = fork({ + values: [ + [$selectedRequestProfile, 'profile-1'], + [$requestProfiles, [selectedProfile]], + ], + }); + await allSettled(profileDebugLogsExported, { scope }); + + expect(sendMessage).toHaveBeenCalledWith({ + type: 'export-debug-logs', + scope: 'profile', + profileId: 'profile-1', + profile: selectedProfile, + }); + expect(downloadDebugLogs).toHaveBeenCalledWith(payload); + }); + + it('does not download logs when the background response is unsuccessful', async () => { + sendMessage.mockResolvedValue({ ok: false, error: 'boom' }); + + const scope = fork(); + await allSettled(generalDebugLogsExported, { scope }); + + expect(downloadDebugLogs).not.toHaveBeenCalled(); + }); + + it('falls back to the first profile when none is selected', async () => { + const fallbackProfile = { + id: 'profile-2', + name: 'Fallback', + requestHeaders: [], + requestCookies: [], + urlFilters: [], + }; + sendMessage.mockResolvedValue({ ok: true, result: { scope: 'profile' } }); + + const scope = fork({ + values: [ + [$selectedRequestProfile, ''], + [$requestProfiles, [fallbackProfile]], + ], + }); + await allSettled(profileDebugLogsExported, { scope }); + + expect(sendMessage).toHaveBeenCalledWith({ + type: 'export-debug-logs', + scope: 'profile', + profileId: 'profile-2', + profile: fallbackProfile, + }); + }); +}); diff --git a/src/features/export-debug-logs/index.ts b/src/features/export-debug-logs/index.ts new file mode 100644 index 00000000..cf5b8265 --- /dev/null +++ b/src/features/export-debug-logs/index.ts @@ -0,0 +1 @@ +export { $isExportingDebugLogs, generalDebugLogsExported, profileDebugLogsExported } from './model'; diff --git a/src/features/export-debug-logs/model.ts b/src/features/export-debug-logs/model.ts new file mode 100644 index 00000000..3b312cd7 --- /dev/null +++ b/src/features/export-debug-logs/model.ts @@ -0,0 +1,82 @@ +import { createEffect, createEvent, sample } from 'effector'; +import browser from 'webextension-polyfill'; + +import { notificationAdded } from '#entities/notification/model'; +import { NotificationInfo, NotificationVariant } from '#entities/notification/types'; +import { $requestProfiles, $selectedRequestProfile } from '#entities/request-profile/model'; +import { RuntimeMessageType } from '#shared/constants'; + +import { downloadDebugLogs } from './utils'; + +export const generalDebugLogsExported = createEvent(); +export const profileDebugLogsExported = createEvent(); + +type ExportDebugLogsResponse = { + ok?: boolean; + result?: unknown; + error?: string; +}; + +type ExportDebugLogsParams = { + scope: 'general' | 'profile'; + profileId?: string; + profile?: unknown; +}; + +const exportDebugLogsFx = createEffect(async ({ scope, profileId, profile }: ExportDebugLogsParams) => { + const response = (await browser.runtime.sendMessage({ + type: RuntimeMessageType.ExportDebugLogs, + scope, + profileId, + profile, + })) as ExportDebugLogsResponse | undefined; + + if (!response?.ok || response.result == null) { + throw new Error(response?.error || 'Failed to export debug logs'); + } + + downloadDebugLogs(response.result); +}); + +export const $isExportingDebugLogs = exportDebugLogsFx.pending; + +sample({ + clock: generalDebugLogsExported, + fn: (): ExportDebugLogsParams => ({ scope: 'general' }), + target: exportDebugLogsFx, +}); + +sample({ + clock: profileDebugLogsExported, + source: { + profileId: $selectedRequestProfile, + profiles: $requestProfiles, + }, + fn: ({ profileId, profiles }): ExportDebugLogsParams => { + const profile = profiles.find(item => item.id === profileId) ?? profiles[0]; + return { + scope: 'profile', + profileId: profile?.id, + profile, + }; + }, + target: exportDebugLogsFx, +}); + +sample({ + clock: exportDebugLogsFx.done, + fn: (): NotificationInfo => ({ + variant: NotificationVariant.Default, + message: 'Debug logs downloaded', + }), + target: notificationAdded, +}); + +sample({ + clock: exportDebugLogsFx.failData, + fn: (): NotificationInfo => ({ + variant: NotificationVariant.Default, + message: 'Failed to export debug logs', + }), + target: notificationAdded, +}); diff --git a/src/features/export-debug-logs/utils/__tests__/downloadDebugLogs.spec.ts b/src/features/export-debug-logs/utils/__tests__/downloadDebugLogs.spec.ts new file mode 100644 index 00000000..33623e2f --- /dev/null +++ b/src/features/export-debug-logs/utils/__tests__/downloadDebugLogs.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDebugLogsFilename } from '../downloadDebugLogs'; + +describe('buildDebugLogsFilename', () => { + const now = new Date(2026, 8, 9, 7, 5, 4); + + it('builds a timestamped debug logs filename', () => { + expect(buildDebugLogsFilename(now)).toBe('Cloudhood_debug_logs_2026-9-9_7-5-4.txt'); + }); + + it('includes a sanitized profile name for profile logs', () => { + expect(buildDebugLogsFilename(now, 'profile', 'Work / QA')).toBe( + 'Cloudhood_debug_logs_profile_Work_QA_2026-9-9_7-5-4.txt', + ); + }); +}); diff --git a/src/features/export-debug-logs/utils/downloadDebugLogs.ts b/src/features/export-debug-logs/utils/downloadDebugLogs.ts new file mode 100644 index 00000000..f996d3a9 --- /dev/null +++ b/src/features/export-debug-logs/utils/downloadDebugLogs.ts @@ -0,0 +1,29 @@ +export function buildDebugLogsFilename( + now: Date, + scope: 'general' | 'profile' = 'general', + profileName?: string, +): string { + const timestamp = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}_${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}`; + + if (scope === 'profile') { + const safeName = (profileName || 'profile').replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 40); + return `Cloudhood_debug_logs_profile_${safeName}_${timestamp}.txt`; + } + + return `Cloudhood_debug_logs_${timestamp}.txt`; +} + +export function downloadDebugLogs(payload: unknown, now: Date = new Date()): void { + const scopedPayload = payload as { scope?: 'general' | 'profile'; profileName?: string; profile?: { name?: string } }; + const content = JSON.stringify(payload, null, 2); + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const a = document.createElement('a'); + a.href = window.URL.createObjectURL(blob); + a.download = buildDebugLogsFilename( + now, + scopedPayload.scope, + scopedPayload.profileName ?? scopedPayload.profile?.name, + ); + a.click(); + window.URL.revokeObjectURL(a.href); +} diff --git a/src/features/export-debug-logs/utils/index.ts b/src/features/export-debug-logs/utils/index.ts new file mode 100644 index 00000000..566a48cd --- /dev/null +++ b/src/features/export-debug-logs/utils/index.ts @@ -0,0 +1 @@ +export { buildDebugLogsFilename, downloadDebugLogs } from './downloadDebugLogs'; diff --git a/src/index.tsx b/src/index.tsx index f711feeb..5e57b39c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,40 +1,4 @@ -import createCache from '@emotion/cache'; -import { CacheProvider } from '@emotion/react'; -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; - import { App } from './app/App'; -import { getStyleNonce } from './shared/utils/csp'; -import { enableExtensionReload } from './utils/extension-reload'; - -const nonce = getStyleNonce(); - -const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); - -const emotionCache = createCache({ - key: 'cloudhood-css', - nonce, - prepend: true, - container: document.head, - speedy: !isFirefox, - ...(isFirefox && { - insertionPoint: document.head.firstChild as HTMLElement, - }), -}); - -// Enable auto-reload in dev mode -if (process.env.NODE_ENV === 'development') { - enableExtensionReload(); - // eslint-disable-next-line no-console - console.log('🔄 Extension auto-reload initialized for popup - TEST VERSION'); -} - -const root = createRoot(window.document.getElementById('app-container') as HTMLElement); +import { mountExtensionPage } from './mountExtensionPage'; -root.render( - - - - - , -); +mountExtensionPage(); diff --git a/src/mountExtensionPage.tsx b/src/mountExtensionPage.tsx new file mode 100644 index 00000000..be46ff9c --- /dev/null +++ b/src/mountExtensionPage.tsx @@ -0,0 +1,35 @@ +import createCache from '@emotion/cache'; +import { CacheProvider } from '@emotion/react'; +import { ReactNode, StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { getStyleNonce } from './shared/utils/csp'; +import { enableExtensionReload } from './utils/extension-reload'; + +export function mountExtensionPage(children: ReactNode) { + const nonce = getStyleNonce(); + const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); + + const emotionCache = createCache({ + key: 'cloudhood-css', + nonce, + prepend: true, + container: document.head, + speedy: !isFirefox, + ...(isFirefox && { + insertionPoint: document.head.firstChild as HTMLElement, + }), + }); + + if (process.env.NODE_ENV === 'development') { + enableExtensionReload(); + } + + const root = createRoot(window.document.getElementById('app-container') as HTMLElement); + + root.render( + + {children} + , + ); +} diff --git a/src/options.html b/src/options.html new file mode 100644 index 00000000..a4eb5920 --- /dev/null +++ b/src/options.html @@ -0,0 +1,12 @@ + + + + + Cloudhood Settings + + + +
+ + + diff --git a/src/options.tsx b/src/options.tsx new file mode 100644 index 00000000..fed88b76 --- /dev/null +++ b/src/options.tsx @@ -0,0 +1,6 @@ +import { OptionsApp } from './app/OptionsApp'; +import { mountExtensionPage } from './mountExtensionPage'; + +document.body.classList.add('options-page'); + +mountExtensionPage(); diff --git a/src/shared/utils/__tests__/debugLogsExport.spec.ts b/src/shared/utils/__tests__/debugLogsExport.spec.ts new file mode 100644 index 00000000..f4f5b257 --- /dev/null +++ b/src/shared/utils/__tests__/debugLogsExport.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { BrowserStorageKey } from '#shared/constants'; + +import { + COUNTERACT_BASE_ID, + type DebugLogsExportPayload, + type DebugLogsProfile, + filterDebugLogsForProfile, + getProfileRuleIds, + parseProfileFromUnknown, + parseProfilesFromStorage, +} from '../debugLogsExport'; + +const profile: DebugLogsProfile = { + id: 'profile-1', + name: 'Work', + requestHeaders: [{ id: 42, name: 'X-Test', value: 'abc' }], + requestCookies: [{ name: 'session', value: 'tok' }], + urlFilters: [], +}; + +function createPayload(overrides: Partial = {}): DebugLogsExportPayload { + return { + exportedAt: '2026-09-09T00:00:00.000Z', + scope: 'general', + worker: { bootId: 'w1' }, + health: { applyCounter: 1 }, + storage: { + [BrowserStorageKey.Profiles]: JSON.stringify([profile]), + [BrowserStorageKey.SelectedProfile]: 'profile-1', + [BrowserStorageKey.IsPaused]: false, + }, + dnr: { + dynamicRulesCount: 2, + sessionRulesCount: 1, + dynamicRules: [{ id: 42 }, { id: 99 }], + sessionRules: [ + { id: COUNTERACT_BASE_ID, action: { requestHeaders: [{ header: 'X-Test' }] } }, + { id: COUNTERACT_BASE_ID + 1, action: { requestHeaders: [{ header: 'Other' }] } }, + ], + }, + logs: [ + { seq: 1, timestamp: 1, level: 'INFO', message: 'Apply profile-1', args: [] }, + { seq: 2, timestamp: 2, level: 'INFO', message: 'Unrelated', args: ['other'] }, + { seq: 3, timestamp: 3, level: 'INFO', message: 'Header', args: ['X-Test'] }, + ], + ...overrides, + }; +} + +describe('debugLogsExport', () => { + it('parses profiles from a JSON storage string', () => { + expect(parseProfilesFromStorage({ [BrowserStorageKey.Profiles]: JSON.stringify([profile]) })).toEqual([profile]); + }); + + it('collects header and counteracting rule ids for a profile', () => { + expect(getProfileRuleIds(profile)).toEqual(new Set([42])); + }); + + it('keeps only profile-related rules and logs', () => { + const filtered = filterDebugLogsForProfile(createPayload(), profile); + + expect(filtered.scope).toBe('profile'); + expect(filtered.profileId).toBe('profile-1'); + expect(filtered.dnr.dynamicRules).toEqual([{ id: 42 }]); + expect(filtered.dnr.sessionRules).toEqual([ + { id: COUNTERACT_BASE_ID, action: { requestHeaders: [{ header: 'X-Test' }] } }, + ]); + expect(filtered.logs.map(entry => entry.seq)).toEqual([1, 3]); + expect(filtered.profile).toEqual(profile); + }); + + it('parses a profile payload from an untrusted message', () => { + expect(parseProfileFromUnknown({ id: 'profile-1', name: 'Work' })).toEqual({ + id: 'profile-1', + name: 'Work', + requestHeaders: undefined, + requestCookies: undefined, + urlFilters: undefined, + }); + expect(parseProfileFromUnknown({ name: 'missing-id' })).toBeUndefined(); + }); +}); diff --git a/src/shared/utils/debugLogsExport.ts b/src/shared/utils/debugLogsExport.ts new file mode 100644 index 00000000..15fdefa9 --- /dev/null +++ b/src/shared/utils/debugLogsExport.ts @@ -0,0 +1,157 @@ +import { BrowserStorageKey } from '#shared/constants'; + +export const COUNTERACT_BASE_ID = 1_000_000_000; + +export type DebugLogsScope = 'general' | 'profile'; + +export type DebugLogsProfile = { + id: string; + name?: string; + requestHeaders?: Array<{ id: number; name: string; value: string }>; + requestCookies?: Array<{ name: string; value: string }>; + urlFilters?: Array<{ value: string }>; +}; + +export type DebugLogEntry = { + seq: number; + timestamp: number; + level: string; + message: string; + args: string[]; +}; + +export type DebugLogsDnrSnapshot = { + dynamicRulesCount: number; + sessionRulesCount: number; + dynamicRules: unknown[]; + sessionRules: unknown[]; +}; + +export type DebugLogsExportPayload = { + exportedAt: string; + scope: DebugLogsScope; + profileId?: string; + profileName?: string; + profile?: DebugLogsProfile; + worker: unknown; + health: unknown; + storage: Record; + dnr: DebugLogsDnrSnapshot; + logs: DebugLogEntry[]; +}; + +export function parseProfilesFromStorage(storage: Record): DebugLogsProfile[] { + const raw = storage[BrowserStorageKey.Profiles]; + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as DebugLogsProfile[]) : []; + } catch { + return []; + } + } + + return Array.isArray(raw) ? (raw as DebugLogsProfile[]) : []; +} + +export function parseProfileFromUnknown(value: unknown): DebugLogsProfile | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const profile = value as Partial; + if (typeof profile.id !== 'string' || profile.id.length === 0) { + return undefined; + } + + return { + id: profile.id, + name: typeof profile.name === 'string' ? profile.name : undefined, + requestHeaders: Array.isArray(profile.requestHeaders) ? profile.requestHeaders : undefined, + requestCookies: Array.isArray(profile.requestCookies) ? profile.requestCookies : undefined, + urlFilters: Array.isArray(profile.urlFilters) ? profile.urlFilters : undefined, + }; +} + +export function getProfileRuleIds(profile: DebugLogsProfile): Set { + const ids = new Set(); + const urlFilterCount = Math.max(profile.urlFilters?.length ?? 0, 1); + + for (const header of profile.requestHeaders ?? []) { + for (let index = 0; index < urlFilterCount; index += 1) { + ids.add(header.id + index * 10000); + } + } + + return ids; +} + +function isRuleWithId(rule: unknown): rule is { id: number } { + return typeof rule === 'object' && rule !== null && typeof (rule as { id?: unknown }).id === 'number'; +} + +function getRuleHeaderNames(rule: unknown): string[] { + if (typeof rule !== 'object' || rule === null) { + return []; + } + + const headers = (rule as { action?: { requestHeaders?: Array<{ header?: unknown }> } }).action?.requestHeaders ?? []; + return headers + .map(header => header.header) + .filter((name): name is string => typeof name === 'string' && name.length > 0); +} + +function isProfileRelatedRule(rule: unknown, ruleIds: Set, headerNames: Set): boolean { + if (isRuleWithId(rule) && ruleIds.has(rule.id)) { + return true; + } + + return ( + isRuleWithId(rule) && rule.id >= COUNTERACT_BASE_ID && getRuleHeaderNames(rule).some(name => headerNames.has(name)) + ); +} + +function collectProfileNeedles(profile: DebugLogsProfile): string[] { + return [ + profile.id, + profile.name, + ...(profile.requestHeaders ?? []).flatMap(header => [String(header.id), header.name, header.value]), + ...(profile.requestCookies ?? []).flatMap(cookie => [cookie.name, cookie.value]), + ...(profile.urlFilters ?? []).map(filter => filter.value), + ].filter((value): value is string => Boolean(value && value.length > 0)); +} + +export function filterDebugLogsForProfile( + payload: DebugLogsExportPayload, + profile: DebugLogsProfile, +): DebugLogsExportPayload { + const ruleIds = getProfileRuleIds(profile); + const headerNames = new Set((profile.requestHeaders ?? []).map(header => header.name).filter(Boolean)); + const needles = collectProfileNeedles(profile); + const dynamicRules = payload.dnr.dynamicRules.filter(rule => isProfileRelatedRule(rule, ruleIds, headerNames)); + const sessionRules = payload.dnr.sessionRules.filter(rule => isProfileRelatedRule(rule, ruleIds, headerNames)); + + return { + ...payload, + scope: 'profile', + profileId: profile.id, + profileName: profile.name, + profile, + storage: { + [BrowserStorageKey.SelectedProfile]: profile.id, + [BrowserStorageKey.IsPaused]: payload.storage[BrowserStorageKey.IsPaused], + [BrowserStorageKey.HeadersConfigMeta]: payload.storage[BrowserStorageKey.HeadersConfigMeta], + }, + dnr: { + dynamicRulesCount: dynamicRules.length, + sessionRulesCount: sessionRules.length, + dynamicRules, + sessionRules, + }, + logs: payload.logs.filter(entry => { + const haystack = `${entry.message} ${entry.args.join(' ')}`; + return needles.some(needle => haystack.includes(needle)); + }), + }; +} diff --git a/src/shared/utils/setIconBadge.ts b/src/shared/utils/setIconBadge.ts index 38dbdce8..a31ffe08 100644 --- a/src/shared/utils/setIconBadge.ts +++ b/src/shared/utils/setIconBadge.ts @@ -10,6 +10,24 @@ type SetIconBadgeParams = { hasDnrMismatch?: boolean; }; +const BADGE_API_TIMEOUT_MS = 1000; + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs); + promise.then( + value => { + clearTimeout(timer); + resolve(value); + }, + error => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + export async function setIconBadge({ isPaused, activeRulesCount, hasDnrMismatch = false }: SetIconBadgeParams) { const iconPath = isPaused ? 'img/paused-icon-38.png' : 'img/main-icon-38.png'; const badgeText = !isPaused && activeRulesCount > 0 ? activeRulesCount.toString() : ''; @@ -29,7 +47,9 @@ export async function setIconBadge({ isPaused, activeRulesCount, hasDnrMismatch logger.debug('Setting icon badge:', { isPaused, activeRulesCount, iconPath, badgeText, hasDnrMismatch }); try { - // In Chrome Manifest V3, use an object with icon sizes + // In Chrome Manifest V3, use an object with icon sizes. + // One shared timeout: Firefox can leave action.setIcon pending forever, and a hung call + // must not keep the DNR apply lock or be retried. const iconDetails = { path: { 38: iconPath, @@ -38,35 +58,39 @@ export async function setIconBadge({ isPaused, activeRulesCount, hasDnrMismatch logger.debug('Using icon details:', iconDetails); - await browserAction.setIcon(iconDetails); - await browserAction.setBadgeText({ text: badgeText }); - await browserAction.setBadgeBackgroundColor({ color: badgeColor }); + await withTimeout( + Promise.all([ + browserAction.setIcon(iconDetails), + browserAction.setBadgeText({ text: badgeText }), + browserAction.setBadgeBackgroundColor({ color: badgeColor }), + ]), + BADGE_API_TIMEOUT_MS, + ); logger.debug('Icon badge set successfully'); } catch (err) { + const message = err instanceof Error ? err.message : String(err); logger.error('Error setting icon badge:', err); logger.error('Error details:', { iconPath, badgeText, isPaused, activeRulesCount, - errorMessage: err instanceof Error ? err.message : String(err), + errorMessage: message, }); - // Fallback: try using a simple path + if (message.startsWith('timed out')) { + return; + } + try { logger.debug('Trying fallback with simple path:', iconPath); - await browserAction.setIcon({ path: iconPath }); - await browserAction.setBadgeText({ text: badgeText }); + await withTimeout( + Promise.all([browserAction.setIcon({ path: iconPath }), browserAction.setBadgeText({ text: badgeText })]), + BADGE_API_TIMEOUT_MS, + ); logger.debug('Icon badge set successfully with fallback'); } catch (fallbackErr) { logger.error('Fallback also failed:', fallbackErr); - // As a last resort, set the badge text without an icon - try { - await browserAction.setBadgeText({ text: badgeText }); - logger.debug('Badge text set without icon'); - } catch (textErr) { - logger.error('Failed to set badge text:', textErr); - } } } } diff --git a/src/widgets/settings/Settings.tsx b/src/widgets/settings/Settings.tsx new file mode 100644 index 00000000..67f4df93 --- /dev/null +++ b/src/widgets/settings/Settings.tsx @@ -0,0 +1,113 @@ +import { useUnit } from 'effector-react'; + +import { Button } from '@cloud-ru/ds-button'; +import { Divider } from '@cloud-ru/ds-divider'; +import { FieldSelect } from '@cloud-ru/ds-fields'; +import { DaySVG, DownloadSVG, LaptopPhoneSVG, NightSVG } from '@cloud-ru/ds-icons/interface/system'; +import { ItemId } from '@cloud-ru/ds-list'; +import { Typography } from '@cloud-ru/ds-typography'; + +import { $currentTheme, currentThemeChanged } from '#entities/themeMode/model'; +import { $isExportingDebugLogs, generalDebugLogsExported, profileDebugLogsExported } from '#features/export-debug-logs'; +import { ThemeMode } from '#shared/constants'; + +import packageJson from '../../../package.json'; +import * as S from './styled'; + +const THEME_OPTIONS = [ + { id: ThemeMode.Light, content: { label: 'Light' }, beforeContent: }, + { id: ThemeMode.Dark, content: { label: 'Dark' }, beforeContent: }, + { id: ThemeMode.System, content: { label: 'System' }, beforeContent: }, +]; + +export function Settings() { + const [currentTheme, isExportingDebugLogs, onThemeChanged, onGeneralDebugLogsExported, onProfileDebugLogsExported] = + useUnit([ + $currentTheme, + $isExportingDebugLogs, + currentThemeChanged, + generalDebugLogsExported, + profileDebugLogsExported, + ]); + + const handleThemeChange = (value: ItemId | undefined) => { + if (value === ThemeMode.Light || value === ThemeMode.Dark || value === ThemeMode.System) { + onThemeChanged(value); + } + }; + + return ( + + + + Settings + + + + + + + + + Appearance + + + + + + + Diagnostics + + + Download recent debug logs from the background worker. +
+ Extension logs include everything. +
+ Profile logs keep entries for the current profile. +
+ +