Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Cloudhood is a cross-browser extension (Chrome & Firefox) for managing custom HTTP request headers. Users create profiles containing headers and URL filters, which are applied to web requests via the Declarative Net Request API.

## Commands

```bash
# Development
pnpm dev:chrome # Chrome dev with hot reload
pnpm dev:firefox # Firefox dev with file watch

# Build
pnpm build # Build both browsers
pnpm build:chromium # Chrome only
pnpm build:firefox # Firefox only

# Testing
pnpm test:unit # Unit tests (Vitest)
pnpm test:e2e # E2E tests interactive (Playwright)
pnpm test:e2e:ci # E2E tests in CI mode
pnpm test:e2e:screenshots # Visual regression tests
pnpm test:e2e:screenshots:docker # Visual regression in Docker (used in CI)

# Linting
pnpm lint # ESLint
pnpm lint:css # Stylelint
```

Run a single unit test file: `pnpm test:unit src/shared/utils/__tests__/formatHeaders.test.ts`

Run a single E2E test: `pnpm test:e2e tests/e2e/some-test.spec.ts`

## Architecture

### Feature-Sliced Design (FSD)

The codebase follows FSD with strict layer imports — each layer can only import from layers below it:

```
app → pages → widgets → features → entities → shared
```

### Path Aliases

TypeScript path aliases map to FSD layers: `#app`, `#pages/*`, `#widgets/*`, `#features/*`, `#entities/*`, `#shared/*`. Defined in `tsconfig.json`.

### State Management — Effector

All state is managed with **Effector** (stores, events, effects, `sample`). Each entity/feature has a `model/` directory containing Effector units. Data persists to `browser.storage` via effects.

### Key Domain Model

A **Profile** (`src/entities/request-profile/types.ts`) contains:
- `requestHeaders: RequestHeader[]` — name/value pairs applied to matching requests
- `urlFilters: UrlFilter[]` — URL patterns determining which requests get headers

Headers are applied via Declarative Net Request in `src/shared/utils/setBrowserHeaders.ts`.

### Browser Compatibility

- `webextension-polyfill` wraps Chrome/Firefox API differences
- `src/shared/utils/browserAPI.ts` provides further abstraction (action API v2/v3 fallback)
- Separate manifests: `manifest.chromium.json`, `manifest.firefox.json`
- Build output goes to `build/chrome/` and `build/firefox/`

### Build System

Vite with two build targets:
1. **Popup** — React SPA (entry: `src/index.tsx`)
2. **Background** — Service worker (entry: `src/background.ts`, config: `vite.background.config.ts`)

The `BROWSER` env var (`chrome` | `firefox`) controls which manifest and build output are used.

### UI Components

Uses `@snack-uikit/*` component library with `@emotion/styled` for CSS-in-JS styling.

## Code Style

- Prettier: 2-space indent, 120 print width, single quotes, trailing commas
- ESLint config from `@cloud-ru/eslint-config` with `eslint-plugin-effector`
- Commit messages validated by `@cloud-ru/ft-config-commit-message` (conventional commits)
- Pre-commit hooks via Husky + lint-staged

## Tech Stack

