references/chrome-api-mocks.md
# Chrome API Mocks
## Option A: jest-chrome (recommended)
```bash
npm install -D jest-chrome
```
```ts
// jest.setup.ts
import chrome from 'jest-chrome';
Object.assign(global, { chrome });
```
`jest-chrome` auto-stubs all `chrome.*` APIs as `jest.fn()` with proper typings.
---
## Option B: Manual Mocks
```ts
// jest.setup.ts — full manual mock template
const mockStore: Record<string, unknown> = {};
const chrome = {
storage: {
local: buildStorageMock(mockStore, 'local'),
sync: buildStorageMock(mockStore, 'sync'),
session: buildStorageMock(mockStore, 'session'),
onChanged: { addListener: jest.fn(), removeListener: jest.fn() },
},
runtime: {
id: 'test-extension-id',
sendMessage: jest.fn(),
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
hasListener: jest.fn(),
},
getManifest: jest.fn(() => ({ version: '1.0.0', name: 'Test Extension' })),
getURL: jest.fn((path: string) => `chrome-extension://test-extension-id/${path}`),
lastError: undefined as chrome.runtime.LastError | undefined,
},
tabs: {
query: jest.fn(),
create: jest.fn(),
update: jest.fn(),
get: jest.fn(),
sendMessage: jest.fn(),
onUpdated: { addListener: jest.fn(), removeListener: jest.fn() },
onActivated: { addListener: jest.fn(), removeListener: jest.fn() },
},
action: {
setIcon: jest.fn(),
setBadgeText: jest.fn(),
setBadgeBackgroundColor: jest.fn(),
setTitle: jest.fn(),
onClicked: { addListener: jest.fn() },
},
alarms: {
create: jest.fn(),
get: jest.fn(),
getAll: jest.fn(),
clear: jest.fn(),
clearAll: jest.fn(),
onAlarm: { addListener: jest.fn(), removeListener: jest.fn() },
},
scripting: {
executeScript: jest.fn(),
insertCSS: jest.fn(),
},
};
Object.assign(global, { chrome });
```
## Storage Mock Builder
```ts
function buildStorageMock(store: Record<string, unknown>, area: string) {
return {
get: jest.fn((keys: string | string[] | null, cb: (r: Record<string, unknown>) => void) => {
if (keys === null) return cb({ ...store });
const k = Array.isArray(keys) ? keys : [keys];
const result: Record<string, unknown> = {};
k.forEach(key => { if (key in store) result[key] = store[key]; });
cb(result);
}),
set: jest.fn((data: Record<string, unknown>, cb?: () => void) => {
Object.assign(store, data);
cb?.();
}),
remove: jest.fn((keys: string | string[], cb?: () => void) => {
(Array.isArray(keys) ? keys : [keys]).forEach(k => delete store[k]);
cb?.();
}),
clear: jest.fn((cb?: () => void) => {
Object.keys(store).forEach(k => delete store[k]);
cb?.();
}),
};
}
```
## Helpers
```ts
// createMockTab
export function createMockTab(overrides: Partial<chrome.tabs.Tab> = {}): chrome.tabs.Tab {
return {
id: 1,
index: 0,
windowId: 1,
highlighted: false,
active: true,
pinned: false,
incognito: false,
url: 'https://example.com',
title: 'Example',
status: 'complete',
...overrides,
};
}
// createMockSender
export function createMockSender(
overrides: Partial<chrome.runtime.MessageSender> = {}
): chrome.runtime.MessageSender {
return {
tab: createMockTab(),
frameId: 0,
url: 'https://example.com',
...overrides,
};
}
```
## Reset Between Tests
```ts
beforeEach(() => {
jest.clearAllMocks();
// If using manual store mock, clear it:
Object.keys(mockStore).forEach(k => delete mockStore[k]);
});
```
references/e2e-testing-puppeteer.md
# E2E Testing with Puppeteer
## Setup
```bash
npm install -D puppeteer @types/puppeteer
# Build extension before E2E
npm run build
```
## Launch Chrome with Extension
```ts
import puppeteer, { Browser } from 'puppeteer';
import path from 'path';
const EXT_PATH = path.resolve(__dirname, '../../dist');
async function launchWithExtension(): Promise<Browser> {
return puppeteer.launch({
headless: false, // REQUIRED — extensions blocked in headless
args: [
`--disable-extensions-except=${EXT_PATH}`,
`--load-extension=${EXT_PATH}`,
'--no-sandbox',
'--disable-setuid-sandbox',
],
defaultViewport: null,
});
}
```
## Wait for Extension Load + Get ID
```ts
async function getExtensionId(browser: Browser): Promise<string> {
// Give extension time to register service worker
await new Promise(r => setTimeout(r, 1000));
const targets = await browser.targets();
const sw = targets.find(
t => t.type() === 'service_worker' && t.url().includes('chrome-extension://')
);
if (!sw) throw new Error('Extension service worker not found');
const [, , extId] = sw.url().split('/');
return extId;
}
```
## Test Popup Interactions
```ts
it('popup renders action button', async () => {
const extId = await getExtensionId(browser);
const popupUrl = `chrome-extension://${extId}/popup.html`;
const page = await browser.newPage();
await page.goto(popupUrl, { waitUntil: 'networkidle0' });
const btn = await page.waitForSelector('#action-btn', { timeout: 3000 });
expect(btn).not.toBeNull();
await btn!.click();
const result = await page.$eval('#result', el => el.textContent);
expect(result).toBe('Done');
await page.close();
});
```
## Test Content Script Effects
```ts
it('content script injects banner on target page', async () => {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
// Wait for content script to inject
const banner = await page.waitForSelector('#ext-banner', { timeout: 5000 });
expect(banner).not.toBeNull();
const text = await page.$eval('#ext-banner', el => el.textContent);
expect(text).toContain('Extension Active');
await page.close();
});
```
## Test Service Worker via Targets API
```ts
it('service worker responds to runtime message', async () => {
const extId = await getExtensionId(browser);
// Open extension background page (MV3: use devtools protocol)
const targets = await browser.targets();
const swTarget = targets.find(t => t.url().includes(extId));
const worker = await swTarget?.worker();
// Evaluate in SW context
const response = await worker?.evaluate(() => {
return new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'PING' }, resolve);
});
});
expect(response).toEqual({ type: 'PONG' });
});
```
## Screenshot Capture
```ts
async function capturePopup(browser: Browser, extId: string, filename: string) {
const page = await browser.newPage();
await page.setViewport({ width: 400, height: 600 });
await page.goto(`chrome-extension://${extId}/popup.html`, {
waitUntil: 'networkidle0',
});
await page.screenshot({ path: `screenshots/${filename}.png`, fullPage: true });
await page.close();
}
```
## Jest E2E Config
```ts
// jest.e2e.config.ts
export default {
preset: 'ts-jest',
testEnvironment: 'node', // node, not jsdom for E2E
testMatch: ['**/*.e2e.test.ts'],
testTimeout: 30000,
globalSetup: './e2e/setup.ts',
globalTeardown: './e2e/teardown.ts',
};
// e2e/setup.ts
export default async () => {
(global as any).__BROWSER__ = await launchWithExtension();
};
// e2e/teardown.ts
export default async () => {
await (global as any).__BROWSER__?.close();
};
```
references/unit-integration-testing.md
# Unit & Integration Testing
## Jest Setup
```bash
npm install -D jest ts-jest @types/jest jest-chrome
npm install -D @testing-library/react @testing-library/jest-dom jsdom
```
```ts
// jest.config.ts
export default {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./jest.setup.ts'],
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
testMatch: ['**/*.test.ts', '**/*.test.tsx'],
testTimeout: 10000,
};
```
## Test Utility Functions
```ts
// src/utils/format.test.ts
import { truncateText } from '@/utils/format';
describe('truncateText', () => {
it('truncates text exceeding limit', () => {
expect(truncateText('Hello World', 5)).toBe('Hello...');
});
it('returns text unchanged when within limit', () => {
expect(truncateText('Hi', 10)).toBe('Hi');
});
});
```
## Test Storage Service
```ts
// src/services/storage.test.ts
import { StorageService } from '@/services/storage';
beforeEach(() => {
chrome.storage.local.get.mockImplementation((keys, cb) => cb({}));
chrome.storage.local.set.mockImplementation((data, cb) => cb?.());
});
it('saves and retrieves settings', async () => {
const svc = new StorageService();
await svc.save({ theme: 'dark' });
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{ theme: 'dark' },
expect.any(Function)
);
});
```
## Test Message Passing
```ts
// src/services/messenger.test.ts
import { sendToBackground } from '@/services/messenger';
it('sends message and returns response', async () => {
chrome.runtime.sendMessage.mockImplementation((msg, cb) => {
cb({ success: true, data: 'result' });
});
const res = await sendToBackground({ type: 'FETCH_DATA', url: 'https://example.com' });
expect(res).toEqual({ success: true, data: 'result' });
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
{ type: 'FETCH_DATA', url: 'https://example.com' },
expect.any(Function)
);
});
```
## Test React Popup Component
```tsx
// src/popup/App.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import App from '@/popup/App';
it('shows toggle and changes state on click', async () => {
chrome.storage.local.get.mockImplementation((_, cb) => cb({ enabled: false }));
render(<App />);
const toggle = await screen.findByRole('switch');
expect(toggle).toHaveAttribute('aria-checked', 'false');
fireEvent.click(toggle);
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{ enabled: true },
expect.any(Function)
);
});
```
## Integration: Service Worker + Storage
```ts
// src/background/handler.integration.test.ts
import { handleMessage } from '@/background/handler';
const mockStore: Record<string, unknown> = {};
beforeEach(() => {
chrome.storage.local.get.mockImplementation((keys, cb) => {
const result: Record<string, unknown> = {};
(Array.isArray(keys) ? keys : [keys]).forEach(k => {
if (k in mockStore) result[k] = mockStore[k];
});
cb(result);
});
chrome.storage.local.set.mockImplementation((data, cb) => {
Object.assign(mockStore, data);
cb?.();
});
});
it('processes SAVE_ITEM message and persists to storage', async () => {
const sender = { tab: { id: 1 } } as chrome.runtime.MessageSender;
const sendResponse = jest.fn();
await handleMessage({ type: 'SAVE_ITEM', item: { id: 'x', val: 42 } }, sender, sendResponse);
expect(mockStore['items']).toContainEqual({ id: 'x', val: 42 });
expect(sendResponse).toHaveBeenCalledWith({ success: true });
});
```
## Integration: onMessage Listener
```ts
it('service worker listener routes messages correctly', () => {
const listener = chrome.runtime.onMessage.addListener.mock.calls[0][0];
const res = listener(
{ type: 'GET_VERSION' },
{ tab: { id: 1 } } as chrome.runtime.MessageSender,
jest.fn()
);
// returning true = async response
expect(res).toBe(true);
});
```
SKILL.md
---
name: extension-test
description: Set up and run unit, integration, and E2E tests for Chrome extensions. Covers Jest mocks for chrome.* APIs and Puppeteer E2E with real Chrome.
---
# Extension Testing
## Testing Layer Architecture
```
Unit Tests (Jest) → Test isolated logic, chrome.* API mocks
Integration Tests (Jest) → Test service interactions, message passing
E2E Tests (Puppeteer) → Test in real Chrome with extension loaded
```
**Critical constraint**: Extensions CANNOT run in headless mode. E2E requires `headless: false` or Chrome's `--headless=new` (Chrome 112+).
---
## Layer 1: Unit + Integration (Jest)
### Install
```bash
npm install -D jest @types/jest ts-jest jest-chrome
# For React popup:
npm install -D @testing-library/react @testing-library/jest-dom jsdom
```
### jest.config.ts
```ts
export default {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
```
### jest.setup.ts (minimal)
```ts
import chrome from 'jest-chrome';
Object.assign(global, { chrome });
```
Full mock setup → [chrome-api-mocks.md](references/chrome-api-mocks.md)
Unit/integration patterns → [unit-integration-testing.md](references/unit-integration-testing.md)
---
## Layer 2: E2E (Puppeteer)
### Install
```bash
npm install -D puppeteer
```
### Launch extension in Chrome
```ts
import puppeteer from 'puppeteer';
import path from 'path';
const browser = await puppeteer.launch({
headless: false, // extensions require non-headless
args: [
`--disable-extensions-except=${path.resolve('dist')}`,
`--load-extension=${path.resolve('dist')}`,
],
});
```
### Get extension ID
```ts
const targets = await browser.targets();
const extTarget = targets.find(t => t.type() === 'service_worker');
const extUrl = extTarget?.url() ?? '';
const [, , extId] = extUrl.split('/');
```
Full E2E patterns → [e2e-testing-puppeteer.md](references/e2e-testing-puppeteer.md)
---
## Chrome API Mocking Strategy
| API | Approach |
|-----|----------|
| `chrome.storage` | Mock with in-memory store |
| `chrome.runtime.sendMessage` | jest.fn() + mock response |
| `chrome.tabs` | jest.fn() with createMockTab helper |
| `chrome.action` | jest.fn() stubs |
| `chrome.alarms` | jest.fn() stubs |
All mocks → [chrome-api-mocks.md](references/chrome-api-mocks.md)
---
## package.json Scripts
```json
{
"scripts": {
"test:unit": "jest --testPathPattern=unit",
"test:integration": "jest --testPathPattern=integration",
"test:e2e": "jest --testPathPattern=e2e --runInBand",
"test": "npm run test:unit && npm run test:integration"
}
}
```
> Run E2E separately (`--runInBand`) — Puppeteer tests must run serially.
---
## Reference Files
- [unit-integration-testing.md](references/unit-integration-testing.md) — Jest patterns, mocking, component tests
- [e2e-testing-puppeteer.md](references/e2e-testing-puppeteer.md) — Puppeteer setup, popup/content/SW tests
- [chrome-api-mocks.md](references/chrome-api-mocks.md) — Full chrome.* mock templates