Initial commit: Clean DSS implementation
Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
This commit is contained in:
455
dss-claude-plugin/strategies/local/browser.py
Normal file
455
dss-claude-plugin/strategies/local/browser.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Local Browser Strategy implementation using Playwright.
|
||||
|
||||
Provides direct, local control over a browser for tasks like DOM inspection,
|
||||
screenshotting, and running audits. This is the LOCAL mode counterpart to
|
||||
RemoteBrowserStrategy which uses Shadow State pattern.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from ..base import BrowserStrategy
|
||||
|
||||
# Logger setup
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# URL for axe-core accessibility testing library
|
||||
AXE_CORE_SCRIPT_URL = "https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.8.4/axe.min.js"
|
||||
|
||||
# Optional Playwright import for graceful degradation
|
||||
try:
|
||||
from playwright.async_api import (
|
||||
Browser,
|
||||
ConsoleMessage,
|
||||
Error as PlaywrightError,
|
||||
Page,
|
||||
Playwright,
|
||||
TimeoutError as PlaywrightTimeoutError,
|
||||
async_playwright,
|
||||
)
|
||||
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
# Create dummy types for type hinting when Playwright is not installed
|
||||
Playwright = Type[Any]
|
||||
Browser = Type[Any]
|
||||
Page = Type[Any]
|
||||
ConsoleMessage = Type[Any]
|
||||
PlaywrightError = Exception
|
||||
PlaywrightTimeoutError = Exception
|
||||
|
||||
|
||||
class LocalBrowserStrategy(BrowserStrategy):
|
||||
"""
|
||||
Implements the BrowserStrategy using Playwright for local browser automation.
|
||||
|
||||
This strategy manages a singleton browser instance to perform actions
|
||||
directly on the local machine. It is ideal for development environments
|
||||
where direct access to a browser is possible.
|
||||
|
||||
Features:
|
||||
- Browser pool pattern (reuses browser instances)
|
||||
- Console log capture via CDP
|
||||
- Screenshot capture (element or full page)
|
||||
- DOM snapshot retrieval
|
||||
- Accessibility auditing via axe-core injection
|
||||
- Core Web Vitals and performance metrics
|
||||
|
||||
Note: This class requires Playwright to be installed.
|
||||
Run `pip install "playwright"` and `playwright install chromium`.
|
||||
"""
|
||||
|
||||
# Class-level browser pool (shared across instances)
|
||||
_playwright: Optional[Playwright] = None
|
||||
_browser: Optional[Browser] = None
|
||||
_browser_lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, context: Any):
|
||||
"""
|
||||
Initialize the LocalBrowserStrategy.
|
||||
|
||||
Args:
|
||||
context: The DSSContext providing configuration and session info.
|
||||
"""
|
||||
self.context = context
|
||||
self.page: Optional[Page] = None
|
||||
self._console_logs: List[Any] = []
|
||||
self._page_errors: List[Any] = []
|
||||
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
logger.warning(
|
||||
"Playwright not found. LocalBrowserStrategy will be non-functional. "
|
||||
"Please run 'pip install \"playwright\"' and 'playwright install chromium'."
|
||||
)
|
||||
|
||||
def _check_playwright(self) -> None:
|
||||
"""Ensure Playwright is available, raising an error if not."""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
raise NotImplementedError(
|
||||
"Playwright is not installed. Cannot use LocalBrowserStrategy. "
|
||||
"Install with: pip install playwright && playwright install chromium"
|
||||
)
|
||||
|
||||
async def launch(self, headless: bool = True) -> None:
|
||||
"""
|
||||
Launch and initialize the Playwright browser instance.
|
||||
|
||||
This method is idempotent and ensures that a single browser instance
|
||||
is shared across the application lifecycle (browser pool pattern).
|
||||
|
||||
Args:
|
||||
headless: Whether to run browser in headless mode (default: True)
|
||||
"""
|
||||
self._check_playwright()
|
||||
|
||||
# Check if browser is already running
|
||||
if LocalBrowserStrategy._browser and LocalBrowserStrategy._browser.is_connected():
|
||||
logger.debug("Browser already running, reusing existing instance.")
|
||||
return
|
||||
|
||||
async with LocalBrowserStrategy._browser_lock:
|
||||
# Double-check lock to prevent race conditions
|
||||
if LocalBrowserStrategy._browser and LocalBrowserStrategy._browser.is_connected():
|
||||
return
|
||||
|
||||
logger.info("Starting Playwright...")
|
||||
LocalBrowserStrategy._playwright = await async_playwright().start()
|
||||
|
||||
logger.info("Launching new browser instance...")
|
||||
LocalBrowserStrategy._browser = await LocalBrowserStrategy._playwright.chromium.launch(
|
||||
headless=headless
|
||||
)
|
||||
logger.info("Browser instance launched successfully.")
|
||||
|
||||
async def navigate(self, url: str, wait_until: str = "domcontentloaded") -> None:
|
||||
"""
|
||||
Navigate the browser to a specific URL.
|
||||
|
||||
This creates a new page context for the session, replacing any
|
||||
existing page. It also sets up listeners for console logs and errors.
|
||||
|
||||
Args:
|
||||
url: The URL to navigate to.
|
||||
wait_until: The navigation event to wait for
|
||||
('load', 'domcontentloaded', 'networkidle').
|
||||
"""
|
||||
await self.launch()
|
||||
|
||||
# Close existing page if any
|
||||
if self.page and not self.page.is_closed():
|
||||
await self.page.close()
|
||||
|
||||
if not LocalBrowserStrategy._browser:
|
||||
raise RuntimeError("Browser is not launched. Call launch() first.")
|
||||
|
||||
# Create new page
|
||||
self.page = await LocalBrowserStrategy._browser.new_page()
|
||||
self._console_logs.clear()
|
||||
self._page_errors.clear()
|
||||
|
||||
# Set up event listeners for log capture
|
||||
self.page.on("console", self._on_console_message)
|
||||
self.page.on("pageerror", self._on_page_error)
|
||||
|
||||
logger.info(f"Navigating to {url}...")
|
||||
await self.page.goto(url, wait_until=wait_until)
|
||||
logger.info(f"Navigation to {url} complete.")
|
||||
|
||||
def _on_console_message(self, msg: Any) -> None:
|
||||
"""Handle console message events."""
|
||||
self._console_logs.append(msg)
|
||||
|
||||
def _on_page_error(self, error: Any) -> None:
|
||||
"""Handle page error events."""
|
||||
self._page_errors.append(error)
|
||||
|
||||
async def get_console_logs(
|
||||
self,
|
||||
session_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
level: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve captured console logs from the current page.
|
||||
|
||||
Args:
|
||||
session_id: Ignored in LOCAL mode (used for API compatibility).
|
||||
limit: Maximum number of logs to return.
|
||||
level: Filter by log level ('log', 'warn', 'error', 'info', 'debug').
|
||||
|
||||
Returns:
|
||||
List of log entries with level, text, and location.
|
||||
"""
|
||||
if not self.page:
|
||||
logger.warning("No active page. Returning empty logs.")
|
||||
return []
|
||||
|
||||
logs = []
|
||||
for msg in self._console_logs:
|
||||
try:
|
||||
log_entry = {
|
||||
"level": msg.type,
|
||||
"message": msg.text,
|
||||
"timestamp": None, # Playwright doesn't provide timestamp directly
|
||||
"category": "console",
|
||||
"data": {
|
||||
"location": msg.location if hasattr(msg, 'location') else None,
|
||||
}
|
||||
}
|
||||
logs.append(log_entry)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error processing console message: {e}")
|
||||
|
||||
if level:
|
||||
logs = [log for log in logs if log["level"] == level]
|
||||
|
||||
# Return most recent logs up to limit
|
||||
return logs[-limit:]
|
||||
|
||||
async def capture_screenshot(
|
||||
self, selector: Optional[str] = None, full_page: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Capture a screenshot of the current page or a specific element.
|
||||
|
||||
Args:
|
||||
selector: CSS selector to capture a specific element.
|
||||
If None, captures the viewport.
|
||||
full_page: If True, captures the full scrollable page content.
|
||||
Ignored if selector is provided.
|
||||
|
||||
Returns:
|
||||
Path to the saved screenshot file.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no active page is available.
|
||||
"""
|
||||
if not self.page or self.page.is_closed():
|
||||
raise RuntimeError("No active page to capture screenshot from.")
|
||||
|
||||
# Generate unique filename
|
||||
session_id = getattr(self.context, 'session_id', 'local')
|
||||
path = os.path.join(
|
||||
tempfile.gettempdir(), f"dss_screenshot_{session_id}.png"
|
||||
)
|
||||
|
||||
try:
|
||||
if selector:
|
||||
element = self.page.locator(selector)
|
||||
await element.screenshot(path=path, timeout=10000)
|
||||
logger.info(f"Element screenshot saved to {path}")
|
||||
else:
|
||||
await self.page.screenshot(path=path, full_page=full_page, timeout=10000)
|
||||
logger.info(f"Page screenshot saved to {path}")
|
||||
return path
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to capture screenshot: {e}")
|
||||
raise
|
||||
|
||||
async def get_dom_snapshot(self) -> str:
|
||||
"""
|
||||
Get the current DOM state as an HTML string.
|
||||
|
||||
Returns:
|
||||
String containing the outer HTML of the document.
|
||||
"""
|
||||
if not self.page or self.page.is_closed():
|
||||
return "<!-- No active page to get DOM snapshot from. -->"
|
||||
return await self.page.content()
|
||||
|
||||
async def get_errors(
|
||||
self, severity: Optional[str] = None, limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve captured page errors (e.g., uncaught exceptions).
|
||||
|
||||
Args:
|
||||
severity: Filter by severity (not yet implemented).
|
||||
limit: Maximum number of errors to return.
|
||||
|
||||
Returns:
|
||||
List of error details with name, message, and stack trace.
|
||||
"""
|
||||
errors = []
|
||||
for err in self._page_errors:
|
||||
try:
|
||||
error_entry = {
|
||||
"level": "error",
|
||||
"category": "uncaughtError",
|
||||
"message": str(err),
|
||||
"data": {
|
||||
"name": getattr(err, 'name', 'Error'),
|
||||
"stack": getattr(err, 'stack', None),
|
||||
}
|
||||
}
|
||||
errors.append(error_entry)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error processing page error: {e}")
|
||||
|
||||
return errors[-limit:]
|
||||
|
||||
async def run_accessibility_audit(
|
||||
self, selector: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run an accessibility audit on the current page using axe-core.
|
||||
|
||||
This injects the axe-core library into the page and runs a full
|
||||
accessibility scan.
|
||||
|
||||
Args:
|
||||
selector: A CSS selector to limit the audit to a specific element.
|
||||
If None, audits the entire page.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the axe-core audit results with:
|
||||
- violations: List of accessibility violations
|
||||
- passes: List of passing rules
|
||||
- incomplete: List of rules that need review
|
||||
- inapplicable: List of rules that don't apply
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no active page is available.
|
||||
"""
|
||||
if not self.page or self.page.is_closed():
|
||||
raise RuntimeError("No active page to run accessibility audit on.")
|
||||
|
||||
logger.info("Injecting axe-core library...")
|
||||
await self.page.add_script_tag(url=AXE_CORE_SCRIPT_URL)
|
||||
|
||||
# Wait for axe to be available
|
||||
await self.page.wait_for_function("typeof axe !== 'undefined'", timeout=5000)
|
||||
|
||||
logger.info(f"Running accessibility audit{' on ' + selector if selector else ''}...")
|
||||
|
||||
# Run axe with selector context if provided
|
||||
if selector:
|
||||
result = await self.page.evaluate(
|
||||
"(selector) => axe.run(selector)", selector
|
||||
)
|
||||
else:
|
||||
result = await self.page.evaluate("() => axe.run()")
|
||||
|
||||
violations_count = len(result.get('violations', []))
|
||||
logger.info(f"Accessibility audit complete. Found {violations_count} violations.")
|
||||
|
||||
return result
|
||||
|
||||
async def get_performance_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get performance metrics, including Navigation Timing and Core Web Vitals.
|
||||
|
||||
Returns:
|
||||
Dictionary containing:
|
||||
- navigation_timing: Raw Navigation Timing API data
|
||||
- core_web_vitals: FCP, LCP, and CLS metrics
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no active page is available.
|
||||
"""
|
||||
if not self.page or self.page.is_closed():
|
||||
raise RuntimeError("No active page to get performance metrics from.")
|
||||
|
||||
# 1. Get Navigation Timing API metrics
|
||||
timing_raw = await self.page.evaluate(
|
||||
"() => JSON.stringify(window.performance.timing)"
|
||||
)
|
||||
nav_timing = json.loads(timing_raw)
|
||||
|
||||
# 2. Get Core Web Vitals via PerformanceObserver
|
||||
# This script collects buffered entries and waits briefly for new ones
|
||||
metrics_script = """
|
||||
() => new Promise((resolve) => {
|
||||
const metrics = { lcp: null, cls: 0, fcp: null, ttfb: null };
|
||||
|
||||
// Get TTFB from navigation timing
|
||||
const navEntry = performance.getEntriesByType('navigation')[0];
|
||||
if (navEntry) {
|
||||
metrics.ttfb = navEntry.responseStart - navEntry.requestStart;
|
||||
}
|
||||
|
||||
// Get FCP from paint entries
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
for (const entry of paintEntries) {
|
||||
if (entry.name === 'first-contentful-paint') {
|
||||
metrics.fcp = entry.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up observer for LCP and CLS
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'largest-contentful-paint') {
|
||||
metrics.lcp = entry.startTime;
|
||||
}
|
||||
if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
|
||||
metrics.cls += entry.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({
|
||||
type: 'largest-contentful-paint',
|
||||
buffered: true
|
||||
});
|
||||
observer.observe({
|
||||
type: 'layout-shift',
|
||||
buffered: true
|
||||
});
|
||||
|
||||
// Give some time for metrics to be collected
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(metrics);
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
// PerformanceObserver may not be fully supported
|
||||
resolve(metrics);
|
||||
}
|
||||
})
|
||||
"""
|
||||
core_web_vitals = await self.page.evaluate(metrics_script)
|
||||
|
||||
return {
|
||||
"navigation_timing": nav_timing,
|
||||
"core_web_vitals": core_web_vitals
|
||||
}
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close the current page. Browser instance is kept in pool for reuse.
|
||||
|
||||
To fully close the browser, use close_browser() class method.
|
||||
"""
|
||||
if self.page and not self.page.is_closed():
|
||||
await self.page.close()
|
||||
self.page = None
|
||||
self._console_logs.clear()
|
||||
self._page_errors.clear()
|
||||
logger.info("Page closed.")
|
||||
|
||||
@classmethod
|
||||
async def close_browser(cls) -> None:
|
||||
"""
|
||||
Close the browser and stop the Playwright instance.
|
||||
|
||||
This is a class method that closes the shared browser pool.
|
||||
Should be called during application shutdown.
|
||||
"""
|
||||
async with cls._browser_lock:
|
||||
if cls._browser:
|
||||
await cls._browser.close()
|
||||
cls._browser = None
|
||||
logger.info("Browser instance closed.")
|
||||
|
||||
if cls._playwright:
|
||||
await cls._playwright.stop()
|
||||
cls._playwright = None
|
||||
logger.info("Playwright stopped.")
|
||||
Reference in New Issue
Block a user