スキル一覧に戻る
SecurityRonin

chrome-extension-development

by SecurityRonin

Battle-tested skills for Claude Code. Deployment patterns, browser automation, and hard-won knowledge from real projects.

0🍴 0📅 2026年1月17日
GitHubで見るManusで実行

SKILL.md


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/*.tsdist/*.js
  • May bundle imported CSS into JS
  • Does NOT automatically copy: HTML, standalone CSS, manifest.json, static assets
// 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.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

// 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">&times;</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 launchPersistentContext to preserve auth
  • Add --disable-blink-features=AutomationControlled to avoid detection
  • Single worker (workers: 1) for extension testing

Common Pitfalls

  1. Losing user gesture - sidePanel.open() must be called synchronously with click, not after await
  2. Trusting raw storage values - Always use getter functions with defaults and migration
  3. Race conditions - Multiple event sources (storage + messages) need debouncing
  4. Forgetting to rebuild - TypeScript changes require npm run build
  5. Sidepanel not ready - Use retry pattern when sending messages after open
  6. Context invalidation - Check chrome.runtime.id before API calls
  7. 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" });

スコア

総合スコア

70/100

リポジトリの品質指標に基づく評価

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

+10
人気

GitHub Stars 100以上

0/15
最近の活動

1ヶ月以内に更新

+10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

0/5
タグ

1つ以上のタグが設定されている

+5

レビュー

💬

レビュー機能は近日公開予定です