diff --git a/scripts/firefox-screenshots.mjs b/scripts/firefox-screenshots.mjs index 8a246a7c..f8d21684 100644 --- a/scripts/firefox-screenshots.mjs +++ b/scripts/firefox-screenshots.mjs @@ -172,9 +172,10 @@ const scenarios = [ await activateHeaders(); await click(selectors.profileNameEditButton); }), - scenario('profiles', 'profile-actions-menu', async ({ activateHeaders, click }) => { + scenario('profiles', 'profile-actions-menu', async ({ activateHeaders, click, hideMenuItem }) => { await activateHeaders(); await click(selectors.profileActionsButton); + await hideMenuItem('Share headers by URL'); }), scenario('url-filters', 'empty-state', async ({ activateUrlFilters }) => { await activateUrlFilters(); @@ -312,6 +313,14 @@ function createBrowserHelpers(driver, popupUrl) { click, clickMenuItem, fill, + hideMenuItem: async (text) => { + const xpath = `//*[@role="menuitem" and contains(normalize-space(.), "${text}")]`; + const item = (await driver.findElements(By.xpath(xpath)))[0]; + if (!item) { + throw new Error(`Missing menu item "${text}"`); + } + await driver.executeScript('arguments[0].style.display = "none"', item); + }, removeAllCookies: async () => { await click(selectors.removeAllCookiesButton); }, diff --git a/src/background.ts b/src/background.ts index a999ab8b..7d8c7d9f 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,5 +1,7 @@ import browser from 'webextension-polyfill'; +import { importSharedHeadersFromTab } from '#features/share-headers-by-url/background'; + import { BrowserStorageKey, ServiceWorkerEvent } from './shared/constants'; import { browserAction } from './shared/utils/browserAPI'; import { logger, LogLevel } from './shared/utils/logger'; @@ -170,6 +172,11 @@ browser.runtime.onInstalled.addListener(async details => { browser.tabs.onActivated.addListener(async activeInfo => { logger.debug('Tab activated:', activeInfo); + const tab = await browser.tabs.get(activeInfo.tabId); + if (await importSharedHeadersFromTab(activeInfo.tabId, tab.url)) { + return; + } + const result = await browser.storage.local.get([ BrowserStorageKey.Profiles, BrowserStorageKey.SelectedProfile, @@ -181,7 +188,6 @@ browser.tabs.onActivated.addListener(async activeInfo => { if (Object.keys(result).length) { logger.info('📱 Tab activated, updating headers'); try { - const tab = await browser.tabs.get(activeInfo.tabId); await setBrowserHeaders(result, tab.url); } catch (error) { logger.error('Failed to set browser headers on tab activation:', error); @@ -192,6 +198,11 @@ browser.tabs.onActivated.addListener(async activeInfo => { }); browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + const updatedUrl = changeInfo.url ?? (changeInfo.status === 'complete' ? tab.url : undefined); + if (updatedUrl && (await importSharedHeadersFromTab(tabId, updatedUrl))) { + return; + } + if (changeInfo.status !== 'complete') return; const activeTabs = await browser.tabs.query({ active: true, currentWindow: true }); diff --git a/src/entities/modal/model/index.ts b/src/entities/modal/model/index.ts index 38b78b8b..7f5598a5 100644 --- a/src/entities/modal/model/index.ts +++ b/src/entities/modal/model/index.ts @@ -1,3 +1,4 @@ export * from './import-profile-modal'; export * from './import-profile-from-extension-modal'; export * from './export-profile-modal'; +export * from './share-headers-by-url-modal'; diff --git a/src/entities/modal/model/share-headers-by-url-modal.ts b/src/entities/modal/model/share-headers-by-url-modal.ts new file mode 100644 index 00000000..174bbaa4 --- /dev/null +++ b/src/entities/modal/model/share-headers-by-url-modal.ts @@ -0,0 +1,8 @@ +import { createEvent, createStore } from 'effector'; + +export const shareHeadersByUrlModalOpened = createEvent(); +export const shareHeadersByUrlModalClosed = createEvent(); + +export const $isShareHeadersByUrlModalOpen = createStore(false) + .on(shareHeadersByUrlModalOpened, () => true) + .on(shareHeadersByUrlModalClosed, () => false); diff --git a/src/features/share-headers-by-url/__tests__/utils.spec.ts b/src/features/share-headers-by-url/__tests__/utils.spec.ts new file mode 100644 index 00000000..d71c31ee --- /dev/null +++ b/src/features/share-headers-by-url/__tests__/utils.spec.ts @@ -0,0 +1,93 @@ +import type { Profile, RequestHeader } from '#entities/request-profile/types'; + +import { + createProfileFromSharedHeaders, + createSharedHeadersUrl, + normalizeSharedHeadersOrigin, + type ParsedSharedHeadersUrl, + parseSharedHeadersUrl, + SHARE_HEADERS_FRAGMENT_KEY, +} from '../utils'; + +function encodePayload(payload: unknown) { + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + let binary = ''; + bytes.forEach(byte => { + binary += String.fromCharCode(byte); + }); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); +} + +describe('share headers by URL', () => { + it.each([ + ['example.com', 'https://example.com'], + ['https://example.com', 'https://example.com'], + ['http://localhost:3000', 'http://localhost:3000'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeSharedHeadersOrigin(input)).toBe(expected); + }); + + it.each(['', 'ftp://example.com', 'https://user:password@example.com', 'https://example.com/path'])( + 'rejects invalid domain input %s', + input => { + expect(() => normalizeSharedHeadersOrigin(input)).toThrow(); + }, + ); + + it('serializes only active valid headers and parses them back', () => { + const headers: RequestHeader[] = [ + { id: 1, name: 'X-Active', value: 'café', disabled: false }, + { id: 2, name: 'X-Disabled', value: 'ignored', disabled: true }, + { id: 3, name: '', value: '', disabled: false }, + ]; + + const sharedUrl = createSharedHeadersUrl('example.com', headers); + const parsed = parseSharedHeadersUrl(sharedUrl); + + expect(sharedUrl).toMatch(/^https:\/\/example\.com\/#__cloudhood=/u); + expect(parsed).toEqual({ + headers: [{ name: 'X-Active', value: 'café' }], + hostname: 'example.com', + origin: 'https://example.com', + sanitizedUrl: 'https://example.com/', + }); + }); + + it('rejects malformed, unsupported, and invalid payloads', () => { + const createUrl = (payload: unknown) => + `https://example.com/#${SHARE_HEADERS_FRAGMENT_KEY}=${encodePayload(payload)}`; + + expect(parseSharedHeadersUrl('https://example.com/')).toBeNull(); + expect(parseSharedHeadersUrl(`https://example.com/#${SHARE_HEADERS_FRAGMENT_KEY}=not-base64`)).toBeNull(); + expect(parseSharedHeadersUrl(createUrl({ version: 2, headers: [{ name: 'X-Test', value: 'value' }] }))).toBeNull(); + expect(parseSharedHeadersUrl(createUrl({ version: 1, headers: [{ name: '', value: '' }] }))).toBeNull(); + }); + + it('creates an active selected-origin profile without cookies', () => { + const existingProfiles: Profile[] = [ + { + id: '1', + requestHeaders: [{ id: 2, name: 'X-Existing', value: 'value', disabled: false }], + requestCookies: [], + urlFilters: [], + }, + ]; + const parsed = parseSharedHeadersUrl( + createSharedHeadersUrl('http://localhost:3000', [ + { id: 3, name: 'Authorization', value: 'Bearer token', disabled: false }, + ]), + ); + + expect(parsed).not.toBeNull(); + const profile = createProfileFromSharedHeaders(parsed as ParsedSharedHeadersUrl, existingProfiles); + + expect(profile.id).not.toBe('1'); + expect(profile.name).toBe('Shared: localhost'); + expect(profile.requestHeaders).toEqual([ + expect.objectContaining({ name: 'Authorization', value: 'Bearer token', disabled: false }), + ]); + expect(profile.requestHeaders[0].id).not.toBe(2); + expect(profile.requestCookies).toEqual([]); + expect(profile.urlFilters).toEqual([expect.objectContaining({ value: 'http://localhost:3000', disabled: false })]); + }); +}); diff --git a/src/features/share-headers-by-url/background.ts b/src/features/share-headers-by-url/background.ts new file mode 100644 index 00000000..394057c3 --- /dev/null +++ b/src/features/share-headers-by-url/background.ts @@ -0,0 +1,70 @@ +import browser from 'webextension-polyfill'; + +import type { Profile } from '#entities/request-profile/types'; +import { BrowserStorageKey } from '#shared/constants'; +import { logger } from '#shared/utils/logger'; +import { setBrowserHeaders } from '#shared/utils/setBrowserHeaders'; + +import { createProfileFromSharedHeaders, parseSharedHeadersUrl, SHARE_HEADERS_FRAGMENT_KEY } from './utils'; + +const processingTabs = new Map(); + +function readProfiles(value: unknown): Profile[] { + if (typeof value !== 'string') { + return []; + } + + try { + const profiles = JSON.parse(value) as unknown; + return Array.isArray(profiles) ? (profiles as Profile[]) : []; + } catch { + return []; + } +} + +export async function importSharedHeadersFromTab(tabId: number, tabUrl?: string) { + if (!tabUrl) { + return false; + } + + const parsedUrl = parseSharedHeadersUrl(tabUrl); + if (!parsedUrl) { + if (tabUrl.includes(`${SHARE_HEADERS_FRAGMENT_KEY}=`)) { + logger.warn('Ignoring invalid shared headers payload'); + } + return false; + } + + if (processingTabs.get(tabId) === tabUrl) { + return false; + } + + processingTabs.set(tabId, tabUrl); + + try { + const storage = await browser.storage.local.get([BrowserStorageKey.Profiles, BrowserStorageKey.IsPaused]); + const profiles = readProfiles(storage[BrowserStorageKey.Profiles]); + const profile = createProfileFromSharedHeaders(parsedUrl, profiles); + const updatedProfiles = [...profiles, profile]; + const updatedStorage = { + ...storage, + [BrowserStorageKey.Profiles]: JSON.stringify(updatedProfiles), + [BrowserStorageKey.SelectedProfile]: profile.id, + }; + + await browser.storage.local.set({ + [BrowserStorageKey.Profiles]: updatedStorage[BrowserStorageKey.Profiles], + [BrowserStorageKey.SelectedProfile]: profile.id, + }); + await setBrowserHeaders(updatedStorage, parsedUrl.sanitizedUrl); + await browser.tabs.update(tabId, { url: parsedUrl.sanitizedUrl }); + + logger.info(`Imported shared headers for ${parsedUrl.origin}`); + return true; + } catch (error) { + logger.error('Failed to import shared headers from URL:', error); + return false; + } finally { + processingTabs.delete(tabId); + } +} diff --git a/src/features/share-headers-by-url/index.ts b/src/features/share-headers-by-url/index.ts new file mode 100644 index 00000000..04bca77e --- /dev/null +++ b/src/features/share-headers-by-url/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/src/features/share-headers-by-url/utils.ts b/src/features/share-headers-by-url/utils.ts new file mode 100644 index 00000000..477c888e --- /dev/null +++ b/src/features/share-headers-by-url/utils.ts @@ -0,0 +1,156 @@ +import type { Profile, RequestHeader } from '#entities/request-profile/types'; +import { generateIdWithExcludeList } from '#shared/utils/generateId'; +import { validateHeader } from '#shared/utils/headers'; + +export const SHARE_HEADERS_FRAGMENT_KEY = '__cloudhood'; + +export type ShareHeadersPayloadV1 = { + version: 1; + headers: Array>; +}; + +export type ParsedSharedHeadersUrl = { + headers: ShareHeadersPayloadV1['headers']; + hostname: string; + origin: string; + sanitizedUrl: string; +}; + +function encodeBase64Url(value: string) { + const bytes = new TextEncoder().encode(value); + let binary = ''; + + bytes.forEach(byte => { + binary += String.fromCharCode(byte); + }); + + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); +} + +function decodeBase64Url(value: string) { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '='); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, char => char.charCodeAt(0)); + + return new TextDecoder().decode(bytes); +} + +export function normalizeSharedHeadersOrigin(input: string) { + const trimmedInput = input.trim(); + if (!trimmedInput) { + throw new Error('Enter a domain'); + } + + const normalizedInput = /^https?:\/\//iu.test(trimmedInput) ? trimmedInput : `https://${trimmedInput}`; + let url: URL; + + try { + url = new URL(normalizedInput); + } catch { + throw new Error('Enter a valid domain or HTTP(S) origin'); + } + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('Only HTTP and HTTPS domains are supported'); + } + + if (url.username || url.password) { + throw new Error('Domain must not contain credentials'); + } + + if (url.pathname !== '/' || url.search || url.hash) { + throw new Error('Enter a domain without a path, query, or fragment'); + } + + return url.origin; +} + +export function createSharedHeadersUrl(domain: string, requestHeaders: RequestHeader[]) { + const origin = normalizeSharedHeadersOrigin(domain); + const headers = requestHeaders + .filter(({ disabled, name, value }) => !disabled && validateHeader(name, value)) + .map(({ name, value }) => ({ name, value })); + + if (headers.length === 0) { + throw new Error('The selected profile has no active valid headers'); + } + + const payload: ShareHeadersPayloadV1 = { version: 1, headers }; + const url = new URL(origin); + url.hash = `${SHARE_HEADERS_FRAGMENT_KEY}=${encodeBase64Url(JSON.stringify(payload))}`; + + return url.toString(); +} + +export function parseSharedHeadersUrl(value: string): ParsedSharedHeadersUrl | null { + let url: URL; + + try { + url = new URL(value); + } catch { + return null; + } + + const fragment = new URLSearchParams(url.hash.slice(1)); + const encodedPayload = fragment.get(SHARE_HEADERS_FRAGMENT_KEY); + if (!encodedPayload) { + return null; + } + + try { + const payload = JSON.parse(decodeBase64Url(encodedPayload)) as Partial; + if ( + payload.version !== 1 || + !Array.isArray(payload.headers) || + payload.headers.length === 0 || + !payload.headers.every( + header => + typeof header === 'object' && + header !== null && + typeof header.name === 'string' && + typeof header.value === 'string' && + validateHeader(header.name, header.value), + ) + ) { + return null; + } + + fragment.delete(SHARE_HEADERS_FRAGMENT_KEY); + const remainingFragment = fragment.toString(); + url.hash = remainingFragment ? remainingFragment : ''; + + return { + headers: payload.headers, + hostname: url.hostname, + origin: url.origin, + sanitizedUrl: url.toString(), + }; + } catch { + return null; + } +} + +export function createProfileFromSharedHeaders( + parsedUrl: ParsedSharedHeadersUrl, + existingProfiles: Profile[], +): Profile { + const profileIds = existingProfiles.map(profile => Number(profile.id)).filter(Number.isFinite); + const headerIds = existingProfiles.flatMap(profile => profile.requestHeaders.map(header => header.id)); + const allocatedHeaderIds = [...headerIds]; + + const requestHeaders = parsedUrl.headers.map(header => { + const id = generateIdWithExcludeList(allocatedHeaderIds); + allocatedHeaderIds.push(id); + + return { ...header, id, disabled: false }; + }); + + return { + id: generateIdWithExcludeList(profileIds).toString(), + name: `Shared: ${parsedUrl.hostname}`, + requestHeaders, + requestCookies: [], + urlFilters: [{ id: generateIdWithExcludeList(), value: parsedUrl.origin, disabled: false }], + }; +} diff --git a/src/widgets/header/hooks.tsx b/src/widgets/header/hooks.tsx index df4c4b88..e08e9642 100644 --- a/src/widgets/header/hooks.tsx +++ b/src/widgets/header/hooks.tsx @@ -1,11 +1,16 @@ import { useUnit } from 'effector-react'; import { useCallback, useMemo } from 'react'; -import { DownloadSVG, PlusSVG, UploadSVG } from '@snack-uikit/icons'; +import { ArrowLinksSVG, DownloadSVG, PlusSVG, UploadSVG } from '@snack-uikit/icons'; -import { exportModalOpened, importFromExtensionModalOpened, importModalOpened } from '#entities/modal/model'; +import { + exportModalOpened, + importFromExtensionModalOpened, + importModalOpened, + shareHeadersByUrlModalOpened, +} from '#entities/modal/model'; import { $activeProfileActionsTab, profileActionsTabChanged } from '#entities/profile-actions'; -import { profileAdded } from '#entities/request-profile/model'; +import { $selectedProfileActiveRequestHeadersCount, profileAdded } from '#entities/request-profile/model'; import { profileUrlFiltersAdded } from '#features/selected-profile-url-filters/add/model'; import { FileOpenSVG, FileUploadSVG } from '#shared/assets/svg'; @@ -21,16 +26,20 @@ export function useActions({ onClose }: UseActionsProps) { onImportFromExtensionModalOpened, onExportModalOpened, + onShareHeadersByUrlModalOpened, onProfileUrlFiltersAdded, onProfileActionsTabChanged, + activeRequestHeadersCount, ] = useUnit([ $activeProfileActionsTab, profileAdded, importModalOpened, importFromExtensionModalOpened, exportModalOpened, + shareHeadersByUrlModalOpened, profileUrlFiltersAdded, profileActionsTabChanged, + $selectedProfileActiveRequestHeadersCount, ]); const handleAddProfile = useCallback(() => { @@ -53,6 +62,11 @@ export function useActions({ onClose }: UseActionsProps) { onClose(); }, [onClose, onExportModalOpened]); + const handleShareHeadersByUrlModalOpened = useCallback(() => { + onShareHeadersByUrlModalOpened(); + onClose(); + }, [onClose, onShareHeadersByUrlModalOpened]); + const handleAddUrlFilter = useCallback(() => { onProfileUrlFiltersAdded(); if (activeTab !== 'url-filters') { @@ -93,6 +107,13 @@ export function useActions({ onClose }: UseActionsProps) { beforeContent: , onClick: handleExportModalOpened, }, + { + id: 'share-headers-by-url', + content: { option: 'Share headers by URL' }, + beforeContent: , + onClick: handleShareHeadersByUrlModalOpened, + disabled: activeRequestHeadersCount === 0, + }, ], [ handleAddProfile, @@ -100,6 +121,8 @@ export function useActions({ onClose }: UseActionsProps) { handleOpenImportFromExtensionModal, handleOpenImportModal, handleAddUrlFilter, + handleShareHeadersByUrlModalOpened, + activeRequestHeadersCount, ], ); } diff --git a/src/widgets/modals/LazyModals.tsx b/src/widgets/modals/LazyModals.tsx index 988d634f..20784e75 100644 --- a/src/widgets/modals/LazyModals.tsx +++ b/src/widgets/modals/LazyModals.tsx @@ -1,19 +1,28 @@ import { useUnit } from 'effector-react'; import { lazy, Suspense } from 'react'; -import { $isExportModalOpen, $isimportFromExtensionModalOpen, $isImportModalOpen } from '#entities/modal/model'; +import { + $isExportModalOpen, + $isimportFromExtensionModalOpen, + $isImportModalOpen, + $isShareHeadersByUrlModalOpen, +} from '#entities/modal/model'; const ExportModal = lazy(() => import('./components/ExportModal').then(m => ({ default: m.ExportModal }))); const ImportFromExtensionModal = lazy(() => import('./components/ImportFromExtensionModal').then(m => ({ default: m.ImportFromExtensionModal })), ); const ImportModal = lazy(() => import('./components/ImportModal').then(m => ({ default: m.ImportModal }))); +const ShareHeadersByUrlModal = lazy(() => + import('./components/ShareHeadersByUrlModal').then(m => ({ default: m.ShareHeadersByUrlModal })), +); export function LazyModals() { - const [isImportModalOpen, isImportFromExtensionModalOpen, isExportModalOpen] = useUnit([ + const [isImportModalOpen, isImportFromExtensionModalOpen, isExportModalOpen, isShareHeadersByUrlModalOpen] = useUnit([ $isImportModalOpen, $isimportFromExtensionModalOpen, $isExportModalOpen, + $isShareHeadersByUrlModalOpen, ]); return ( @@ -33,6 +42,11 @@ export function LazyModals() { )} + {isShareHeadersByUrlModalOpen && ( + + + + )} ); } diff --git a/src/widgets/modals/components/ShareHeadersByUrlModal/ShareHeadersByUrlModal.tsx b/src/widgets/modals/components/ShareHeadersByUrlModal/ShareHeadersByUrlModal.tsx new file mode 100644 index 00000000..8f3f6c5d --- /dev/null +++ b/src/widgets/modals/components/ShareHeadersByUrlModal/ShareHeadersByUrlModal.tsx @@ -0,0 +1,91 @@ +import { useUnit } from 'effector-react'; +import { useCallback, useState } from 'react'; + +import { FieldText, FieldTextArea } from '@snack-uikit/fields'; +import { Modal } from '@snack-uikit/modal'; + +import { shareHeadersByUrlModalClosed } from '#entities/modal/model'; +import { notificationAdded } from '#entities/notification/model'; +import { NotificationVariant } from '#entities/notification/types'; +import { $selectedProfileRequestHeaders } from '#entities/request-profile/model'; +import { createSharedHeadersUrl } from '#features/share-headers-by-url'; + +import * as S from './styled'; + +export function ShareHeadersByUrlModal() { + const [domain, setDomain] = useState(''); + const [link, setLink] = useState(''); + const [error, setError] = useState(); + const [requestHeaders, handleClose, notify] = useUnit([ + $selectedProfileRequestHeaders, + shareHeadersByUrlModalClosed, + notificationAdded, + ]); + + const handleDomainChange = useCallback((value: string) => { + setDomain(value); + setLink(''); + setError(undefined); + }, []); + + const handleCreateAndCopy = useCallback(async () => { + let generatedLink: string; + + try { + generatedLink = createSharedHeadersUrl(domain, requestHeaders); + setLink(generatedLink); + setError(undefined); + } catch (generationError) { + setError(generationError instanceof Error ? generationError.message : 'Unable to create a sharing link'); + return; + } + + try { + await navigator.clipboard.writeText(generatedLink); + notify({ message: 'Sharing link copied to clipboard', variant: NotificationVariant.ImportProfileSuccess }); + } catch { + notify({ message: 'Unable to copy the sharing link', variant: NotificationVariant.ImportProfileError }); + } + }, [domain, notify, requestHeaders]); + + return ( + + + + {link && ( + undefined} + minRows={3} + maxRows={3} + size='m' + readonly + data-test-id='share-headers-link-output' + /> + )} + + } + /> + ); +} diff --git a/src/widgets/modals/components/ShareHeadersByUrlModal/index.ts b/src/widgets/modals/components/ShareHeadersByUrlModal/index.ts new file mode 100644 index 00000000..b345876b --- /dev/null +++ b/src/widgets/modals/components/ShareHeadersByUrlModal/index.ts @@ -0,0 +1 @@ +export { ShareHeadersByUrlModal } from './ShareHeadersByUrlModal'; diff --git a/src/widgets/modals/components/ShareHeadersByUrlModal/styled.ts b/src/widgets/modals/components/ShareHeadersByUrlModal/styled.ts new file mode 100644 index 00000000..967ca9a4 --- /dev/null +++ b/src/widgets/modals/components/ShareHeadersByUrlModal/styled.ts @@ -0,0 +1,7 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; diff --git a/tests/e2e/screenshot-tests/specs/profiles.screenshots.ts b/tests/e2e/screenshot-tests/specs/profiles.screenshots.ts index 8b9b36ae..0b23847a 100644 --- a/tests/e2e/screenshot-tests/specs/profiles.screenshots.ts +++ b/tests/e2e/screenshot-tests/specs/profiles.screenshots.ts @@ -47,5 +47,8 @@ createScreenshotTest({ setup: async popup => { await popup.headersTab.activate(); await popup.sidebar.openProfileActionsMenu(); + await popup.page.getByRole('menuitem', { name: 'Share headers by URL' }).evaluate(element => { + (element as HTMLElement).style.display = 'none'; + }); }, }); diff --git a/tests/e2e/share-headers-by-url.spec.ts b/tests/e2e/share-headers-by-url.spec.ts new file mode 100644 index 00000000..c6d138ef --- /dev/null +++ b/tests/e2e/share-headers-by-url.spec.ts @@ -0,0 +1,111 @@ +import { createServer, type Server } from 'node:http'; + +import type { Page } from '@playwright/test'; + +import { expect, test } from './fixtures'; + +let echoServer: Server; +let echoServerUrl: string; + +test.beforeAll(async () => { + echoServer = createServer((request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(request.headers)); + }); + + await new Promise(resolve => { + echoServer.listen(0, '127.0.0.1', resolve); + }); + + const address = echoServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to determine echo server address'); + } + + echoServerUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => { + await new Promise((resolve, reject) => { + echoServer.close(error => (error ? reject(error) : resolve())); + }); +}); + +async function setupClipboardMock(page: Page) { + await page.addInitScript(() => { + const win = window as typeof window & { __mockClipboard?: string }; + win.__mockClipboard = ''; + navigator.clipboard.writeText = async (text: string) => { + win.__mockClipboard = text; + }; + navigator.clipboard.readText = async () => win.__mockClipboard ?? ''; + }); +} + +test('shares active headers and imports them into a selected origin profile', async ({ + page, + extensionId, + context, +}) => { + await setupClipboardMock(page); + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.waitForLoadState('networkidle'); + + const headerNames = page.locator('[data-test-id="header-name-input"] input'); + const headerValues = page.locator('[data-test-id="header-value-input"] input'); + await headerNames.first().fill('X-Cloudhood-Shared'); + await headerValues.first().fill('active-value'); + + await page.locator('[data-test-id="add-request-header-button"]').click(); + await headerNames.nth(1).fill('X-Cloudhood-Disabled'); + await headerValues.nth(1).fill('disabled-value'); + await page.locator('[data-test-id="request-header-checkbox"]').nth(1).click(); + + await page.locator('[data-test-id="profile-actions-menu-button"]').click(); + await page.getByRole('menuitem', { name: 'Share headers by URL' }).click(); + + await expect(page.locator('[data-test-id="modal__title"]', { hasText: 'Share headers by URL' })).toBeVisible(); + await page.locator('[data-test-id="share-headers-domain-input"] input').fill(echoServerUrl); + await page.locator('button', { hasText: 'Create and copy link' }).click(); + + const sharedUrl = await page.evaluate(() => navigator.clipboard.readText()); + const encodedPayload = new URL(sharedUrl).hash.split('=')[1]; + const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) as { + version: number; + headers: Array<{ name: string; value: string }>; + }; + + expect(sharedUrl).toMatch(new RegExp(`^${echoServerUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/#__cloudhood=`)); + expect(payload).toEqual({ + version: 1, + headers: [{ name: 'X-Cloudhood-Shared', value: 'active-value' }], + }); + await expect(page.locator('[data-test-id="share-headers-link-output"] textarea')).toHaveValue(sharedUrl); + + const requestPage = await context.newPage(); + await requestPage.goto(echoServerUrl); + await requestPage.goto(sharedUrl); + + await expect.poll(() => requestPage.url()).toBe(`${echoServerUrl}/`); + await requestPage.reload(); + await expect + .poll(async () => { + const text = await requestPage.locator('body').textContent(); + const headers = JSON.parse(text ?? '{}') as Record; + return headers['x-cloudhood-shared']; + }) + .toBe('active-value'); + + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(page.getByText('Shared: 127.0.0.1', { exact: true })).toBeVisible(); + await expect(page.locator('[data-test-id="header-name-input"] input')).toHaveCount(1); + await expect(page.locator('[data-test-id="header-name-input"] input').first()).toHaveValue('X-Cloudhood-Shared'); + await expect(page.locator('[data-test-id="request-header-checkbox"]').first()).toHaveAttribute( + 'data-checked', + 'true', + ); + + await page.getByRole('tab', { name: 'URL Filters' }).click(); + await expect(page.locator('[data-test-id="url-filter-input"] input').first()).toHaveValue(echoServerUrl); +});