| name | chrome-extension-development |
| description | Use when building Chrome extensions (Manifest V3). Covers floating panel architecture, sidepanel API, storage patterns, message passing, content scripts, SPA navigation detection, context menus, Vitest testing, Playwright E2E, and common pitfalls. |
Chrome Extension Development
Guidelines for building and maintaining Chrome browser extensions.
Build System Architecture
The Source vs Dist Problem
Chrome extensions require specific files in a loadable directory structure. Common confusion arises from mixing source and generated files.
Typical bundler behavior (Vite, esbuild, webpack):
- Compiles TypeScript/JavaScript:
src/*.ts ā dist/*.js
- May bundle imported CSS into JS
- Does NOT automatically copy: HTML, standalone CSS, manifest.json, static assets
Recommended: Vite with publicDir
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
build: {
outDir: "dist",
copyPublicDir: true,
rollupOptions: {
input: {
background: resolve(__dirname, "src/background.ts"),
sidepanel: resolve(__dirname, "src/sidepanel.ts"),
},
output: {
entryFileNames: "[name].js",
},
},
},
publicDir: "public",
test: {
environment: "jsdom",
exclude: ["**/node_modules/**", "**/e2e/**"],
},
});
Project structure:
src/
background.ts # Service worker
sidepanel.ts # Sidepanel UI logic
content.ts # Content script (if needed)
storage.ts # Storage utilities
public/
manifest.json # Extension manifest
sidepanel.html # Sidepanel markup
styles.css # Styles
icons/ # Extension icons
dist/ # Generated (gitignore this)
Manifest V3 Configuration
{
"manifest_version": 3,
"name": "Extension Name",
"version": "0.1.0",
"description": "Description",
"permissions": [
"activeTab",
"storage",
"sidePanel",
"scripting",
"contextMenus",
"notifications"
],
"side_panel": {
"default_path": "sidepanel.html"
},
"commands": {
"action-name": {
"suggested_key": {
"default": "Alt+P",
"mac": "Alt+P"
},
"description": "Command description"
}
},
"action": {
"default_title": "Extension Name",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
Manifest V3 Notes
- Service workers replace background pages (no persistent background)
chrome.scripting.executeScript replaces chrome.tabs.executeScript
- Host permissions moved from
permissions to host_permissions
- Remote code execution prohibited; all code must be bundled
Content Scripts
Content scripts run in web page context with limited Chrome API access.
Manifest Configuration
{
"content_scripts": [
{
"matches": ["*://*.example.com/*"],
"js": ["content.js"],
"css": ["styles.css"],
"run_at": "document_idle"
}
],
"host_permissions": [
"*://*.example.com/*"
],
"web_accessible_resources": [
{
"resources": ["styles.css", "images/*"],
"matches": ["*://*.example.com/*"]
}
]
}
run_at options:
document_start - Before DOM is constructed (for early interception)
document_end - DOM ready, before images/subframes
document_idle - After DOM complete (default, safest)
Content Script Lifecycle
import { logger } from './logger';
logger.debug('Content script starting');
function isExtensionContextValid(): boolean {
try {
return chrome?.runtime?.id !== undefined;
} catch {
return false;
}
}
async function init(): Promise<void> {
if (!isExtensionContextValid()) {
logger.warn('Extension context invalid, skipping init');
return;
}
if (document.readyState === 'loading') {
await new Promise(resolve =>
document.addEventListener('DOMContentLoaded', resolve)
);
}
setupUI();
observePageChanges();
}
init().catch(logger.error);
Communicating with Background
async function sendToBackground(message: unknown): Promise<unknown> {
if (!isExtensionContextValid()) return null;
return new Promise((resolve) => {
try {
chrome.runtime.sendMessage(message, (response) => {
if (chrome.runtime.lastError) {
resolve(null);
return;
}
resolve(response);
});
} catch {
resolve(null);
}
});
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'getProfile') {
fetchProfile(message.id).then(sendResponse);
return true;
}
});
Injecting UI Elements
function createFloatingPanel(): HTMLElement {
const panel = document.createElement('div');
panel.id = 'my-extension-panel';
panel.style.cssText = `
position: fixed;
top: 100px;
right: 20px;
z-index: 2147483647;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
const shadow = panel.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>/* Your isolated styles */</style>
<div class="content">...</div>
`;
document.body.appendChild(panel);
return panel;
}
Floating Panel Architecture
For complex floating UI that persists across page navigation, use a factory pattern with state management.
Panel Interface
export enum PanelState {
Minimized = 'minimized',
Expanded = 'expanded',
}
export interface Position {
x: number;
y: number;
}
export interface Panel {
element: HTMLElement;
getState: () => PanelState;
toggle: () => void;
setPosition: (x: number, y: number) => void;
getPosition: () => Position;
setContent: (html: string) => void;
onAction: (callback: () => void) => void;
destroy: () => void;
}
Panel Factory
export function createPanel(container: HTMLElement): Panel {
let state: PanelState = PanelState.Minimized;
let position: Position = { x: 20, y: 20 };
let actionCallback: (() => void) | null = null;
const element = document.createElement('div');
element.className = 'sr-panel sr-panel--minimized sr-panel--draggable';
element.style.transform = `translate(${position.x}px, ${position.y}px)`;
const orb = document.createElement('div');
orb.className = 'sr-panel__orb sr-panel__orb--visible';
element.appendChild(orb);
const content = document.createElement('div');
content.className = 'sr-panel__content';
element.appendChild(content);
container.appendChild(element);
function toggle(): void {
if (state === PanelState.Minimized) {
state = PanelState.Expanded;
element.classList.remove('sr-panel--minimized');
element.classList.add('sr-panel--expanded');
orb.classList.remove('sr-panel__orb--visible');
content.classList.add('sr-panel__content--visible');
} else {
state = PanelState.Minimized;
element.classList.remove('sr-panel--expanded');
element.classList.add('sr-panel--minimized');
orb.classList.add('sr-panel__orb--visible');
content.classList.remove('sr-panel__content--visible');
}
}
orb.addEventListener('click', toggle);
return {
element,
getState: () => state,
toggle,
setPosition: (x, y) => {
position = { x, y };
element.style.transform = `translate(${x}px, ${y}px)`;
},
getPosition: () => ({ ...position }),
setContent: (html) => {
content.innerHTML = html;
const minimizeBtn = content.querySelector('.sr-panel__minimize');
minimizeBtn?.addEventListener('click', toggle);
},
onAction: (callback) => { actionCallback = callback; },
destroy: () => element.remove(),
};
}
Drag and Drop with Position Persistence
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
function setupDragListeners(panel: Panel): void {
const element = panel.element;
element.addEventListener('mousedown', (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'BUTTON' || target.closest('button')) return;
isDragging = true;
const pos = panel.getPosition();
dragOffset = {
x: e.clientX - pos.x,
y: e.clientY - pos.y,
};
element.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e: MouseEvent) => {
if (!isDragging) return;
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
panel.setPosition(newX, newY);
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
panel.element.style.cursor = 'grab';
savePosition(panel.getPosition());
}
});
}
function savePosition(position: Position): void {
if (!isExtensionContextValid()) return;
chrome.storage.sync.set({ panelPosition: position });
}
async function loadPosition(): Promise<Position | null> {
if (!isExtensionContextValid()) return null;
return new Promise((resolve) => {
chrome.storage.sync.get(['panelPosition'], (result) => {
resolve(result.panelPosition || null);
});
});
}
CSS Injection via chrome.runtime.getURL
function injectStyles(): void {
if (document.getElementById('my-extension-styles')) return;
if (!isExtensionContextValid()) return;
const link = document.createElement('link');
link.id = 'my-extension-styles';
link.rel = 'stylesheet';
link.href = chrome.runtime.getURL('panel.css');
document.head.appendChild(link);
}
Floating Panel CSS
:root {
--panel-bg: #1a1a1a;
--panel-accent: #c9a227;
--panel-text: #fafafa;
--panel-shadow: 0 4px 6px rgba(0,0,0,0.1), 0 10px 20px rgba(0,0,0,0.15);
--panel-transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sr-panel {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
transition: var(--panel-transition);
}
.sr-panel--draggable {
cursor: grab;
}
.sr-panel--draggable:active {
cursor: grabbing;
}
.sr-panel__orb {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--panel-bg);
border: 2px solid var(--panel-accent);
box-shadow: var(--panel-shadow);
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--panel-transition);
}
.sr-panel__orb--visible {
display: flex;
}
.sr-panel__orb:hover {
transform: scale(1.1);
box-shadow: 0 0 20px rgba(201, 162, 39, 0.3), var(--panel-shadow);
}
.sr-panel__orb--alert {
animation: sr-pulse 2s ease-in-out infinite;
}
@keyframes sr-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(201, 162, 39, 0.4); }
50% { box-shadow: 0 0 0 10px rgba(201, 162, 39, 0); }
}
.sr-panel__content {
display: none;
width: 300px;
background: #f5f2eb;
border-radius: 8px;
box-shadow: var(--panel-shadow);
overflow: hidden;
border: 1px solid var(--panel-accent);
}
.sr-panel__content--visible {
display: block;
animation: sr-expand 0.3s ease-out;
}
@keyframes sr-expand {
from {
opacity: 0;
transform: scale(0.9) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
Progress/Loading States
interface ExtractionProgress {
step: string;
label: string;
progress: number;
elapsed: number;
}
function setProgress(progress: ExtractionProgress | null): void {
const progressBar = element.querySelector('.sr-panel__progress');
if (!progress) {
progressBar?.remove();
return;
}
const label = progressBar?.querySelector('.sr-panel__progress-label');
const time = progressBar?.querySelector('.sr-panel__progress-time');
const fill = progressBar?.querySelector('.sr-panel__progress-fill') as HTMLElement;
if (label) label.textContent = progress.label;
if (time) time.textContent = `${(progress.elapsed / 1000).toFixed(1)}s`;
if (fill) fill.style.width = `${progress.progress * 100}%`;
}
Content Priming (Show Partial Data Early)
function primePanel(): void {
if (!panel) return;
const h1 = document.querySelector('h1');
const name = h1?.textContent?.trim() || 'Loading...';
const headlineEl = document.querySelector('.headline-class');
const headline = headlineEl?.textContent?.trim();
panel.setContent(`
<div class="sr-panel__header">
<span class="sr-panel__name">${name}</span>
</div>
<div class="sr-panel__body">
${headline ? `<div class="sr-panel__headline">${headline}</div>` : ''}
<div class="sr-panel__loading">Analyzing profile...</div>
</div>
`);
}
SPA URL Change Detection
Many modern sites are SPAs that don't trigger page loads. Detect navigation using multiple strategies:
function observeUrlChanges(): void {
let lastUrl = window.location.href;
const checkUrlChange = () => {
const currentUrl = window.location.href;
if (currentUrl !== lastUrl) {
const oldUrl = lastUrl;
lastUrl = currentUrl;
handleUrlChange(currentUrl, oldUrl);
}
};
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);
history.pushState = function(...args) {
originalPushState(...args);
checkUrlChange();
};
history.replaceState = function(...args) {
originalReplaceState(...args);
checkUrlChange();
};
window.addEventListener('popstate', checkUrlChange);
const observer = new MutationObserver(checkUrlChange);
observer.observe(document.body, { childList: true, subtree: true });
}
function handleUrlChange(newUrl: string, oldUrl: string): void {
currentProfileId = null;
if (isTargetPage(newUrl)) {
panel?.setMinimalMode(false);
primePanel();
setTimeout(() => extractData(), 500);
} else {
panel?.setMinimalMode(true);
loadHistory();
}
}
Background Script URL Notification (More Reliable)
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.url) {
chrome.tabs.sendMessage(tabId, {
type: 'URL_CHANGED',
url: changeInfo.url,
}).catch(() => {});
}
});
let lastHandledUrl = window.location.href;
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'URL_CHANGED' && message.url !== lastHandledUrl) {
const oldUrl = lastHandledUrl;
lastHandledUrl = message.url;
handleUrlChange(message.url, oldUrl);
}
});
Modal/Popup Patterns
function showPopup(content: string): void {
document.querySelector('.my-extension-popup')?.remove();
const popup = document.createElement('div');
popup.className = 'my-extension-popup';
popup.innerHTML = `
<div class="popup-backdrop"></div>
<div class="popup-content">
<button class="popup-close">×</button>
${content}
</div>
`;
document.body.appendChild(popup);
requestAnimationFrame(() => {
popup.classList.add('popup--visible');
});
const closePopup = () => {
popup.classList.remove('popup--visible');
setTimeout(() => popup.remove(), 300);
};
popup.querySelector('.popup-backdrop')?.addEventListener('click', closePopup);
popup.querySelector('.popup-close')?.addEventListener('click', closePopup);
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closePopup();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
Sidepanel API
Use sidepanel instead of popup for persistent UI that stays open while user navigates.
Opening Sidepanel with User Gesture
Critical: chrome.sidePanel.open() requires a user gesture. Open SYNCHRONOUSLY before any async operations:
let cachedPartyCode: string | null = null;
if (typeof chrome !== "undefined" && chrome.storage) {
getPartyCode().then(code => { cachedPartyCode = code; });
chrome.storage.onChanged.addListener((changes) => {
if (changes.partyCode) {
cachedPartyCode = changes.partyCode.newValue ?? null;
}
});
}
export async function handleIconClick(tab: chrome.tabs.Tab): Promise<void> {
const hasPartyCode = cachedPartyCode !== null;
if (!hasPartyCode) {
if (tab.windowId) {
chrome.sidePanel.open({ windowId: tab.windowId }).catch(() => {});
}
await doAsyncWork();
return;
}
}
Disabling Auto-Open Behavior
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
Storage Patterns
Getter Functions with Defaults and Migration
Never trust raw storage values. Always use getter functions:
const QUEUE_KEY = "pendingUrlQueue";
export interface QueuedPost {
url: string;
poster?: string;
content?: string;
platform: string;
}
export async function getQueue(): Promise<QueuedPost[]> {
const result = await chrome.storage.local.get([QUEUE_KEY]);
const queue = result[QUEUE_KEY] ?? [];
return queue.map((item: string | QueuedPost) => {
if (typeof item === 'string') {
return { url: item, platform: detectPlatform(item) };
}
return item;
});
}
export async function addToQueue(post: QueuedPost): Promise<void> {
const queue = await getQueue();
if (queue.some(p => p.url === post.url)) {
return;
}
queue.push(post);
const MAX_SIZE = 9999;
while (queue.length > MAX_SIZE) {
queue.shift();
}
await chrome.storage.local.set({ [QUEUE_KEY]: queue });
}
Storage Change Listeners
Problem: Raw changes.newValue can be undefined or in old format.
Solution: Always use getter function in listeners:
chrome.storage.onChanged.addListener((changes) => {
const queue = changes.pendingUrlQueue?.newValue || [];
updateUI(queue);
});
chrome.storage.onChanged.addListener((changes) => {
if (changes.pendingUrlQueue) {
debouncedRefresh();
}
});
Debouncing Concurrent Updates
Problem: Multiple event sources can fire simultaneously (storage listener + message handler).
Solution: Module-level debounce:
let refreshTimeout: number | null = null;
let pendingFromClipboard = false;
export async function debouncedQueueRefresh(fromClipboard = false): Promise<void> {
if (fromClipboard) {
pendingFromClipboard = true;
}
if (refreshTimeout) {
clearTimeout(refreshTimeout);
}
refreshTimeout = window.setTimeout(async () => {
const queue = await getQueue();
updateQueueBanner(queue.length, queue, pendingFromClipboard);
refreshTimeout = null;
pendingFromClipboard = false;
}, 50);
}
Message Passing
Sidepanel Communication with Retry
Sidepanel may not be ready immediately after opening:
async function sendToSidepanel(
message: Record<string, unknown>,
retries = 3
): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
await chrome.runtime.sendMessage(message);
return;
} catch {
if (i < retries - 1) {
await new Promise(resolve => setTimeout(resolve, 100 * (i + 1)));
}
}
}
}
await sendToSidepanel({ type: "refreshQueue", source: "feed" });
Message Listener in Sidepanel
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === "refreshQueue") {
debouncedQueueRefresh(message.source === "clipboard");
} else if (message.type === "showGuidance") {
showGuidanceBubble(message.context);
}
return true;
});
Context Menus
export async function setupContextMenus(): Promise<void> {
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
await chrome.contextMenus.removeAll();
chrome.contextMenus.create({
id: "open-panel",
title: "Open Extension Panel",
contexts: ["action"],
});
chrome.contextMenus.create({
id: "add-item",
title: "Add Current Item",
contexts: ["action"],
});
}
chrome.contextMenus.onClicked.addListener(handleContextMenuClick);
chrome.runtime.onInstalled.addListener(setupContextMenus);
Badge Management
export async function updateBadge(count: number): Promise<void> {
const text = count > 0 ? String(count) : "";
await chrome.action.setBadgeText({ text });
await chrome.action.setBadgeBackgroundColor({ color: "#0077b5" });
}
export async function updateNeedsPartyBadge(): Promise<void> {
const count = await getQueueCount();
if (count > 0) {
await chrome.action.setBadgeText({ text: "!" });
await chrome.action.setBadgeBackgroundColor({ color: "#ff6b35" });
} else {
await chrome.action.setBadgeText({ text: "" });
}
}
Notifications
let notificationCounter = 0;
export async function showNotification(
type: "success" | "error" | "info",
title: string,
message: string
): Promise<void> {
const notificationId = `extension-${Date.now()}-${notificationCounter++}`;
chrome.notifications.create(
notificationId,
{
type: "basic",
iconUrl: "icons/icon128.png",
title,
message,
},
() => {
setTimeout(() => {
chrome.notifications.clear(notificationId, () => {});
}, 5000);
}
);
}
Keyboard Shortcuts
chrome.commands.onCommand.addListener((command) => {
if (command === "add-post") {
handleKeyboardShortcut();
}
});
export async function handleKeyboardShortcut(): Promise<void> {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
if (tab) {
await handleIconClick(tab);
}
}
Rate Limiting
export class RateLimiter {
private timestamps: number[] = [];
private maxActions: number;
private windowMs: number;
constructor(maxActions: number, windowMs: number) {
this.maxActions = maxActions;
this.windowMs = windowMs;
}
private cleanOldTimestamps(): void {
const now = Date.now();
this.timestamps = this.timestamps.filter(ts => now - ts < this.windowMs);
}
canProceed(): boolean {
this.cleanOldTimestamps();
return this.timestamps.length < this.maxActions;
}
recordAction(): void {
this.timestamps.push(Date.now());
}
getRemainingTime(): number {
this.cleanOldTimestamps();
if (this.timestamps.length < this.maxActions) return 0;
return this.windowMs - (Date.now() - this.timestamps[0]);
}
reset(): void {
this.timestamps = [];
}
}
const postRateLimiter = new RateLimiter(3, 60000);
if (!postRateLimiter.canProceed()) {
const remainingSec = Math.ceil(postRateLimiter.getRemainingTime() / 1000);
return { success: false, message: `Please wait ${remainingSec}s` };
}
postRateLimiter.recordAction();
Structured Logging
Create a consistent, filterable logging module:
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const PREFIX = '[MyExtension]';
let currentLevel: LogLevel = 'debug';
export function setLogLevel(level: LogLevel): void {
currentLevel = level;
}
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
}
function log(level: LogLevel, ...args: unknown[]): void {
if (!shouldLog(level)) return;
const consoleFn = level === 'debug' ? console.debug
: level === 'info' ? console.log
: level === 'warn' ? console.warn
: console.error;
consoleFn(PREFIX, ...args);
}
export const logger = {
debug: (...args: unknown[]) => log('debug', ...args),
info: (...args: unknown[]) => log('info', ...args),
warn: (...args: unknown[]) => log('warn', ...args),
error: (...args: unknown[]) => log('error', ...args),
forModule(module: string) {
const modulePrefix = `[${module}]`;
return {
debug: (...args: unknown[]) => log('debug', modulePrefix, ...args),
info: (...args: unknown[]) => log('info', modulePrefix, ...args),
warn: (...args: unknown[]) => log('warn', modulePrefix, ...args),
error: (...args: unknown[]) => log('error', modulePrefix, ...args),
};
},
};
import { logger } from './logger';
logger.debug('User clicked button', { userId: 123 });
const panelLogger = logger.forModule('Panel');
panelLogger.info('Panel opened');
Benefits:
- Filterable in Chrome DevTools by prefix
- Easily toggle verbosity in production
- Module-specific prefixes for debugging
Extension Context Validation
Chrome extension context can become invalid during SPA navigation or page reloads:
function isExtensionContextValid(): boolean {
try {
return chrome?.runtime?.id !== undefined;
} catch {
return false;
}
}
async function safeStorageGet<T>(key: string): Promise<T | null> {
if (!isExtensionContextValid()) {
console.debug('Extension context invalidated');
return null;
}
return new Promise((resolve) => {
try {
chrome.storage.local.get([key], (result) => {
if (chrome.runtime.lastError) {
console.debug('Storage error:', chrome.runtime.lastError);
resolve(null);
return;
}
resolve(result[key] || null);
});
} catch (e) {
console.debug('Storage exception:', e);
resolve(null);
}
});
}
When context becomes invalid:
- SPA navigation (history.pushState)
- Extension reload/update
- Page refresh during content script execution
- Service worker termination
Executing Scripts in Tabs
export async function getPageContent(tabId: number): Promise<string> {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText,
});
return results[0]?.result ?? "";
}
export async function detectPostFromFeed(tabId: number): Promise<{
url: string | null;
extractedPost: ExtractedPost | null;
}> {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: detectPostInPage,
});
return results[0]?.result ?? { url: null, extractedPost: null };
}
Testing with Vitest
Mock Chrome APIs
import { vi } from 'vitest';
const mockChrome = {
storage: {
local: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
},
onChanged: {
addListener: vi.fn(),
},
},
runtime: {
sendMessage: vi.fn().mockResolvedValue(undefined),
onMessage: { addListener: vi.fn() },
onInstalled: { addListener: vi.fn() },
},
action: {
onClicked: { addListener: vi.fn() },
setBadgeText: vi.fn().mockResolvedValue(undefined),
setBadgeBackgroundColor: vi.fn().mockResolvedValue(undefined),
},
sidePanel: {
open: vi.fn().mockResolvedValue(undefined),
setPanelBehavior: vi.fn().mockResolvedValue(undefined),
},
contextMenus: {
onClicked: { addListener: vi.fn() },
removeAll: vi.fn().mockResolvedValue(undefined),
create: vi.fn(),
},
tabs: {
query: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue({}),
},
scripting: {
executeScript: vi.fn().mockResolvedValue([]),
},
notifications: {
create: vi.fn(),
clear: vi.fn(),
},
commands: {
onCommand: { addListener: vi.fn() },
},
};
globalThis.chrome = mockChrome;
export { mockChrome };
Vitest Configuration
export default defineConfig({
test: {
environment: "jsdom",
exclude: ["**/node_modules/**", "**/e2e/**"],
setupFiles: ["./src/test-setup.ts"],
},
});
Example Test
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockChrome } from './test-setup';
import { getQueue, addToQueue } from './storage';
describe('storage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns empty array when no queue exists', async () => {
mockChrome.storage.local.get.mockResolvedValue({});
const queue = await getQueue();
expect(queue).toEqual([]);
});
it('migrates old string format to QueuedPost', async () => {
mockChrome.storage.local.get.mockResolvedValue({
pendingUrlQueue: ['https://linkedin.com/posts/123']
});
const queue = await getQueue();
expect(queue[0]).toHaveProperty('url');
expect(queue[0]).toHaveProperty('platform');
});
it('prevents duplicate URLs', async () => {
mockChrome.storage.local.get.mockResolvedValue({
pendingUrlQueue: [{ url: 'https://example.com', platform: 'test' }]
});
await addToQueue({ url: 'https://example.com', platform: 'test' });
expect(mockChrome.storage.local.set).not.toHaveBeenCalled();
});
});
Testing Async UI Functions
When testing functions that depend on storage, make them async and await in tests:
export async function showGuidanceBubble(context: "notAPost"): Promise<void> {
const queueCount = await getQueueCount();
if (queueCount > 0) {
return;
}
}
it('does not show guidance when queue has posts', async () => {
mockChrome.storage.local.get.mockResolvedValue({
pendingUrlQueue: [{ url: 'https://example.com', platform: 'test' }]
});
await showGuidanceBubble('notAPost');
expect(bubble?.style.display).not.toBe('block');
});
E2E Testing with Playwright
Playwright Configuration
import { defineConfig } from '@playwright/test';
import path from 'path';
const extensionPath = path.resolve(__dirname, 'dist');
export default defineConfig({
testDir: './tests',
timeout: 120000,
retries: 0,
workers: 1,
use: {
headless: false,
viewport: { width: 1280, height: 720 },
actionTimeout: 10000,
trace: 'on-first-retry',
},
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'extension-tests',
testMatch: /.*\.spec\.ts/,
dependencies: ['setup'],
},
],
});
Test Fixtures with Extension
import { test as base, chromium, BrowserContext } from '@playwright/test';
import path from 'path';
const extensionPath = path.resolve(__dirname, '../dist');
const userDataDir = path.resolve(__dirname, '../.auth/user-data');
export const test = base.extend<{
context: BrowserContext;
extensionId: string;
}>({
context: async ({}, use) => {
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--no-first-run',
'--disable-blink-features=AutomationControlled',
],
viewport: { width: 1280, height: 720 },
});
await use(context);
await context.close();
},
extensionId: async ({ context }, use) => {
let extensionId = '';
const serviceWorkers = context.serviceWorkers();
if (serviceWorkers.length > 0) {
const url = serviceWorkers[0].url();
const match = url.match(/chrome-extension:\/\/([^/]+)/);
if (match) extensionId = match[1];
}
await use(extensionId);
},
});
export { expect } from '@playwright/test';
Auth Setup (Manual Login)
import { test as setup, chromium } from '@playwright/test';
import path from 'path';
import fs from 'fs';
const authFile = path.resolve(__dirname, '../.auth/session.json');
const userDataDir = path.resolve(__dirname, '../.auth/user-data');
const extensionPath = path.resolve(__dirname, '../dist');
setup('authenticate', async () => {
if (fs.existsSync(authFile)) {
const stats = fs.statSync(authFile);
const ageHours = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60);
if (ageHours < 24) {
console.log('Using existing auth');
return;
}
}
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--no-first-run',
],
});
const page = await context.newPage();
await page.goto('https://example.com/login');
await page.waitForURL('**/dashboard/**', { timeout: 300000 });
await context.storageState({ path: authFile });
await context.close();
});
Example E2E Test
import { test, expect } from './fixtures';
test('extracts profile data', async ({ context }) => {
const page = await context.newPage();
await page.goto('https://example.com/profile/user123');
await page.waitForSelector('#my-extension-panel', { timeout: 10000 });
const name = await page.locator('#my-extension-panel .name').textContent();
expect(name).toBeTruthy();
});
Key Points:
- Extensions require
headless: false
- Use
launchPersistentContext to preserve auth
- Add
--disable-blink-features=AutomationControlled to avoid detection
- Single worker (
workers: 1) for extension testing
Common Pitfalls
- Losing user gesture -
sidePanel.open() must be called synchronously with click, not after await
- Trusting raw storage values - Always use getter functions with defaults and migration
- Race conditions - Multiple event sources (storage + messages) need debouncing
- Forgetting to rebuild - TypeScript changes require
npm run build
- Sidepanel not ready - Use retry pattern when sending messages after open
- Context invalidation - Check
chrome.runtime.id before API calls
- Service worker termination - Don't rely on in-memory state; use storage
Preserving UI State
Problem: Temporary messages (like "No post detected") can overwrite important UI (like queue display).
Solution: Check existing state before showing temporary content:
export async function showGuidanceBubble(context: "notAPost"): Promise<void> {
const queueCount = await getQueueCount();
if (queueCount > 0) {
return;
}
}
Quick Reference
chrome.sidePanel.open({ windowId: tab.windowId });
const result = await chrome.storage.local.get([KEY]);
return result[KEY] ?? defaultValue;
for (let i = 0; i < 3; i++) {
try { await chrome.runtime.sendMessage(msg); return; }
catch { await sleep(100 * (i + 1)); }
}
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText,
});
await chrome.action.setBadgeText({ text: "!" });
await chrome.action.setBadgeBackgroundColor({ color: "#ff6b35" });