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..2afe1989 --- /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, + }, + window.location.origin, + ); + } + + // 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 { + // 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..c0875ffe --- /dev/null +++ b/src/assets/mock-requests.js @@ -0,0 +1,137 @@ +/* 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') { + activeOverrides = event.data.overrides.map((o) => ({ + urlPattern: o.urlPattern, + responseContent: o.responseContent, + })); + } + }); + + // 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 => url.includes(o.urlPattern)); + + if (override) { + 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); + }; + + // 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 => this._url.includes(o.urlPattern)); + + super.open(method, url, async, username, password); + } + + send(body) { + if (this._override) { + try { + const parsedResponse = JSON.parse(this._override.responseContent); + + log(`Mocked XHR: ${this._url}`, { + url: this._url, + method: this._method, + response: parsedResponse, + 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; + } catch (error) { + console.warn('[Cloudhood] Invalid JSON in override for XHR URL:', this._url, error); + } + } + + super.send(body); + } + }; +})(); diff --git a/src/background.ts b/src/background.ts index 763e280a..0e0d792b 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,6 +1,6 @@ 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'; @@ -36,7 +36,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 +44,17 @@ 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 +67,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..302656a1 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(({ requestHeaders, responseOverrides, ...rest }) => ({ ...rest, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- если модель будет расширять, то потенциально будет ошибка - requestHeaders: requestHeaders.map(({ id, ...headerRest }) => headerRest), + requestHeaders: requestHeaders.map(({ ...headerRest }) => headerRest), + ...(responseOverrides && { + responseOverrides: responseOverrides.map(({ ...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..d30e7fbf 100644 --- a/src/pages/main/components/ProfileActions/ProfileActions.tsx +++ b/src/pages/main/components/ProfileActions/ProfileActions.tsx @@ -4,19 +4,24 @@ 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, + $selectedProfileActiveResponseOverridesCount, + $selectedProfileActiveUrlFiltersCount} 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 +38,11 @@ export function ProfileActions() { counter={getCounterProps(activeUrlFiltersCount)} value='url-filters' /> + @@ -40,6 +50,9 @@ export function ProfileActions() { + + + diff --git a/src/shared/utils/createOverrideRules.ts b/src/shared/utils/createOverrideRules.ts new file mode 100644 index 00000000..ce0a718e --- /dev/null +++ b/src/shared/utils/createOverrideRules.ts @@ -0,0 +1,52 @@ +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 getOverrideRules(responseOverrides: ResponseOverride[]): browser.DeclarativeNetRequest.Rule[] { + if (responseOverrides.length === 0) { + return []; + } + + 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[]; + + const uniqueUrlPatterns = [...new Set(responseOverrides.map(o => o.urlPattern).filter(Boolean))]; + + if (uniqueUrlPatterns.length === 0) { + return [ + { + id: CSP_RULE_ID_OFFSET, + priority: 1, + action, + condition: { + resourceTypes, + }, + }, + ]; + } + + return uniqueUrlPatterns.map((urlPattern, index) => { + const urlCondition = createUrlCondition(urlPattern); + return { + id: CSP_RULE_ID_OFFSET + index, + priority: 1, + action, + condition: { + ...urlCondition, + resourceTypes, + }, + }; + }); +} + 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..1ee56dff --- /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}'); + }); +}); + 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: '' } ] }),