From cd0dab012c72344c90a6b4618dd749619c877988 Mon Sep 17 00:00:00 2001 From: Arutyunyan Artem Date: Tue, 18 Nov 2025 13:37:24 +0300 Subject: [PATCH 1/3] feat: overrides tab * feat: Add mock overrides functionality This commit introduces the ability to mock network requests by adding mock overrides to profiles. It includes UI components, state management, and backend logic for handling mock rules. Co-authored-by: artalarut * Refactor: Rename mock overrides to response overrides This commit renames "mock overrides" to "response overrides" for clarity and consistency. It also removes unused mock override related code. Co-authored-by: artalarut --------- Co-authored-by: Cursor Agent --- src/background.ts | 17 +- src/entities/profile-actions/model.ts | 2 +- src/entities/request-profile/model/index.ts | 1 + .../request-profile/model/request-profiles.ts | 1 + .../model/selected-overrides.ts | 17 ++ src/entities/request-profile/types.ts | 15 +- src/features/export-profile/model.ts | 7 +- .../utils/validateProfileList.ts | 4 + .../selected-profile-overrides/add/model.ts | 28 +++ .../remove/model.ts | 25 +++ .../reorder/model.ts | 100 +++++++++ .../toggle/model.ts | 17 ++ .../update/model.ts | 32 +++ .../OverridesActions/AllOverridesCheckbox.tsx | 19 ++ .../OverridesActions/OverridesActions.tsx | 56 +++++ .../ProfileActions/OverridesActions/index.ts | 1 + .../ProfileActions/ProfileActions.tsx | 20 +- src/shared/utils/createOverrideRules.ts | 75 +++++++ src/shared/utils/setBrowserHeaders.ts | 19 +- src/widgets/overrides/Overrides.tsx | 43 ++++ .../components/OverrideRow/OverrideRow.tsx | 85 ++++++++ .../overrides/components/OverrideRow/index.ts | 1 + .../components/OverrideRow/styled.ts | 24 +++ src/widgets/overrides/index.ts | 1 + src/widgets/overrides/styled.ts | 10 + tests/e2e/response-overrides.spec.ts | 201 ++++++++++++++++++ 26 files changed, 803 insertions(+), 18 deletions(-) create mode 100644 src/entities/request-profile/model/selected-overrides.ts create mode 100644 src/features/selected-profile-overrides/add/model.ts create mode 100644 src/features/selected-profile-overrides/remove/model.ts create mode 100644 src/features/selected-profile-overrides/reorder/model.ts create mode 100644 src/features/selected-profile-overrides/toggle/model.ts create mode 100644 src/features/selected-profile-overrides/update/model.ts create mode 100644 src/pages/main/components/ProfileActions/OverridesActions/AllOverridesCheckbox.tsx create mode 100644 src/pages/main/components/ProfileActions/OverridesActions/OverridesActions.tsx create mode 100644 src/pages/main/components/ProfileActions/OverridesActions/index.ts create mode 100644 src/shared/utils/createOverrideRules.ts create mode 100644 src/widgets/overrides/Overrides.tsx create mode 100644 src/widgets/overrides/components/OverrideRow/OverrideRow.tsx create mode 100644 src/widgets/overrides/components/OverrideRow/index.ts create mode 100644 src/widgets/overrides/components/OverrideRow/styled.ts create mode 100644 src/widgets/overrides/index.ts create mode 100644 src/widgets/overrides/styled.ts create mode 100644 tests/e2e/response-overrides.spec.ts diff --git a/src/background.ts b/src/background.ts index 763e280a..6c7bf18a 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,12 +1,13 @@ import browser from 'webextension-polyfill'; -import type { Profile, RequestHeader } from '#entities/request-profile/types'; +import type { Profile, RequestHeader, ResponseOverride } from '#entities/request-profile/types'; import { BrowserStorageKey, ServiceWorkerEvent } from './shared/constants'; import { browserAction } from './shared/utils/browserAPI'; import { logger, LogLevel } from './shared/utils/logger'; import { setBrowserHeaders } from './shared/utils/setBrowserHeaders'; import { setIconBadge } from './shared/utils/setIconBadge'; +import { getOverrideRules } from './shared/utils/createOverrideRules'; import { enableExtensionReload } from './utils/extension-reload'; logger.configure({ @@ -36,7 +37,7 @@ logger.info('🔍 About to check storage contents...'); logger.info(' - Is Paused:', result[BrowserStorageKey.IsPaused] || false); // Логируем количество профилей, если они есть - let activeHeadersCount = 0; + let activeRulesCount = 0; if (result[BrowserStorageKey.Profiles]) { try { const profiles = JSON.parse(result[BrowserStorageKey.Profiles] as string); @@ -44,11 +45,15 @@ logger.info('🔍 About to check storage contents...'); if (profiles.length > 0) { logger.info(' - Profile names:', profiles.map((p: Profile) => p.name || p.id).join(', ')); - // Подсчитываем активные заголовки для badge + // Подсчитываем активные правила для badge const selectedProfile = profiles.find((p: Profile) => p.id === result[BrowserStorageKey.SelectedProfile]); if (selectedProfile) { - activeHeadersCount = selectedProfile.requestHeaders?.filter((h: RequestHeader) => !h.disabled).length || 0; + const activeHeadersCount = selectedProfile.requestHeaders?.filter((h: RequestHeader) => !h.disabled).length || 0; + const activeOverridesCount = selectedProfile.responseOverrides?.filter((o: ResponseOverride) => !o.disabled).length || 0; + activeRulesCount = activeHeadersCount + activeOverridesCount; logger.info(` - Active headers count: ${activeHeadersCount}`); + logger.info(` - Active overrides count: ${activeOverridesCount}`); + logger.info(` - Total active rules count: ${activeRulesCount}`); } } } catch (error) { @@ -61,8 +66,8 @@ logger.info('🔍 About to check storage contents...'); // Устанавливаем badge на основе данных из storage const isPaused = (result[BrowserStorageKey.IsPaused] as boolean) || false; - await setIconBadge({ isPaused, activeRulesCount: activeHeadersCount }); - logger.info(`🏷️ Badge set: paused=${isPaused}, activeRules=${activeHeadersCount}`); + await setIconBadge({ isPaused, activeRulesCount }); + logger.info(`🏷️ Badge set: paused=${isPaused}, activeRules=${activeRulesCount}`); } catch (error) { logger.error('Failed to check storage on background script load:', error); } diff --git a/src/entities/profile-actions/model.ts b/src/entities/profile-actions/model.ts index 20d17918..a8f6b4b7 100644 --- a/src/entities/profile-actions/model.ts +++ b/src/entities/profile-actions/model.ts @@ -2,7 +2,7 @@ import { createEvent, createStore } from 'effector'; import { selectedRequestProfileIdChanged } from '#entities/request-profile/model/selected-request-profile'; -export type ProfileActionsTab = 'headers' | 'url-filters'; +export type ProfileActionsTab = 'headers' | 'url-filters' | 'overrides'; export const profileActionsTabChanged = createEvent(); diff --git a/src/entities/request-profile/model/index.ts b/src/entities/request-profile/model/index.ts index 47fadaf5..aa80758a 100644 --- a/src/entities/request-profile/model/index.ts +++ b/src/entities/request-profile/model/index.ts @@ -2,3 +2,4 @@ export * from './request-profiles'; export * from './selected-profile-url-filters'; export * from './selected-request-headers'; export * from './selected-request-profile'; +export * from './selected-overrides'; diff --git a/src/entities/request-profile/model/request-profiles.ts b/src/entities/request-profile/model/request-profiles.ts index d6aef20d..d913be6f 100644 --- a/src/entities/request-profile/model/request-profiles.ts +++ b/src/entities/request-profile/model/request-profiles.ts @@ -44,6 +44,7 @@ const profileAddedFx = attach({ id: addedHeaderId, requestHeaders: [{ id: generateId(), name: '', value: '', disabled: false }], urlFilters: [{ id: generateId(), value: '', disabled: false }], + responseOverrides: [{ id: generateId(), urlPattern: '', responseContent: '', disabled: false }], }, ], addedHeaderId, diff --git a/src/entities/request-profile/model/selected-overrides.ts b/src/entities/request-profile/model/selected-overrides.ts new file mode 100644 index 00000000..acf6b35e --- /dev/null +++ b/src/entities/request-profile/model/selected-overrides.ts @@ -0,0 +1,17 @@ +import { combine } from 'effector'; + +import { $requestProfiles } from './request-profiles'; +import { $selectedRequestProfile } from './selected-request-profile'; + +export const $selectedProfileResponseOverrides = combine( + $selectedRequestProfile, + $requestProfiles, + (selectedProfileId, profiles) => profiles.find(p => p.id === selectedProfileId)?.responseOverrides ?? [], + { skipVoid: false }, +); + +export const $selectedProfileActiveResponseOverridesCount = combine( + $selectedProfileResponseOverrides, + overrides => overrides.filter(item => !item.disabled && item.urlPattern.trim() && item.responseContent.trim()).length, + { skipVoid: false }, +); diff --git a/src/entities/request-profile/types.ts b/src/entities/request-profile/types.ts index 280f6ac2..d6b27c44 100644 --- a/src/entities/request-profile/types.ts +++ b/src/entities/request-profile/types.ts @@ -10,7 +10,20 @@ export type UrlFilter = { disabled: boolean; }; -export type Profile = { id: string; name?: string; requestHeaders: RequestHeader[]; urlFilters: UrlFilter[] }; +export type ResponseOverride = { + id: number; + urlPattern: string; + responseContent: string; + disabled: boolean; +}; + +export type Profile = { + id: string; + name?: string; + requestHeaders: RequestHeader[]; + urlFilters: UrlFilter[]; + responseOverrides?: ResponseOverride[]; +}; export type RemoveHeaderPayload = { headerId: number; diff --git a/src/features/export-profile/model.ts b/src/features/export-profile/model.ts index b28d108d..c6fc9f03 100644 --- a/src/features/export-profile/model.ts +++ b/src/features/export-profile/model.ts @@ -39,11 +39,12 @@ export const $profileExportString = combine( return JSON.stringify( profiles .filter(({ id }) => selectedExportProfileIdList.includes(id)) - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- если модель будет расширять, то потенциально будет ошибка - .map(({ id, requestHeaders, ...rest }) => ({ + .map(({ id, requestHeaders, responseOverrides, ...rest }) => ({ ...rest, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- если модель будет расширять, то потенциально будет ошибка requestHeaders: requestHeaders.map(({ id, ...headerRest }) => headerRest), + ...(responseOverrides && { + responseOverrides: responseOverrides.map(({ id, ...overrideRest }) => overrideRest), + }), })) || [], ); }, diff --git a/src/features/import-profile/utils/validateProfileList.ts b/src/features/import-profile/utils/validateProfileList.ts index 061c31cf..ac8fcef8 100644 --- a/src/features/import-profile/utils/validateProfileList.ts +++ b/src/features/import-profile/utils/validateProfileList.ts @@ -83,5 +83,9 @@ export function generateProfileList(profileList: Profile[], existingProfileList: id: generateIdWithExcludeList(existingProfileRequestHeadersListId), })), urlFilters: profile.urlFilters || [], + responseOverrides: (profile.responseOverrides || []).map(override => ({ + ...override, + id: generateIdWithExcludeList(existingProfileRequestHeadersListId), + })), })); } diff --git a/src/features/selected-profile-overrides/add/model.ts b/src/features/selected-profile-overrides/add/model.ts new file mode 100644 index 00000000..ac667311 --- /dev/null +++ b/src/features/selected-profile-overrides/add/model.ts @@ -0,0 +1,28 @@ +import { attach, createEvent, sample } from 'effector'; + +import { $requestProfiles, $selectedRequestProfile, profileUpdated } from '#entities/request-profile/model'; +import { ResponseOverride } from '#entities/request-profile/types'; +import { generateId } from '#shared/utils/generateId'; + +type SelectedProfileResponseOverridesAdded = Omit[]; + +export const selectedProfileResponseOverridesAdded = createEvent(); + +const selectedProfileResponseOverridesAddedFx = attach({ + source: { profiles: $requestProfiles, selectedProfile: $selectedRequestProfile }, + effect: ({ profiles, selectedProfile }, responseOverrides: SelectedProfileResponseOverridesAdded) => { + const profile = profiles.find(p => p.id === selectedProfile); + + if (!profile) { + throw new Error('Profile not found'); + } + + return { + ...profile, + responseOverrides: [...(profile.responseOverrides || []), ...responseOverrides.map(m => ({ ...m, id: generateId() }))], + }; + }, +}); + +sample({ clock: selectedProfileResponseOverridesAdded, target: selectedProfileResponseOverridesAddedFx }); +sample({ clock: selectedProfileResponseOverridesAddedFx.doneData, target: profileUpdated }); diff --git a/src/features/selected-profile-overrides/remove/model.ts b/src/features/selected-profile-overrides/remove/model.ts new file mode 100644 index 00000000..9426fd8f --- /dev/null +++ b/src/features/selected-profile-overrides/remove/model.ts @@ -0,0 +1,25 @@ +import { attach, createEvent, sample } from 'effector'; + +import { $requestProfiles, $selectedRequestProfile, profileUpdated } from '#entities/request-profile/model'; +import { ResponseOverride } from '#entities/request-profile/types'; + +export const selectedProfileResponseOverridesRemoved = createEvent(); + +const selectedProfileResponseOverridesRemovedFx = attach({ + source: { profiles: $requestProfiles, selectedProfile: $selectedRequestProfile }, + effect: ({ profiles, selectedProfile }, overridesId: ResponseOverride['id'][]) => { + const profile = profiles.find(p => p.id === selectedProfile); + + if (!profile) { + throw new Error('Profile not found'); + } + + return { + ...profile, + responseOverrides: (profile.responseOverrides || []).filter(m => !overridesId.includes(m.id)), + }; + }, +}); + +sample({ clock: selectedProfileResponseOverridesRemoved, target: selectedProfileResponseOverridesRemovedFx }); +sample({ clock: selectedProfileResponseOverridesRemovedFx.doneData, target: profileUpdated }); diff --git a/src/features/selected-profile-overrides/reorder/model.ts b/src/features/selected-profile-overrides/reorder/model.ts new file mode 100644 index 00000000..f516a278 --- /dev/null +++ b/src/features/selected-profile-overrides/reorder/model.ts @@ -0,0 +1,100 @@ +import { DragOverEvent, DragStartEvent } from '@dnd-kit/core'; +import { arrayMove } from '@dnd-kit/sortable'; +import { attach, combine, sample } from 'effector'; + +import { + $requestProfiles, + $selectedProfileResponseOverrides, + $selectedRequestProfile, + profileUpdated, +} from '#entities/request-profile/model'; +import { + createSortableListModel, + dragEnded, + dragOver, + dragStarted, + type SortableItemId, + type SortableItemIdOrNull, +} from '#entities/sortable-list'; + +export const { + $flattenItems: $flattenResponseOverrides, + $dragTarget: $dragTargetResponseOverrides, + $raisedItem: $raisedResponseOverride, + reorderItems, + itemsUpdated, +} = createSortableListModel({ + $items: $selectedProfileResponseOverrides, + $selectedItem: $selectedRequestProfile, + $allItems: $requestProfiles.map(profiles => profiles.map(profile => profile.responseOverrides || [])), + itemsUpdated: profileUpdated, +}); + +export const $draggableResponseOverride = combine( + [$raisedResponseOverride, $selectedProfileResponseOverrides], + ([raisedId, overrides]) => (raisedId ? overrides.find(override => override.id === raisedId) : null), +); + +const reorderResponseOverridesFx = attach({ + source: { profiles: $requestProfiles, selectedProfile: $selectedRequestProfile }, + effect: ({ profiles, selectedProfile }, payload: { active: string | number; target: string | number }) => { + const { active, target } = payload; + + const profile = profiles.find(p => p.id === selectedProfile); + + if (!profile) { + return null; + } + + const responseOverrides = profile.responseOverrides || []; + const activeIndex = responseOverrides.findIndex(override => override.id === active); + const targetIndex = responseOverrides.findIndex(override => override.id === target); + + if (activeIndex === -1 || targetIndex === -1) { + return null; + } + + return { + id: profile.id, + ...(profile.name && { name: profile.name }), + requestHeaders: profile.requestHeaders, + urlFilters: profile.urlFilters, + responseOverrides: arrayMove(responseOverrides, activeIndex, targetIndex), + }; + }, +}); + +sample({ + clock: dragStarted, + filter: (event: DragStartEvent) => Boolean(event.active.id), + fn: (event: DragStartEvent) => event.active.id as string | number, + target: $raisedResponseOverride, +}); + +sample({ + clock: dragOver, + filter: (event: DragOverEvent) => Boolean(event.over?.id), + fn: (event: DragOverEvent) => event.over?.id as string | number, + target: $dragTargetResponseOverrides, +}); + +const responseOverrideMoved = sample({ + clock: dragEnded, + source: { active: $raisedResponseOverride, target: $dragTargetResponseOverrides }, + filter(src: { + active: SortableItemIdOrNull; + target: SortableItemIdOrNull; + }): src is { active: SortableItemId; target: SortableItemId } { + return Boolean(src.active) && Boolean(src.target) && src.active !== src.target; + }, +}); + +sample({ clock: responseOverrideMoved, target: reorderResponseOverridesFx }); +sample({ + clock: reorderResponseOverridesFx.doneData, + filter: Boolean, + target: profileUpdated, +}); + +$dragTargetResponseOverrides.reset(reorderResponseOverridesFx.finally); +$raisedResponseOverride.reset(reorderResponseOverridesFx.finally); diff --git a/src/features/selected-profile-overrides/toggle/model.ts b/src/features/selected-profile-overrides/toggle/model.ts new file mode 100644 index 00000000..60f8538c --- /dev/null +++ b/src/features/selected-profile-overrides/toggle/model.ts @@ -0,0 +1,17 @@ +import { combine, createEvent, sample } from 'effector'; + +import { $selectedProfileResponseOverrides } from '#entities/request-profile/model/selected-overrides'; +import { selectedProfileResponseOverridesUpdated } from '#features/selected-profile-overrides/update/model'; + +export const toggleAllProfileResponseOverrides = createEvent(); + +export const $isAllEnabled = combine($selectedProfileResponseOverrides, overrides => overrides.every(m => !m.disabled), { + skipVoid: false, +}); + +sample({ + clock: toggleAllProfileResponseOverrides, + source: $selectedProfileResponseOverrides, + fn: (overrides, enabled) => overrides.map(m => ({ ...m, disabled: !enabled })), + target: selectedProfileResponseOverridesUpdated, +}); diff --git a/src/features/selected-profile-overrides/update/model.ts b/src/features/selected-profile-overrides/update/model.ts new file mode 100644 index 00000000..be7c8c7c --- /dev/null +++ b/src/features/selected-profile-overrides/update/model.ts @@ -0,0 +1,32 @@ +import { attach, createEvent, sample } from 'effector'; + +import { $requestProfiles, $selectedRequestProfile, profileUpdated } from '#entities/request-profile/model'; +import type { ResponseOverride } from '#entities/request-profile/types'; + +export const selectedProfileResponseOverridesUpdated = createEvent(); + +const selectedProfileResponseOverridesUpdatedFx = attach({ + source: { profiles: $requestProfiles, selectedProfile: $selectedRequestProfile }, + effect: ({ profiles, selectedProfile }, updatedOverrides: ResponseOverride[]) => { + const profile = profiles.find(p => p.id === selectedProfile); + + if (!profile) { + throw new Error('Profile not found'); + } + + return { + ...profile, + responseOverrides: (profile.responseOverrides || []).map(override => { + const updatedOverride = updatedOverrides.find(m => m.id === override.id); + if (updatedOverride) { + return { ...updatedOverride }; + } + + return override; + }), + }; + }, +}); + +sample({ clock: selectedProfileResponseOverridesUpdated, target: selectedProfileResponseOverridesUpdatedFx }); +sample({ clock: selectedProfileResponseOverridesUpdatedFx.doneData, target: profileUpdated }); diff --git a/src/pages/main/components/ProfileActions/OverridesActions/AllOverridesCheckbox.tsx b/src/pages/main/components/ProfileActions/OverridesActions/AllOverridesCheckbox.tsx new file mode 100644 index 00000000..c063de0d --- /dev/null +++ b/src/pages/main/components/ProfileActions/OverridesActions/AllOverridesCheckbox.tsx @@ -0,0 +1,19 @@ +import { useUnit } from 'effector-react'; + +import { Checkbox } from '@snack-uikit/toggles'; + +import { $isPaused } from '#entities/is-paused/model'; +import { $isAllEnabled, toggleAllProfileResponseOverrides } from '#features/selected-profile-overrides/toggle/model'; + +export function AllOverridesCheckbox() { + const [isPaused, isAllEnabled] = useUnit([$isPaused, $isAllEnabled]); + + return ( + + ); +} diff --git a/src/pages/main/components/ProfileActions/OverridesActions/OverridesActions.tsx b/src/pages/main/components/ProfileActions/OverridesActions/OverridesActions.tsx new file mode 100644 index 00000000..58162f8b --- /dev/null +++ b/src/pages/main/components/ProfileActions/OverridesActions/OverridesActions.tsx @@ -0,0 +1,56 @@ +import { useUnit } from 'effector-react'; + +import { ButtonFunction } from '@snack-uikit/button'; +import { PlusSVG, TrashSVG } from '@snack-uikit/icons'; +import { Typography } from '@snack-uikit/typography'; + +import { $isPaused } from '#entities/is-paused/model'; +import { $isProfileRemoveAvailable } from '#entities/request-profile/model'; +import { selectedProfileRemoved } from '#features/selected-profile/remove/model'; +import { selectedProfileResponseOverridesAdded } from '#features/selected-profile-overrides/add/model'; +import { ProfileActionsLayout } from '#shared/components'; +import { Overrides } from '#widgets/overrides'; + +import { AllOverridesCheckbox } from './AllOverridesCheckbox'; + +export function OverridesActions() { + const [isPaused, handleRemove, isProfileRemoveAvailable] = useUnit([ + $isPaused, + selectedProfileRemoved, + $isProfileRemoveAvailable, + ]); + + const handleAddResponseOverride = () => { + selectedProfileResponseOverridesAdded([{ disabled: false, urlPattern: '', responseContent: '' }]); + }; + + const leftHeaderActions = ( + <> + + Response Overrides + + ); + + const rightHeaderActions = ( + <> + } + onClick={handleAddResponseOverride} + /> + } + disabled={isPaused || !isProfileRemoveAvailable} + onClick={handleRemove} + /> + + ); + + return ( + + + + ); +} diff --git a/src/pages/main/components/ProfileActions/OverridesActions/index.ts b/src/pages/main/components/ProfileActions/OverridesActions/index.ts new file mode 100644 index 00000000..88cb0c31 --- /dev/null +++ b/src/pages/main/components/ProfileActions/OverridesActions/index.ts @@ -0,0 +1 @@ +export { OverridesActions } from './OverridesActions'; diff --git a/src/pages/main/components/ProfileActions/ProfileActions.tsx b/src/pages/main/components/ProfileActions/ProfileActions.tsx index a684adab..cbc1a8ac 100644 --- a/src/pages/main/components/ProfileActions/ProfileActions.tsx +++ b/src/pages/main/components/ProfileActions/ProfileActions.tsx @@ -4,19 +4,25 @@ import { Tabs } from '@snack-uikit/tabs'; import { $isPaused } from '#entities/is-paused/model'; import { $activeProfileActionsTab, profileActionsTabChanged } from '#entities/profile-actions'; -import { $selectedProfileActiveRequestHeadersCount, $selectedProfileActiveUrlFiltersCount } from '#entities/request-profile/model'; +import { + $selectedProfileActiveRequestHeadersCount, + $selectedProfileActiveUrlFiltersCount, + $selectedProfileActiveResponseOverridesCount +} from '#entities/request-profile/model'; import { getCounterProps } from '#shared/utils/getCounterProps'; +import { OverridesActions } from './OverridesActions'; import { RequestHeadersActions } from './RequestHeadersActions'; import * as S from './styled'; import { UrlFiltersActions } from './UrlFiltersActions'; export function ProfileActions() { - const [isPaused, activeTab, activeRequestHeadersCount, activeUrlFiltersCount] = useUnit([ + const [isPaused, activeTab, activeRequestHeadersCount, activeUrlFiltersCount, activeResponseOverridesCount] = useUnit([ $isPaused, $activeProfileActionsTab, $selectedProfileActiveRequestHeadersCount, - $selectedProfileActiveUrlFiltersCount + $selectedProfileActiveUrlFiltersCount, + $selectedProfileActiveResponseOverridesCount ]); return ( @@ -33,6 +39,11 @@ export function ProfileActions() { counter={getCounterProps(activeUrlFiltersCount)} value='url-filters' /> + @@ -40,6 +51,9 @@ export function ProfileActions() { + + + diff --git a/src/shared/utils/createOverrideRules.ts b/src/shared/utils/createOverrideRules.ts new file mode 100644 index 00000000..80e6f90f --- /dev/null +++ b/src/shared/utils/createOverrideRules.ts @@ -0,0 +1,75 @@ +import browser from 'webextension-polyfill'; + +import type { ResponseOverride } from '#entities/request-profile/types'; + +import { logger } from './logger'; + +const OVERRIDE_RULE_ID_OFFSET = 100000; + +function isValidRegex(pattern: string): boolean { + try { + new RegExp(pattern); + return true; + } catch { + return false; + } +} + +export function getOverrideRules(responseOverrides: ResponseOverride[]): browser.DeclarativeNetRequest.Rule[] { + const allResourceTypes = [ + 'main_frame', + 'sub_frame', + 'stylesheet', + 'script', + 'image', + 'font', + 'object', + 'xmlhttprequest', + 'ping', + 'csp_report', + 'media', + 'websocket', + 'other', + ] as browser.DeclarativeNetRequest.ResourceType[]; + + const rules: browser.DeclarativeNetRequest.Rule[] = []; + + responseOverrides.forEach((override, index) => { + if (!override.urlPattern.trim() || !override.responseContent.trim()) { + return; + } + + try { + const urlPattern = override.urlPattern.trim(); + + if (!isValidRegex(urlPattern)) { + logger.warn(`Invalid URL regex: ${urlPattern}`); + return; + } + + const encodedContent = encodeURIComponent(override.responseContent); + const dataUrl = `data:application/json;charset=utf-8,${encodedContent}`; + + rules.push({ + id: OVERRIDE_RULE_ID_OFFSET + index, + priority: 1, + action: { + type: 'redirect' as const, + redirect: { + url: dataUrl, + }, + }, + condition: { + regexFilter: urlPattern, + resourceTypes: allResourceTypes, + }, + }); + + logger.debug(`Created override rule for regex: ${urlPattern}`); + } catch (error) { + logger.error(`Failed to create override rule for ${override.id}:`, error); + } + }); + + return rules; +} diff --git a/src/shared/utils/setBrowserHeaders.ts b/src/shared/utils/setBrowserHeaders.ts index 6ee13b8f..11f42586 100644 --- a/src/shared/utils/setBrowserHeaders.ts +++ b/src/shared/utils/setBrowserHeaders.ts @@ -3,6 +3,7 @@ import browser from 'webextension-polyfill'; import type { Profile, RequestHeader } from '#entities/request-profile/types'; import { BrowserStorageKey } from '#shared/constants'; +import { getOverrideRules } from './createOverrideRules'; import { createUrlCondition } from './createUrlCondition'; import { validateHeader } from './headers'; import { logger } from './logger'; @@ -105,12 +106,14 @@ export async function setBrowserHeaders(result: Record) { const selectedProfileHeaders = profile?.requestHeaders ?? []; const selectedProfileUrlFilters = profile?.urlFilters ?? []; + const selectedProfileResponseOverrides = profile?.responseOverrides ?? []; const activeHeaders = selectedProfileHeaders.filter( ({ disabled, name, value }) => !disabled && validateHeader(name, value), ); - // Remove extra line and fix logging + const activeResponseOverrides = selectedProfileResponseOverrides.filter(({ disabled }) => !disabled); + logger.info('URL filters from profile:', selectedProfileUrlFilters); const activeUrlFilters = selectedProfileUrlFilters @@ -118,20 +121,28 @@ export async function setBrowserHeaders(result: Record) { .map(({ value }) => value.trim()); logger.info('Active URL filters:', activeUrlFilters); + logger.info('Active response overrides:', activeResponseOverrides); - // Добавляем более заметное логирование logger.debug('🔍 Profile data:', { profileId: selectedProfile, headersCount: selectedProfileHeaders.length, activeHeadersCount: activeHeaders.length, urlFiltersCount: selectedProfileUrlFilters.length, activeUrlFiltersCount: activeUrlFilters.length, + responseOverridesCount: selectedProfileResponseOverrides.length, + activeResponseOverridesCount: activeResponseOverrides.length, }); - const addRules: browser.DeclarativeNetRequest.Rule[] = !isPaused + const headerRules: browser.DeclarativeNetRequest.Rule[] = !isPaused ? activeHeaders.flatMap(header => getRulesForHeader(header, activeUrlFilters)) : []; + const overrideRules: browser.DeclarativeNetRequest.Rule[] = !isPaused + ? getOverrideRules(activeResponseOverrides) + : []; + + const addRules = [...headerRules, ...overrideRules]; + const removeRuleIds = currentRules.map(item => item.id); try { @@ -161,7 +172,7 @@ export async function setBrowserHeaders(result: Record) { logger.info('Rules updated successfully'); logger.groupEnd(); - await setIconBadge({ isPaused, activeRulesCount: activeHeaders.length }); + await setIconBadge({ isPaused, activeRulesCount: activeHeaders.length + activeResponseOverrides.length }); } catch (err) { logger.error('Failed to update dynamic rules:', err); } diff --git a/src/widgets/overrides/Overrides.tsx b/src/widgets/overrides/Overrides.tsx new file mode 100644 index 00000000..7c11e751 --- /dev/null +++ b/src/widgets/overrides/Overrides.tsx @@ -0,0 +1,43 @@ +import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { SortableContext } from '@dnd-kit/sortable'; +import { useUnit } from 'effector-react'; + +import { $selectedProfileResponseOverrides } from '#entities/request-profile/model/selected-overrides'; +import { dragEnded, dragOver, dragStarted, restrictToParentElement } from '#entities/sortable-list'; +import { + $draggableResponseOverride, + $flattenResponseOverrides, +} from '#features/selected-profile-overrides/reorder/model'; +import { isDefined } from '#shared/utils/typeGuards'; + +import { OverrideRow } from './components/OverrideRow'; +import * as S from './styled'; + +export function Overrides() { + const { responseOverrides, flattenResponseOverrides, activeResponseOverride } = useUnit({ + responseOverrides: $selectedProfileResponseOverrides, + flattenResponseOverrides: $flattenResponseOverrides, + activeResponseOverride: $draggableResponseOverride, + }); + + const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor)); + + return ( + + + + {responseOverrides.map(override => ( + + ))} + + + {isDefined(activeResponseOverride) ? : null} + + ); +} diff --git a/src/widgets/overrides/components/OverrideRow/OverrideRow.tsx b/src/widgets/overrides/components/OverrideRow/OverrideRow.tsx new file mode 100644 index 00000000..27bc1ec1 --- /dev/null +++ b/src/widgets/overrides/components/OverrideRow/OverrideRow.tsx @@ -0,0 +1,85 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { useUnit } from 'effector-react/effector-react.mjs'; +import { type KeyboardEvent } from 'react'; + +import { ButtonFunction } from '@snack-uikit/button'; +import { FieldText } from '@snack-uikit/fields'; +import { CrossSVG } from '@snack-uikit/icons'; +import { Checkbox, CheckboxProps } from '@snack-uikit/toggles'; + +import { $isPaused } from '#entities/is-paused/model'; +import type { ResponseOverride } from '#entities/request-profile/types'; +import { DragHandle } from '#entities/sortable-list'; +import { selectedProfileResponseOverridesRemoved } from '#features/selected-profile-overrides/remove/model'; +import { selectedProfileResponseOverridesUpdated } from '#features/selected-profile-overrides/update/model'; + +import * as S from './styled'; + +export function OverrideRow(props: ResponseOverride) { + const { disabled, urlPattern, responseContent, id } = props; + const { setNodeRef, listeners, attributes, transition, transform, isDragging } = useSortable({ id }); + const { isPaused } = useUnit({ + isPaused: $isPaused, + }); + + const handleKeyPress = (event: KeyboardEvent) => { + const target = event.target as HTMLInputElement; + const cursorPosition = target.selectionStart; + if (event.key === ' ' && cursorPosition === 0) { + event.preventDefault(); + } + }; + + const handleChange = (field: 'urlPattern' | 'responseContent') => (value: string) => { + selectedProfileResponseOverridesUpdated([{ ...props, [field]: value }]); + }; + + const handleChecked: CheckboxProps['onChange'] = checked => { + selectedProfileResponseOverridesUpdated([{ ...props, disabled: !checked }]); + }; + + return ( + + + + + + + + + + + + } + onClick={() => selectedProfileResponseOverridesRemoved([id])} + /> + + ); +} diff --git a/src/widgets/overrides/components/OverrideRow/index.ts b/src/widgets/overrides/components/OverrideRow/index.ts new file mode 100644 index 00000000..dbbe7196 --- /dev/null +++ b/src/widgets/overrides/components/OverrideRow/index.ts @@ -0,0 +1 @@ +export { OverrideRow } from './OverrideRow'; diff --git a/src/widgets/overrides/components/OverrideRow/styled.ts b/src/widgets/overrides/components/OverrideRow/styled.ts new file mode 100644 index 00000000..98477bf8 --- /dev/null +++ b/src/widgets/overrides/components/OverrideRow/styled.ts @@ -0,0 +1,24 @@ +import { CSS, type Transform } from '@dnd-kit/utilities'; +import styled from '@emotion/styled'; + +export const Wrapper = styled.div<{ transform: Transform | null; isDragging: boolean; transition?: string }>` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 4px; + + transform: ${props => CSS.Transform.toString(props.transform)}; + opacity: ${props => (props.isDragging ? 0 : 1)}; + transition: ${props => props.transition}; + + width: 100%; +`; + +export const LeftOverrideActions = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + min-width: 280px; +`; diff --git a/src/widgets/overrides/index.ts b/src/widgets/overrides/index.ts new file mode 100644 index 00000000..c9741e99 --- /dev/null +++ b/src/widgets/overrides/index.ts @@ -0,0 +1 @@ +export { Overrides } from './Overrides'; diff --git a/src/widgets/overrides/styled.ts b/src/widgets/overrides/styled.ts new file mode 100644 index 00000000..61092c81 --- /dev/null +++ b/src/widgets/overrides/styled.ts @@ -0,0 +1,10 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + + gap: 8px; + align-items: flex-end; + padding-bottom: 16px; +`; diff --git a/tests/e2e/response-overrides.spec.ts b/tests/e2e/response-overrides.spec.ts new file mode 100644 index 00000000..f2976dc1 --- /dev/null +++ b/tests/e2e/response-overrides.spec.ts @@ -0,0 +1,201 @@ +import { expect, test } from './fixtures'; + +test.describe('Response Overrides', () => { + /** + * Тест-кейс: Базовая функциональность переопределения ответов + * + * Цель: Проверить основную функциональность работы с переопределением ответов - + * возможность добавления, редактирования и удаления правил. + * + * Сценарий: + * 1. Открываем popup расширения + * 2. Переходим на вкладку Overrides + * 3. Добавляем правило переопределения + * 4. Заполняем поля URL pattern и Response content + * 5. Проверяем сохранение значений + * 6. Редактируем значения + * 7. Удаляем правило + */ + test('should add, edit and remove response overrides', async ({ page, extensionId }) => { + // Шаг 1: Открываем popup расширения + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + // Шаг 2: Переходим на вкладку Overrides + const overridesTab = page.locator('[role="tab"]:has-text("Overrides")'); + await overridesTab.click(); + await expect(overridesTab).toHaveAttribute('aria-selected', 'true'); + + // Проверяем, что секция переопределения отображается + const overridesSection = page.locator('[data-test-id="profile-overrides-section"]'); + await expect(overridesSection).toBeVisible({ timeout: 5000 }); + + // Шаг 3: Добавляем правило переопределения + const addOverrideButton = page.locator('[data-test-id="add-response-override-button"]'); + await addOverrideButton.click(); + + // Ждем появления полей + await page.waitForTimeout(500); + + // Шаг 4: Заполняем поля URL pattern и Response content + const urlPatternField = page.locator('[data-test-id="url-pattern-input"] input').first(); + const responseContentField = page.locator('[data-test-id="response-content-input"] input').first(); + + await expect(urlPatternField).toBeVisible({ timeout: 10000 }); + await expect(responseContentField).toBeVisible({ timeout: 10000 }); + + // Проверяем новый placeholder + await expect(urlPatternField).toHaveAttribute('placeholder', 'URL Regex'); + + await urlPatternField.fill('^https://api\\.example\\.com/data'); + await responseContentField.fill('{"mock": "data"}'); + + // Шаг 5: Проверяем сохранение значений + await expect(urlPatternField).toHaveValue('^https://api\\.example\\.com/data'); + await expect(responseContentField).toHaveValue('{"mock": "data"}'); + + // Шаг 6: Редактируем значения + await urlPatternField.fill('^https://api\\.example\\.com/v2/.*'); + await responseContentField.fill('{"mock": "updated"}'); + + await expect(urlPatternField).toHaveValue('^https://api\\.example\\.com/v2/.*'); + await expect(responseContentField).toHaveValue('{"mock": "updated"}'); + + // Шаг 7: Удаляем правило + const removeOverrideButton = page.locator('[data-test-id="remove-response-override-button"]').first(); + await removeOverrideButton.click(); + + // Ждем удаления + await page.waitForTimeout(500); + + // Проверяем, что поля исчезли или стали пустыми (если логика такова, что последнее не удаляется полностью из DOM, а очищается - нужно проверить) + // В коде: selectedProfileResponseOverridesRemoved([id]) фильтрует массив. Если массив пуст, map не отрендерит ничего. + const overrideRows = page.locator('[data-test-id="url-pattern-input"]'); + await expect(overrideRows).toHaveCount(0); + }); + + /** + * Тест-кейс: Чекбокс включения/отключения правил переопределения + * + * Цель: Проверить функциональность чекбокса для включения/отключения + * правил переопределения ответов. + * + * Сценарий: + * 1. Открываем popup расширения + * 2. Переходим на вкладку Overrides + * 3. Добавляем правило + * 4. Проверяем начальное состояние чекбокса + * 5. Отключаем правило через чекбокс + * 6. Проверяем, что правило отключено + * 7. Включаем правило обратно + */ + test('should toggle response override checkbox', async ({ page, extensionId }) => { + // Шаг 1: Открываем popup расширения + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + // Шаг 2: Переходим на вкладку Overrides + const overridesTab = page.locator('[role="tab"]:has-text("Overrides")'); + await overridesTab.click(); + + // Шаг 3: Добавляем правило + const addOverrideButton = page.locator('[data-test-id="add-response-override-button"]'); + await addOverrideButton.click(); + await page.waitForTimeout(500); + + const urlPatternField = page.locator('[data-test-id="url-pattern-input"] input').first(); + await urlPatternField.fill('https://example.com'); + + // Шаг 4: Проверяем начальное состояние чекбокса + const overrideCheckbox = page.locator('[data-test-id="response-override-checkbox"]'); + await expect(overrideCheckbox).toHaveAttribute('data-checked', 'true'); + + // Шаг 5: Отключаем правило через чекбокс + await overrideCheckbox.click(); + + // Шаг 6: Проверяем, что правило отключено + await expect(overrideCheckbox).toHaveAttribute('data-checked', 'false'); + + // Шаг 7: Включаем правило обратно + await overrideCheckbox.click(); + await expect(overrideCheckbox).toHaveAttribute('data-checked', 'true'); + }); + + /** + * Тест-кейс: Чекбокс "включить все переопределения" + * + * Цель: Проверить функциональность чекбокса для включения/отключения + * всех правил переопределения одновременно. + */ + test('should toggle all response overrides checkbox', async ({ page, extensionId }) => { + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const overridesTab = page.locator('[role="tab"]:has-text("Overrides")'); + await overridesTab.click(); + + // Добавляем два правила + const addOverrideButton = page.locator('[data-test-id="add-response-override-button"]'); + await addOverrideButton.click(); + await addOverrideButton.click(); + await page.waitForTimeout(500); + + // Проверяем состояние общего чекбокса + const allOverridesCheckbox = page.locator('[data-test-id="response-overrides-all-checkbox"]'); + await expect(allOverridesCheckbox).toHaveAttribute('data-checked', 'true'); + + // Отключаем все + await allOverridesCheckbox.click(); + + // Проверяем индивидуальные чекбоксы + const checkboxes = page.locator('[data-test-id="response-override-checkbox"]'); + await expect(checkboxes.nth(0)).toHaveAttribute('data-checked', 'false'); + await expect(checkboxes.nth(1)).toHaveAttribute('data-checked', 'false'); + + // Включаем все + await allOverridesCheckbox.click(); + + await expect(checkboxes.nth(0)).toHaveAttribute('data-checked', 'true'); + await expect(checkboxes.nth(1)).toHaveAttribute('data-checked', 'true'); + }); + + /** + * Тест-кейс: Персистентность правил переопределения + * + * Цель: Проверить, что правила сохраняются между сессиями. + */ + test('should persist response overrides across sessions', async ({ page, extensionId }) => { + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const overridesTab = page.locator('[role="tab"]:has-text("Overrides")'); + await overridesTab.click(); + + const addOverrideButton = page.locator('[data-test-id="add-response-override-button"]'); + await addOverrideButton.click(); + await page.waitForTimeout(500); + + const urlPatternField = page.locator('[data-test-id="url-pattern-input"] input').first(); + const responseContentField = page.locator('[data-test-id="response-content-input"] input').first(); + + await urlPatternField.fill('https://persist.example.com'); + await responseContentField.fill('{"persist": true}'); + + // Перезагружаем + await page.reload(); + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + // Нужно снова перейти на вкладку + const overridesTabAfterReload = page.locator('[role="tab"]:has-text("Overrides")'); + await overridesTabAfterReload.click(); + + // Данные должны быть на месте + const urlPatternFieldAfter = page.locator('[data-test-id="url-pattern-input"] input').first(); + const responseContentFieldAfter = page.locator('[data-test-id="response-content-input"] input').first(); + + await expect(urlPatternFieldAfter).toHaveValue('https://persist.example.com'); + await expect(responseContentFieldAfter).toHaveValue('{"persist": true}'); + }); +}); + From dd271cf98ead4e2b56ec850d1481ea9b7abc5008 Mon Sep 17 00:00:00 2001 From: artalar Date: Thu, 27 Nov 2025 09:24:08 +0300 Subject: [PATCH 2/3] fix: overrides proper handling --- manifest.chromium.json | 16 ++- manifest.dev.json | 15 ++- manifest.firefox.json | 16 ++- src/assets/content-script.js | 68 +++++++++++++ src/assets/mock-requests.js | 129 ++++++++++++++++++++++++ src/background.ts | 7 +- src/shared/utils/createCspRules.ts | 44 ++++++++ src/shared/utils/createOverrideRules.ts | 75 -------------- src/shared/utils/setBrowserHeaders.ts | 15 ++- vite.config.ts | 8 ++ 10 files changed, 307 insertions(+), 86 deletions(-) create mode 100644 src/assets/content-script.js create mode 100644 src/assets/mock-requests.js create mode 100644 src/shared/utils/createCspRules.ts delete mode 100644 src/shared/utils/createOverrideRules.ts diff --git a/manifest.chromium.json b/manifest.chromium.json index 2ca92dfb..c66ca921 100644 --- a/manifest.chromium.json +++ b/manifest.chromium.json @@ -31,10 +31,24 @@ "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' data: blob:; style-src 'self' 'nonce-cloudhood-extension-style-nonce';" }, + "content_scripts": [ + { + "matches": [""], + "js": ["mock-requests.js"], + "run_at": "document_start", + "world": "MAIN" + }, + { + "matches": [""], + "js": ["content-script.js"], + "run_at": "document_start" + } + ], "web_accessible_resources": [ { "resources": [ - "img/*" + "img/*", + "mock-requests.js" ], "matches": [ "" diff --git a/manifest.dev.json b/manifest.dev.json index 51646ce6..85cbd768 100644 --- a/manifest.dev.json +++ b/manifest.dev.json @@ -29,9 +29,22 @@ "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' ws://localhost:3333; img-src 'self' data: blob:; style-src 'self' 'nonce-cloudhood-extension-style-nonce';" }, + "content_scripts": [ + { + "matches": [""], + "js": ["mock-requests.js"], + "run_at": "document_start", + "world": "MAIN" + }, + { + "matches": [""], + "js": ["content-script.js"], + "run_at": "document_start" + } + ], "web_accessible_resources": [ { - "resources": ["img/*"], + "resources": ["img/*", "mock-requests.js"], "matches": [""] } ] diff --git a/manifest.firefox.json b/manifest.firefox.json index bb5fdafd..a53f7121 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -33,10 +33,24 @@ "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' data: blob: moz-extension:; style-src 'self' 'nonce-cloudhood-extension-style-nonce'; connect-src 'self' data: blob: moz-extension:;" }, + "content_scripts": [ + { + "matches": [""], + "js": ["mock-requests.js"], + "run_at": "document_start", + "world": "MAIN" + }, + { + "matches": [""], + "js": ["content-script.js"], + "run_at": "document_start" + } + ], "web_accessible_resources": [ { "resources": [ - "img/*" + "img/*", + "mock-requests.js" ], "matches": [ "" diff --git a/src/assets/content-script.js b/src/assets/content-script.js new file mode 100644 index 00000000..71650328 --- /dev/null +++ b/src/assets/content-script.js @@ -0,0 +1,68 @@ +// This script runs in the isolated extension world +// It reads storage and communicates with the MAIN world script via window.postMessage + +(function() { + // Helper to communicate with the injected script + function sendOverrides(overrides) { + window.postMessage( + { + type: 'CLOUDHOOD_UPDATE_OVERRIDES', + overrides, + }, + '*', + ); + } + + // Read storage and update overrides + function updateOverrides() { + chrome.storage.local.get( + ['requestHeaderProfilesV1', 'selectedHeaderProfileV1', 'isPausedV1'], + (result) => { + const isPaused = result.isPausedV1; + if (isPaused) { + sendOverrides([]); + return; + } + + let profiles = []; + const profilesData = result.requestHeaderProfilesV1; + + try { + if (typeof profilesData === 'string') { + profiles = JSON.parse(profilesData); + } else if (Array.isArray(profilesData)) { + profiles = profilesData; + } + } catch (e) { + // ignore + } + + const selectedProfileId = result.selectedHeaderProfileV1; + const profile = profiles.find(p => p.id === selectedProfileId); + + if (profile && profile.responseOverrides) { + const activeOverrides = profile.responseOverrides + .filter(o => !o.disabled && o.urlPattern && o.responseContent) + .map(o => ({ + urlPattern: o.urlPattern, + responseContent: o.responseContent, + })); + + sendOverrides(activeOverrides); + } else { + sendOverrides([]); + } + } + ); + } + + // Listen for storage changes + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === 'local') { + updateOverrides(); + } + }); + + // Initial update + updateOverrides(); +})(); diff --git a/src/assets/mock-requests.js b/src/assets/mock-requests.js new file mode 100644 index 00000000..fac608ec --- /dev/null +++ b/src/assets/mock-requests.js @@ -0,0 +1,129 @@ +/* eslint-disable no-console */ +(function () { + // Configuration + let activeOverrides = []; + + // Store original functions + const originalFetch = window.fetch; + const OriginalXHR = window.XMLHttpRequest; + + const log = (title, data) => { + console.groupCollapsed(`%c[Cloudhood] ${title}`, 'color: #00aa00; font-weight: bold;'); + console.log('Data:', data); + console.trace('Stack Trace'); + console.groupEnd(); + } + + + // Listen for updates from the extension + window.addEventListener('message', (event) => { + if (event.source !== window) return; + + if (event.data?.type === 'CLOUDHOOD_UPDATE_OVERRIDES') { + try { + activeOverrides = event.data.overrides.map((o) => ({ + urlPattern: new RegExp(o.urlPattern), + responseContent: o.responseContent, + })); + // console.debug('[Cloudhood] Overrides updated:', activeOverrides.length); + } catch (e) { + console.error('[Cloudhood] Failed to parse overrides:', e); + } + } + }); + + // Patch Fetch + window.fetch = async (input, init) => { + let url; + + if (typeof input === 'string') { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else if (input instanceof Request) { + url = input.url; + } else { + url = String(input); + } + + const override = activeOverrides.find(o => o.urlPattern.test(url)); + + if (override) { + log(`Mocked Fetch: ${url}`, { + url, + response: JSON.parse(override.responseContent), + originalInput: input, + originalInit: init + }); + + return new Response(override.responseContent, { + status: 200, + statusText: 'OK', + headers: { + 'content-type': 'application/json', + } + }); + } + + return originalFetch(input, init); + }; + + // Patch XHR + window.XMLHttpRequest = class CloudhoodXHR extends OriginalXHR { + open(method, url, async = true, username = null, password = null) { + this._method = method; + this._url = url.toString(); + + this._override = activeOverrides.find(o => o.urlPattern.test(this._url)); + + super.open(method, url, async, username, password); + } + + send(body) { + if (this._override) { + log(`Mocked XHR: ${this._url}`, { + url: this._url, + method: this._method, + response: JSON.parse(this._override.responseContent), + body + }); + + setTimeout(() => { + const responseData = this._override.responseContent; + + Object.defineProperty(this, 'readyState', { value: 4, writable: false }); + Object.defineProperty(this, 'status', { value: 200, writable: false }); + Object.defineProperty(this, 'statusText', { value: 'OK', writable: false }); + Object.defineProperty(this, 'responseText', { value: responseData, writable: false }); + Object.defineProperty(this, 'response', { value: responseData, writable: false }); + Object.defineProperty(this, 'responseURL', { value: this._url, writable: false }); + + this.dispatchEvent(new Event('readystatechange')); + + const progressEvent = new ProgressEvent('load', { + loaded: responseData.length, + total: responseData.length, + lengthComputable: true + }); + + this.dispatchEvent(progressEvent); + this.dispatchEvent(new ProgressEvent('loadend')); + + if (this.onreadystatechange) { + this.onreadystatechange(new Event('readystatechange')); + } + if (this.onload) { + this.onload(progressEvent); + } + if (this.onloadend) { + this.onloadend(new ProgressEvent('loadend')); + } + }, 10); + + return; + } + + super.send(body); + } + }; +})(); diff --git a/src/background.ts b/src/background.ts index 6c7bf18a..0e0d792b 100644 --- a/src/background.ts +++ b/src/background.ts @@ -7,7 +7,6 @@ import { browserAction } from './shared/utils/browserAPI'; import { logger, LogLevel } from './shared/utils/logger'; import { setBrowserHeaders } from './shared/utils/setBrowserHeaders'; import { setIconBadge } from './shared/utils/setIconBadge'; -import { getOverrideRules } from './shared/utils/createOverrideRules'; import { enableExtensionReload } from './utils/extension-reload'; logger.configure({ @@ -48,8 +47,10 @@ logger.info('🔍 About to check storage contents...'); // Подсчитываем активные правила для badge const selectedProfile = profiles.find((p: Profile) => p.id === result[BrowserStorageKey.SelectedProfile]); if (selectedProfile) { - const activeHeadersCount = selectedProfile.requestHeaders?.filter((h: RequestHeader) => !h.disabled).length || 0; - const activeOverridesCount = selectedProfile.responseOverrides?.filter((o: ResponseOverride) => !o.disabled).length || 0; + const activeHeadersCount = + selectedProfile.requestHeaders?.filter((h: RequestHeader) => !h.disabled).length || 0; + const activeOverridesCount = + selectedProfile.responseOverrides?.filter((o: ResponseOverride) => !o.disabled).length || 0; activeRulesCount = activeHeadersCount + activeOverridesCount; logger.info(` - Active headers count: ${activeHeadersCount}`); logger.info(` - Active overrides count: ${activeOverridesCount}`); diff --git a/src/shared/utils/createCspRules.ts b/src/shared/utils/createCspRules.ts new file mode 100644 index 00000000..c8a5b094 --- /dev/null +++ b/src/shared/utils/createCspRules.ts @@ -0,0 +1,44 @@ +import browser from 'webextension-polyfill'; + +import { createUrlCondition } from './createUrlCondition'; + +const CSP_RULE_ID_OFFSET = 200000; + +export function getCspRules(urlFilters: string[]): browser.DeclarativeNetRequest.Rule[] { + const action = { + type: 'modifyHeaders' as const, + responseHeaders: [ + { header: 'Content-Security-Policy', operation: 'remove' as const }, + { header: 'Content-Security-Policy-Report-Only', operation: 'remove' as const }, + ], + }; + + const resourceTypes = ['main_frame', 'sub_frame'] as browser.DeclarativeNetRequest.ResourceType[]; + + if (urlFilters.length === 0) { + return [ + { + id: CSP_RULE_ID_OFFSET, + priority: 1, + action, + condition: { + resourceTypes, + }, + }, + ]; + } + + return urlFilters.map((urlFilter, index) => { + const urlCondition = createUrlCondition(urlFilter); + return { + id: CSP_RULE_ID_OFFSET + index, + priority: 1, + action, + condition: { + ...urlCondition, + resourceTypes, + }, + }; + }); +} + diff --git a/src/shared/utils/createOverrideRules.ts b/src/shared/utils/createOverrideRules.ts deleted file mode 100644 index 80e6f90f..00000000 --- a/src/shared/utils/createOverrideRules.ts +++ /dev/null @@ -1,75 +0,0 @@ -import browser from 'webextension-polyfill'; - -import type { ResponseOverride } from '#entities/request-profile/types'; - -import { logger } from './logger'; - -const OVERRIDE_RULE_ID_OFFSET = 100000; - -function isValidRegex(pattern: string): boolean { - try { - new RegExp(pattern); - return true; - } catch { - return false; - } -} - -export function getOverrideRules(responseOverrides: ResponseOverride[]): browser.DeclarativeNetRequest.Rule[] { - const allResourceTypes = [ - 'main_frame', - 'sub_frame', - 'stylesheet', - 'script', - 'image', - 'font', - 'object', - 'xmlhttprequest', - 'ping', - 'csp_report', - 'media', - 'websocket', - 'other', - ] as browser.DeclarativeNetRequest.ResourceType[]; - - const rules: browser.DeclarativeNetRequest.Rule[] = []; - - responseOverrides.forEach((override, index) => { - if (!override.urlPattern.trim() || !override.responseContent.trim()) { - return; - } - - try { - const urlPattern = override.urlPattern.trim(); - - if (!isValidRegex(urlPattern)) { - logger.warn(`Invalid URL regex: ${urlPattern}`); - return; - } - - const encodedContent = encodeURIComponent(override.responseContent); - const dataUrl = `data:application/json;charset=utf-8,${encodedContent}`; - - rules.push({ - id: OVERRIDE_RULE_ID_OFFSET + index, - priority: 1, - action: { - type: 'redirect' as const, - redirect: { - url: dataUrl, - }, - }, - condition: { - regexFilter: urlPattern, - resourceTypes: allResourceTypes, - }, - }); - - logger.debug(`Created override rule for regex: ${urlPattern}`); - } catch (error) { - logger.error(`Failed to create override rule for ${override.id}:`, error); - } - }); - - return rules; -} diff --git a/src/shared/utils/setBrowserHeaders.ts b/src/shared/utils/setBrowserHeaders.ts index 11f42586..e36f7c4d 100644 --- a/src/shared/utils/setBrowserHeaders.ts +++ b/src/shared/utils/setBrowserHeaders.ts @@ -3,7 +3,7 @@ import browser from 'webextension-polyfill'; import type { Profile, RequestHeader } from '#entities/request-profile/types'; import { BrowserStorageKey } from '#shared/constants'; -import { getOverrideRules } from './createOverrideRules'; +import { getCspRules } from './createCspRules'; import { createUrlCondition } from './createUrlCondition'; import { validateHeader } from './headers'; import { logger } from './logger'; @@ -137,11 +137,16 @@ export async function setBrowserHeaders(result: Record) { ? activeHeaders.flatMap(header => getRulesForHeader(header, activeUrlFilters)) : []; - const overrideRules: browser.DeclarativeNetRequest.Rule[] = !isPaused - ? getOverrideRules(activeResponseOverrides) - : []; + const overrideRules: browser.DeclarativeNetRequest.Rule[] = []; + + // We apply CSP rules globally because we need to strip CSP from the initiator page, + // which might be different from the request target (matched by urlFilters). + // For now, we only need CSP rules if we were using data URI redirects, but we are moving to injection. + // However, keeping them doesn't hurt and might help with other restrictions. + const cspRules: browser.DeclarativeNetRequest.Rule[] = + !isPaused && activeResponseOverrides.length > 0 ? getCspRules([]) : []; - const addRules = [...headerRules, ...overrideRules]; + const addRules = [...headerRules, ...overrideRules, ...cspRules]; const removeRuleIds = currentRules.map(item => item.id); diff --git a/vite.config.ts b/vite.config.ts index 89e7f298..94778dbd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -145,6 +145,14 @@ export default defineConfig(({ mode }) => { { src: 'node_modules/@snack-uikit/icons/dist/esm/sprite/svg/sprite.symbol.svg', dest: '' + }, + { + src: 'src/assets/mock-requests.js', + dest: '' + }, + { + src: 'src/assets/content-script.js', + dest: '' } ] }), From 689269a7ce6473d5d4e2e8cb4c80307c85d56b40 Mon Sep 17 00:00:00 2001 From: aaarutyunyan Date: Mon, 29 Dec 2025 18:08:51 +0300 Subject: [PATCH 3/3] fix: some cleanups --- src/assets/content-script.js | 4 +- src/assets/mock-requests.js | 134 ++++++++++-------- src/features/export-profile/model.ts | 6 +- .../ProfileActions/ProfileActions.tsx | 5 +- ...eateCspRules.ts => createOverrideRules.ts} | 16 ++- src/shared/utils/setBrowserHeaders.ts | 15 +- .../components/OverrideRow/OverrideRow.tsx | 2 +- 7 files changed, 96 insertions(+), 86 deletions(-) rename src/shared/utils/{createCspRules.ts => createOverrideRules.ts} (62%) diff --git a/src/assets/content-script.js b/src/assets/content-script.js index 71650328..2afe1989 100644 --- a/src/assets/content-script.js +++ b/src/assets/content-script.js @@ -9,7 +9,7 @@ type: 'CLOUDHOOD_UPDATE_OVERRIDES', overrides, }, - '*', + window.location.origin, ); } @@ -33,7 +33,7 @@ } else if (Array.isArray(profilesData)) { profiles = profilesData; } - } catch (e) { + } catch { // ignore } diff --git a/src/assets/mock-requests.js b/src/assets/mock-requests.js index fac608ec..c0875ffe 100644 --- a/src/assets/mock-requests.js +++ b/src/assets/mock-requests.js @@ -20,15 +20,10 @@ if (event.source !== window) return; if (event.data?.type === 'CLOUDHOOD_UPDATE_OVERRIDES') { - try { - activeOverrides = event.data.overrides.map((o) => ({ - urlPattern: new RegExp(o.urlPattern), - responseContent: o.responseContent, - })); - // console.debug('[Cloudhood] Overrides updated:', activeOverrides.length); - } catch (e) { - console.error('[Cloudhood] Failed to parse overrides:', e); - } + activeOverrides = event.data.overrides.map((o) => ({ + urlPattern: o.urlPattern, + responseContent: o.responseContent, + })); } }); @@ -46,23 +41,30 @@ url = String(input); } - const override = activeOverrides.find(o => o.urlPattern.test(url)); + const override = activeOverrides.find(o => url.includes(o.urlPattern)); if (override) { - log(`Mocked Fetch: ${url}`, { - url, - response: JSON.parse(override.responseContent), - originalInput: input, - originalInit: init - }); - - return new Response(override.responseContent, { - status: 200, - statusText: 'OK', - headers: { - 'content-type': 'application/json', - } - }); + try { + const parsedResponse = JSON.parse(override.responseContent); + + log(`Mocked Fetch: ${url}`, { + url, + response: parsedResponse, + originalInput: input, + originalInit: init + }); + + return new Response(override.responseContent, { + status: 200, + statusText: 'OK', + headers: { + 'content-type': 'application/json', + } + }); + } catch (error) { + console.warn('[Cloudhood] Invalid JSON in override for URL:', url, error); + return originalFetch(input, init); + } } return originalFetch(input, init); @@ -74,53 +76,59 @@ this._method = method; this._url = url.toString(); - this._override = activeOverrides.find(o => o.urlPattern.test(this._url)); + this._override = activeOverrides.find(o => this._url.includes(o.urlPattern)); super.open(method, url, async, username, password); } send(body) { if (this._override) { - log(`Mocked XHR: ${this._url}`, { - url: this._url, - method: this._method, - response: JSON.parse(this._override.responseContent), - body - }); - - setTimeout(() => { - const responseData = this._override.responseContent; - - Object.defineProperty(this, 'readyState', { value: 4, writable: false }); - Object.defineProperty(this, 'status', { value: 200, writable: false }); - Object.defineProperty(this, 'statusText', { value: 'OK', writable: false }); - Object.defineProperty(this, 'responseText', { value: responseData, writable: false }); - Object.defineProperty(this, 'response', { value: responseData, writable: false }); - Object.defineProperty(this, 'responseURL', { value: this._url, writable: false }); - - this.dispatchEvent(new Event('readystatechange')); - - const progressEvent = new ProgressEvent('load', { - loaded: responseData.length, - total: responseData.length, - lengthComputable: true + try { + const parsedResponse = JSON.parse(this._override.responseContent); + + log(`Mocked XHR: ${this._url}`, { + url: this._url, + method: this._method, + response: parsedResponse, + body }); - this.dispatchEvent(progressEvent); - this.dispatchEvent(new ProgressEvent('loadend')); - - if (this.onreadystatechange) { - this.onreadystatechange(new Event('readystatechange')); - } - if (this.onload) { - this.onload(progressEvent); - } - if (this.onloadend) { - this.onloadend(new ProgressEvent('loadend')); - } - }, 10); - - return; + setTimeout(() => { + const responseData = this._override.responseContent; + + Object.defineProperty(this, 'readyState', { value: 4, writable: false }); + Object.defineProperty(this, 'status', { value: 200, writable: false }); + Object.defineProperty(this, 'statusText', { value: 'OK', writable: false }); + Object.defineProperty(this, 'responseText', { value: responseData, writable: false }); + Object.defineProperty(this, 'response', { value: responseData, writable: false }); + Object.defineProperty(this, 'responseURL', { value: this._url, writable: false }); + + this.dispatchEvent(new Event('readystatechange')); + + const progressEvent = new ProgressEvent('load', { + loaded: responseData.length, + total: responseData.length, + lengthComputable: true + }); + + this.dispatchEvent(progressEvent); + this.dispatchEvent(new ProgressEvent('loadend')); + + if (this.onreadystatechange) { + this.onreadystatechange(new Event('readystatechange')); + } + if (this.onload) { + this.onload(progressEvent); + } + if (this.onloadend) { + this.onloadend(new ProgressEvent('loadend')); + } + }, 10); + + return; + } catch (error) { + console.warn('[Cloudhood] Invalid JSON in override for XHR URL:', this._url, error); + } } super.send(body); diff --git a/src/features/export-profile/model.ts b/src/features/export-profile/model.ts index c6fc9f03..302656a1 100644 --- a/src/features/export-profile/model.ts +++ b/src/features/export-profile/model.ts @@ -39,11 +39,11 @@ export const $profileExportString = combine( return JSON.stringify( profiles .filter(({ id }) => selectedExportProfileIdList.includes(id)) - .map(({ id, requestHeaders, responseOverrides, ...rest }) => ({ + .map(({ requestHeaders, responseOverrides, ...rest }) => ({ ...rest, - requestHeaders: requestHeaders.map(({ id, ...headerRest }) => headerRest), + requestHeaders: requestHeaders.map(({ ...headerRest }) => headerRest), ...(responseOverrides && { - responseOverrides: responseOverrides.map(({ id, ...overrideRest }) => overrideRest), + responseOverrides: responseOverrides.map(({ ...overrideRest }) => overrideRest), }), })) || [], ); diff --git a/src/pages/main/components/ProfileActions/ProfileActions.tsx b/src/pages/main/components/ProfileActions/ProfileActions.tsx index cbc1a8ac..d30e7fbf 100644 --- a/src/pages/main/components/ProfileActions/ProfileActions.tsx +++ b/src/pages/main/components/ProfileActions/ProfileActions.tsx @@ -6,9 +6,8 @@ import { $isPaused } from '#entities/is-paused/model'; import { $activeProfileActionsTab, profileActionsTabChanged } from '#entities/profile-actions'; import { $selectedProfileActiveRequestHeadersCount, - $selectedProfileActiveUrlFiltersCount, - $selectedProfileActiveResponseOverridesCount -} from '#entities/request-profile/model'; + $selectedProfileActiveResponseOverridesCount, + $selectedProfileActiveUrlFiltersCount} from '#entities/request-profile/model'; import { getCounterProps } from '#shared/utils/getCounterProps'; import { OverridesActions } from './OverridesActions'; diff --git a/src/shared/utils/createCspRules.ts b/src/shared/utils/createOverrideRules.ts similarity index 62% rename from src/shared/utils/createCspRules.ts rename to src/shared/utils/createOverrideRules.ts index c8a5b094..ce0a718e 100644 --- a/src/shared/utils/createCspRules.ts +++ b/src/shared/utils/createOverrideRules.ts @@ -1,10 +1,16 @@ import browser from 'webextension-polyfill'; +import type { ResponseOverride } from '#entities/request-profile/types'; + import { createUrlCondition } from './createUrlCondition'; const CSP_RULE_ID_OFFSET = 200000; -export function getCspRules(urlFilters: string[]): browser.DeclarativeNetRequest.Rule[] { +export function getOverrideRules(responseOverrides: ResponseOverride[]): browser.DeclarativeNetRequest.Rule[] { + if (responseOverrides.length === 0) { + return []; + } + const action = { type: 'modifyHeaders' as const, responseHeaders: [ @@ -15,7 +21,9 @@ export function getCspRules(urlFilters: string[]): browser.DeclarativeNetRequest const resourceTypes = ['main_frame', 'sub_frame'] as browser.DeclarativeNetRequest.ResourceType[]; - if (urlFilters.length === 0) { + const uniqueUrlPatterns = [...new Set(responseOverrides.map(o => o.urlPattern).filter(Boolean))]; + + if (uniqueUrlPatterns.length === 0) { return [ { id: CSP_RULE_ID_OFFSET, @@ -28,8 +36,8 @@ export function getCspRules(urlFilters: string[]): browser.DeclarativeNetRequest ]; } - return urlFilters.map((urlFilter, index) => { - const urlCondition = createUrlCondition(urlFilter); + return uniqueUrlPatterns.map((urlPattern, index) => { + const urlCondition = createUrlCondition(urlPattern); return { id: CSP_RULE_ID_OFFSET + index, priority: 1, diff --git a/src/shared/utils/setBrowserHeaders.ts b/src/shared/utils/setBrowserHeaders.ts index e36f7c4d..11f42586 100644 --- a/src/shared/utils/setBrowserHeaders.ts +++ b/src/shared/utils/setBrowserHeaders.ts @@ -3,7 +3,7 @@ import browser from 'webextension-polyfill'; import type { Profile, RequestHeader } from '#entities/request-profile/types'; import { BrowserStorageKey } from '#shared/constants'; -import { getCspRules } from './createCspRules'; +import { getOverrideRules } from './createOverrideRules'; import { createUrlCondition } from './createUrlCondition'; import { validateHeader } from './headers'; import { logger } from './logger'; @@ -137,16 +137,11 @@ export async function setBrowserHeaders(result: Record) { ? activeHeaders.flatMap(header => getRulesForHeader(header, activeUrlFilters)) : []; - const overrideRules: browser.DeclarativeNetRequest.Rule[] = []; - - // We apply CSP rules globally because we need to strip CSP from the initiator page, - // which might be different from the request target (matched by urlFilters). - // For now, we only need CSP rules if we were using data URI redirects, but we are moving to injection. - // However, keeping them doesn't hurt and might help with other restrictions. - const cspRules: browser.DeclarativeNetRequest.Rule[] = - !isPaused && activeResponseOverrides.length > 0 ? getCspRules([]) : []; + const overrideRules: browser.DeclarativeNetRequest.Rule[] = !isPaused + ? getOverrideRules(activeResponseOverrides) + : []; - const addRules = [...headerRules, ...overrideRules, ...cspRules]; + const addRules = [...headerRules, ...overrideRules]; const removeRuleIds = currentRules.map(item => item.id); diff --git a/src/widgets/overrides/components/OverrideRow/OverrideRow.tsx b/src/widgets/overrides/components/OverrideRow/OverrideRow.tsx index 27bc1ec1..1ee56dff 100644 --- a/src/widgets/overrides/components/OverrideRow/OverrideRow.tsx +++ b/src/widgets/overrides/components/OverrideRow/OverrideRow.tsx @@ -54,7 +54,7 @@ export function OverrideRow(props: ResponseOverride) { data-test-id='url-pattern-input' size='m' value={urlPattern} - placeholder='URL Regex' + placeholder='URL Pattern' onChange={handleChange('urlPattern')} onKeyDown={handleKeyPress} showClearButton={false}