- React 18, TypeScript (strict), Vite, Effector
- pnpm (>=10), Node.js (>=20)
- Playwright (E2E), Vitest (unit), jsdom
3 changes: 2 additions & 1 deletion manifest.chromium.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"storage",
"declarativeNetRequest",
"declarativeNetRequestFeedback",
"tabs"
"tabs",
"cookies"
],
"background": {
"service_worker": "background.bundle.js"
Expand Down
3 changes: 2 additions & 1 deletion manifest.dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"storage",
"declarativeNetRequest",
"declarativeNetRequestFeedback",
"tabs"
"tabs",
"cookies"
],
"background": {
"service_worker": "background.bundle.js"
Expand Down
3 changes: 2 additions & 1 deletion manifest.firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"declarativeNetRequest",
"declarativeNetRequestFeedback",
"activeTab",
"tabs"
"tabs",
"cookies"
],
"host_permissions": [
"<all_urls>"
Expand Down
17 changes: 13 additions & 4 deletions scripts/firefox-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const selectors = {
profileSelect: '[data-test-id="profile-select"]',
removeAllUrlFiltersButton: '[data-test-id="remove-all-url-filters-button"]',
removeHeaderButton: '[data-test-id="remove-request-header-button"]',
removeProfileButton: '[data-test-id="remove-profile-button"]',
removeUrlFilterButton: '[data-test-id="remove-url-filter-button"]',
themeToggle: '[data-test-id="theme-toggle-button"]',
urlFilterCheckbox: '[data-test-id="url-filter-checkbox"]',
Expand Down Expand Up @@ -143,7 +144,12 @@ function createBrowser(driver, popupUrl) {
await clickXpath(`//*[@role="menuitem" and contains(normalize-space(.), "${text}")]`, `menu item "${text}"`);
},
clickTab: async text => {
await clickXpath(`//*[@role="tab" and contains(normalize-space(.), "${text}")]`, `tab "${text}"`);
const xpath = `//*[@role="tab" and contains(normalize-space(.), "${text}")]`;
await clickXpath(xpath, `tab "${text}"`);
await waitUntil(async () => {
const [tab] = await driver.findElements(By.xpath(xpath));
return Boolean(tab) && (await tab.getAttribute('aria-selected')) === 'true';
}, `tab "${text}" to become selected`);
},
count: async selector => (await elements(selector)).length,
dynamicRules: async () => driver.executeScript('return browser.declarativeNetRequest.getDynamicRules()'),
Expand Down Expand Up @@ -251,7 +257,7 @@ async function main() {
async () => {
assert.equal(await browser.attr('[role="tab"]', 'aria-selected', 0), 'true');
await browser.clickTab('URL Filters');
assert.equal(await browser.attr('[role="tab"]', 'aria-selected', 1), 'true');
assert.equal(await browser.attr('[role="tab"]', 'aria-selected', 2), 'true');
assert.equal(await browser.count(selectors.urlFiltersSection), 1);
await browser.clickTab('Headers');
assert.equal(await browser.attr('[role="tab"]', 'aria-selected', 0), 'true');
Expand Down Expand Up @@ -420,8 +426,11 @@ async function main() {
async () => (await browser.count(selectors.profileNameInput)) === 0,
'profile rename to finish',
);
await browser.click(selectors.profileActionsButton);
await browser.clickMenuItem('Delete profile');
await waitUntil(
() => browser.enabled(selectors.removeProfileButton),
'remove profile button to become enabled',
);
await browser.click(selectors.removeProfileButton);
await waitForCount(browser, selectors.profileSelect, initialCount);
assert.equal(await browser.value(selectors.headerNameInput), 'X-Profile-One');
},
Expand Down
64 changes: 64 additions & 0 deletions scripts/firefox-screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const selectors = {
addHeaderButton: '[data-test-id="add-request-header-button"]',
toggleAllHeadersButton: '[data-test-id="all-request-headers-checkbox"]',
headerMenuButton: '[data-test-id="request-header-menu-button"]',
cookiesSection: '[data-test-id="profile-cookies-section"]',
cookieNameInput: '[data-test-id="cookie-name-input"] input',
cookieValueInput: '[data-test-id="cookie-value-input"] input',
cookieEnabledCheckbox: '[data-test-id="request-cookie-checkbox"]',
addCookieButton: '[data-test-id="add-request-cookie-button"]',
toggleAllCookiesButton: '[data-test-id="all-request-cookies-checkbox"]',
cookieMenuButton: '[data-test-id="request-cookie-menu-button"]',
removeAllCookiesButton: '[data-test-id="remove-all-request-cookies-button"]',
urlFiltersSection: '[data-test-id="url-filters-section"]',
urlFilterInput: '[data-test-id="url-filter-input"] input',
urlFilterEnabledCheckbox: '[data-test-id="url-filter-checkbox"]',
Expand Down Expand Up @@ -81,6 +89,44 @@ const scenarios = [
await addHeader('X-Menu', 'value');
await click(selectors.headerMenuButton);
}),
scenario('cookies', 'empty-state', async ({ activateCookies }) => {
await activateCookies();
}),
scenario('cookies', 'single-cookie', async ({ activateCookies, addCookie }) => {
await activateCookies();
await addCookie('session_id', 'abc123');
}),
scenario('cookies', 'multiple-cookies', async ({ activateCookies, addCookie }) => {
await activateCookies();
await addCookie('session_id', 'abc123');
await addCookie('theme', 'dark', 1);
}),
scenario('cookies', 'disabled-cookie', async ({ activateCookies, addCookie, click }) => {
await activateCookies();
await addCookie('disabled_cookie', 'value');
await click(selectors.cookieEnabledCheckbox);
}),
scenario('cookies', 'all-disabled', async ({ activateCookies, addCookie, click }) => {
await activateCookies();
await addCookie('first_cookie', 'value-1');
await addCookie('second_cookie', 'value-2', 1);
await click(selectors.toggleAllCookiesButton);
}),
scenario('cookies', 'name-validation-error', async ({ activateCookies, addCookie, blur }) => {
await activateCookies();
await addCookie('invalid name=', 'value');
await blur(selectors.cookieNameInput);
}),
scenario('cookies', 'value-validation-error', async ({ activateCookies, addCookie, blur }) => {
await activateCookies();
await addCookie('session_id', 'invalid;value');
await blur(selectors.cookieValueInput);
}),
scenario('cookies', 'cookie-menu-open', async ({ activateCookies, addCookie, click }) => {
await activateCookies();
await addCookie('menu_cookie', 'value');
await click(selectors.cookieMenuButton);
}),
scenario('modals', 'export-modal', async ({ addHeader, click, clickMenuItem, waitText, blur }) => {
await addHeader('X-Export', 'value');
await click(selectors.profileActionsButton);
Expand All @@ -104,6 +150,12 @@ const scenarios = [
await removeAllUrlFilters();
await waitText('Remove all URL filters');
}),
scenario('modals', 'cookies-delete-confirmation', async ({ activateCookies, addCookie, removeAllCookies, waitText }) => {
await activateCookies();
await addCookie('session_id', 'abc123');
await removeAllCookies();
await waitText('Remove all request cookies');
}),
scenario('profiles', 'single-profile', async ({ activateHeaders }) => {
await activateHeaders();
}),
Expand Down Expand Up @@ -238,10 +290,19 @@ function createBrowserHelpers(driver, popupUrl) {
activateHeaders: async () => {
await clickTab('Headers');
},
activateCookies: async () => {
await clickTab('Request cookies');
await waitUntil(() => visible(selectors.cookiesSection), 'cookies section');
},
activateUrlFilters: async () => {
await clickTab('URL Filters');
await waitUntil(() => visible(selectors.urlFiltersSection), 'URL filters section');
},
addCookie: async (name, value, index = 0) => {
await click(selectors.addCookieButton);
await fill(selectors.cookieNameInput, name, index);
await fill(selectors.cookieValueInput, value, index);
},
addHeader: async (name, value, index = 0) => {
await click(selectors.addHeaderButton);
await fill(selectors.headerNameInput, name, index);
Expand All @@ -251,6 +312,9 @@ function createBrowserHelpers(driver, popupUrl) {
click,
clickMenuItem,
fill,
removeAllCookies: async () => {
await click(selectors.removeAllCookiesButton);
},
removeAllUrlFilters: async () => {
if (await visible(selectors.removeAllUrlFiltersButton)) {
await click(selectors.removeAllUrlFiltersButton);
Expand Down
9 changes: 5 additions & 4 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import browser from 'webextension-polyfill';
import { BrowserStorageKey, ServiceWorkerEvent } from './shared/constants';
import { browserAction } from './shared/utils/browserAPI';
import { logger, LogLevel } from './shared/utils/logger';
import { setBrowserCookies } from './shared/utils/setBrowserCookies';
import { setBrowserHeaders } from './shared/utils/setBrowserHeaders';
import { enableExtensionReload } from './utils/extension-reload';

Expand Down Expand Up @@ -73,7 +74,7 @@ async function notify(message: ServiceWorkerEvent) {
]);

logger.info('📦 Storage data for reload:', result);
await setBrowserHeaders(result, await getCurrentTabUrl());
await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]);
}
return undefined;
}
Expand All @@ -99,7 +100,7 @@ browser.runtime.onStartup.addListener(async function () {
if (Object.keys(result).length) {
logger.info('🚀 Storage data found, setting browser headers on startup');
try {
await setBrowserHeaders(result, await getCurrentTabUrl());
await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]);
} catch (error) {
logger.error('Failed to set browser headers on startup:', error);
}
Expand Down Expand Up @@ -127,7 +128,7 @@ browser.storage.onChanged.addListener(async (changes, areaName) => {
]);
logger.debug('Storage changes data:', result);
try {
await setBrowserHeaders(result, await getCurrentTabUrl());
await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]);
} catch (error) {
logger.error('Failed to set browser headers on storage change:', error);
}
Expand Down Expand Up @@ -157,7 +158,7 @@ browser.runtime.onInstalled.addListener(async details => {
if (Object.keys(result).length) {
logger.info('🔧 Storage data found, initializing browser headers on install/update');
try {
await setBrowserHeaders(result, await getCurrentTabUrl());
await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]);
} catch (error) {
logger.error('Failed to set browser headers on install/update:', error);
}
Expand Down
2 changes: 1 addition & 1 deletion src/entities/profile-actions/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' | 'cookies' | 'url-filters';

export const profileActionsTabChanged = createEvent<ProfileActionsTab>();

Expand Down
8 changes: 8 additions & 0 deletions src/entities/request-profile/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const DEFAULT_REQUEST_HEADERS: Profile[] = [
value: '',
},
],
requestCookies: [
{
id: generateId(),
disabled: false,
name: '',
value: '',
},
],
urlFilters: [
{
id: generateId(),
Expand Down
1 change: 1 addition & 0 deletions src/entities/request-profile/model/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './request-profiles';
export * from './selected-profile-url-filters';
export * from './selected-request-cookies';
export * from './selected-request-headers';
export * from './selected-request-profile';
1 change: 1 addition & 0 deletions src/entities/request-profile/model/request-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const profileAddedFx = attach({
{
id: addedHeaderId,
requestHeaders: [{ id: generateId(), name: '', value: '', disabled: false }],
requestCookies: [{ id: generateId(), name: '', value: '', disabled: false }],
urlFilters: [{ id: generateId(), value: '', disabled: false }],
},
],
Expand Down
19 changes: 19 additions & 0 deletions src/entities/request-profile/model/selected-request-cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { combine } from 'effector';

import { validateCookie } from '#shared/utils/cookies';

import { $requestProfiles } from './request-profiles';
import { $selectedRequestProfile } from './selected-request-profile';

export const $selectedProfileRequestCookies = combine(
$selectedRequestProfile,
$requestProfiles,
(selectedProfileId, profiles) => profiles.find(p => p.id === selectedProfileId)?.requestCookies ?? [],
{ skipVoid: false },
);

export const $selectedProfileActiveRequestCookiesCount = combine(
$selectedProfileRequestCookies,
cookies => cookies.filter(c => !c.disabled && validateCookie(c.name, c.value)).length,
{ skipVoid: false },
);
Loading
Loading