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
// vite.config.ts
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
build: {
outDir: "dist",
copyPublicDir: true, // Copies public/ to dist/
rollupOptions: {
input: {
background: resolve(__dirname, "src/background.ts"),
sidepanel: resolve(__dirname, "src/sidepanel.ts"),
// Add more entry points as needed
},
output: {
entryFileNames: "[name].js",
},
},
},
publicDir: "public", // HTML, CSS, manifest.json, icons
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.executeScriptreplaceschrome.tabs.executeScript - •Host permissions moved from
permissionstohost_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
// content.ts
import { logger } from './logger';
// Log when script is parsed (debugging lifecycle issues)
logger.debug('Content script starting');
// Check if extension context is still valid (SPA navigation can invalidate)
function isExtensionContextValid(): boolean {
try {
return chrome?.runtime?.id !== undefined;
} catch {
return false;
}
}
// Safe initialization
async function init(): Promise<void> {
if (!isExtensionContextValid()) {
logger.warn('Extension context invalid, skipping init');
return;
}
// Wait for page to be ready
if (document.readyState === 'loading') {
await new Promise(resolve =>
document.addEventListener('DOMContentLoaded', resolve)
);
}
// Your initialization code
setupUI();
observePageChanges();
}
init().catch(logger.error);
Communicating with Background
// content.ts - Send to 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);
}
});
}
// background.ts - Listen for messages
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'getProfile') {
fetchProfile(message.id).then(sendResponse);
return true; // Keep channel open for async response
}
});
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);
`;
// Isolate styles with shadow DOM
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;
// Create main element
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)`;
// Minimized orb (clickable indicator)
const orb = document.createElement('div');
orb.className = 'sr-panel__orb sr-panel__orb--visible';
element.appendChild(orb);
// Expanded content container
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');
}
}
// Toggle on orb click
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;
// Re-attach minimize button listener after content update
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) => {
// Don't drag if clicking buttons
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';
// Persist position
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
/* CSS Variables for theming */
: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);
}
/* Panel Container - Fixed Position */
.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;
}
/* Minimized Orb */
.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);
}
/* Pulse animation for alerts */
.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); }
}
/* Expanded Content */
.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; // 0-1
elapsed: number; // ms
}
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)
// Show basic info immediately while full extraction happens
function primePanel(): void {
if (!panel) return;
// Get quick data from visible DOM
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);
}
};
// Strategy 1: Intercept history.pushState and history.replaceState
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();
};
// Strategy 2: Listen to popstate event (back/forward navigation)
window.addEventListener('popstate', checkUrlChange);
// Strategy 3: MutationObserver as fallback
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)) {
// Switch to full mode
panel?.setMinimalMode(false);
primePanel();
setTimeout(() => extractData(), 500);
} else {
// Switch to history mode
panel?.setMinimalMode(true);
loadHistory();
}
}
Background Script URL Notification (More Reliable)
// background.ts - Notify content script of tab URL changes
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.url) {
chrome.tabs.sendMessage(tabId, {
type: 'URL_CHANGED',
url: changeInfo.url,
}).catch(() => {}); // Ignore if no listener
}
});
// content.ts - Listen for URL change messages
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 {
// Remove existing
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);
// Animate in
requestAnimationFrame(() => {
popup.classList.add('popup--visible');
});
// Close handlers
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);
// Close on Escape
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:
// Cache for synchronous access (async getPartyCode() won't work for user gesture)
let cachedPartyCode: string | null = null;
// Initialize cache on load
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> {
// Use CACHED value for synchronous check (preserves user gesture)
const hasPartyCode = cachedPartyCode !== null;
if (!hasPartyCode) {
// Open sidepanel FIRST - must be synchronous with user gesture
if (tab.windowId) {
chrome.sidePanel.open({ windowId: tab.windowId }).catch(() => {});
}
// NOW do async operations
await doAsyncWork();
return;
}
// ... rest of logic
}
Disabling Auto-Open Behavior
// In your setup function (called from onInstalled)
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] ?? [];
// Handle migration from old format
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();
// Prevent duplicates
if (queue.some(p => p.url === post.url)) {
return;
}
queue.push(post);
// Enforce max size
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:
// BAD - can be undefined or wrong format
chrome.storage.onChanged.addListener((changes) => {
const queue = changes.pendingUrlQueue?.newValue || [];
updateUI(queue); // May crash or show wrong data
});
// GOOD - consistent data format
chrome.storage.onChanged.addListener((changes) => {
if (changes.pendingUrlQueue) {
debouncedRefresh(); // Will call getQueue() internally
}
});
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> {
// Track metadata from any trigger
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); // 50ms debounce
}
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)));
}
}
}
}
// Usage
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);
}
// Must return true for async response
return true;
});
Context Menus
export async function setupContextMenus(): Promise<void> {
// Disable auto-open sidepanel - we handle it ourselves
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"],
});
}
// Event listener setup
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,
},
() => {
// Auto-clear after 5 seconds
setTimeout(() => {
chrome.notifications.clear(notificationId, () => {});
}, 5000);
}
);
}
Keyboard Shortcuts
// In background.ts
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 = [];
}
}
// Usage: 3 posts per minute
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:
// logger.ts
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),
// Create module-specific logger
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),
};
},
};
// Usage
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
// Extract data from page
export async function getPageContent(tabId: number): Promise<string> {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText,
});
return results[0]?.result ?? "";
}
// Execute function with arguments
export async function detectPostFromFeed(tabId: number): Promise<{
url: string | null;
extractedPost: ExtractedPost | null;
}> {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: detectPostInPage, // Function defined elsewhere
});
return results[0]?.result ?? { url: null, extractedPost: null };
}
Testing with Vitest
Mock Chrome APIs
// test-setup.ts
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() },
},
};
// @ts-expect-error - mock chrome global
globalThis.chrome = mockChrome;
export { mockChrome };
Vitest Configuration
// vite.config.ts
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' });
// Should not call set because URL is duplicate
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:
// Function must be async
export async function showGuidanceBubble(context: "notAPost"): Promise<void> {
const queueCount = await getQueueCount();
if (queueCount > 0) {
return; // Don't show guidance if queue has items
}
// ... show guidance
}
// Test must await
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
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import path from 'path';
const extensionPath = path.resolve(__dirname, 'dist');
export default defineConfig({
testDir: './tests',
timeout: 120000, // 2 min for complex pages
retries: 0,
workers: 1, // Extensions require single worker
use: {
headless: false, // Extensions require headed mode
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
// tests/fixtures.ts
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) => {
// Get extension ID from service worker
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)
// tests/auth.setup.ts
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 () => {
// Check if already authenticated (less than 24h old)
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;
}
}
// Launch with extension for manual login
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');
// Wait for user to complete login (5 min timeout)
await page.waitForURL('**/dashboard/**', { timeout: 300000 });
// Save session
await context.storageState({ path: authFile });
await context.close();
});
Example E2E Test
// tests/profile.spec.ts
import { test, expect } from './fixtures';
test('extracts profile data', async ({ context }) => {
const page = await context.newPage();
await page.goto('https://example.com/profile/user123');
// Wait for extension UI to appear
await page.waitForSelector('#my-extension-panel', { timeout: 10000 });
// Verify extraction
const name = await page.locator('#my-extension-panel .name').textContent();
expect(name).toBeTruthy();
});
Key Points:
- •Extensions require
headless: false - •Use
launchPersistentContextto preserve auth - •Add
--disable-blink-features=AutomationControlledto 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.idbefore 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> {
// Don't overwrite queue display
const queueCount = await getQueueCount();
if (queueCount > 0) {
return;
}
// ... show guidance
}
Quick Reference
// Open sidepanel (SYNC with user gesture)
chrome.sidePanel.open({ windowId: tab.windowId });
// Storage with defaults
const result = await chrome.storage.local.get([KEY]);
return result[KEY] ?? defaultValue;
// Message with retry
for (let i = 0; i < 3; i++) {
try { await chrome.runtime.sendMessage(msg); return; }
catch { await sleep(100 * (i + 1)); }
}
// Execute in tab
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText,
});
// Badge
await chrome.action.setBadgeText({ text: "!" });
await chrome.action.setBadgeBackgroundColor({ color: "#ff6b35" });