chore: Remove dss-claude-plugin directory
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
Removing obsolete plugin directory after consolidation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
Strategies package for DSS Claude Plugin.
|
||||
|
||||
This package contains the abstract base classes and concrete implementations
|
||||
for different operational strategies (LOCAL vs REMOTE mode).
|
||||
"""
|
||||
|
||||
from .base import BrowserStrategy, FilesystemStrategy
|
||||
|
||||
__all__ = ["BrowserStrategy", "FilesystemStrategy"]
|
||||
@@ -1,186 +0,0 @@
|
||||
"""
|
||||
Base strategy interfaces for DSS Claude Plugin.
|
||||
|
||||
This module defines the abstract base classes that all strategy implementations
|
||||
must adhere to. These interfaces ensure consistent behavior across different
|
||||
execution modes (LOCAL vs REMOTE) and allow the context to switch strategies
|
||||
transparently.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
|
||||
class BrowserStrategy(ABC):
|
||||
"""
|
||||
Abstract base strategy for browser interactions.
|
||||
|
||||
Provides methods for inspecting and interacting with a web page.
|
||||
Implementations will handle the underlying automation (e.g., Playwright
|
||||
for local, API calls for remote).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_console_logs(
|
||||
self,
|
||||
session_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
level: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve console logs from the browser session.
|
||||
|
||||
Args:
|
||||
session_id: The active session identifier (optional for LOCAL mode).
|
||||
limit: Maximum number of logs to return.
|
||||
level: Filter by log level (e.g., "log", "warn", "error").
|
||||
|
||||
Returns:
|
||||
List of log entries containing message, level, and timestamp.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def capture_screenshot(
|
||||
self,
|
||||
selector: Optional[str] = None,
|
||||
full_page: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Capture a screenshot of the current page or 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 saved screenshot (LOCAL) or URL (REMOTE).
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
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.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_errors(
|
||||
self,
|
||||
severity: Optional[str] = None,
|
||||
limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve accumulated browser errors (console errors, crashes, network failures).
|
||||
|
||||
Args:
|
||||
severity: Filter by error severity.
|
||||
limit: Maximum number of errors to return.
|
||||
|
||||
Returns:
|
||||
List of error details.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def run_accessibility_audit(
|
||||
self,
|
||||
selector: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run accessibility audit using axe-core.
|
||||
|
||||
Args:
|
||||
selector: CSS selector to audit specific element. If None, audits entire page.
|
||||
|
||||
Returns:
|
||||
Dictionary with audit results containing:
|
||||
- violations: List of accessibility violations
|
||||
- passes: List of passing rules
|
||||
- incomplete: List of rules needing review
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_performance_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get performance metrics including Core Web Vitals.
|
||||
|
||||
Returns:
|
||||
Dictionary containing:
|
||||
- navigation_timing: Navigation Timing API data
|
||||
- core_web_vitals: TTFB, FCP, LCP, CLS metrics
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class FilesystemStrategy(ABC):
|
||||
"""
|
||||
Abstract base strategy for filesystem operations.
|
||||
|
||||
Provides methods for reading and searching files.
|
||||
Implementations ensure safe access to the filesystem in Local mode
|
||||
or proxy requests in Remote mode.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def read_file(self, path: str) -> str:
|
||||
"""
|
||||
Read the contents of a file.
|
||||
|
||||
Args:
|
||||
path: Relative or absolute path to the file.
|
||||
|
||||
Returns:
|
||||
File content as string.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file does not exist.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_directory(self, path: str) -> List[str]:
|
||||
"""
|
||||
List contents of a directory.
|
||||
|
||||
Args:
|
||||
path: Directory path.
|
||||
|
||||
Returns:
|
||||
List of filenames and directory names in the path.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def search_files(self, pattern: str, path: str = ".") -> List[str]:
|
||||
"""
|
||||
Search for files matching a pattern.
|
||||
|
||||
Args:
|
||||
pattern: Search pattern (glob or regex depending on implementation).
|
||||
path: Root path to start search from.
|
||||
|
||||
Returns:
|
||||
List of matching file paths.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_file_info(self, path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get metadata about a file.
|
||||
|
||||
Args:
|
||||
path: File path.
|
||||
|
||||
Returns:
|
||||
Dictionary containing metadata (size, created_at, modified_at).
|
||||
"""
|
||||
pass
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Local strategies for direct interaction with the user's environment."""
|
||||
|
||||
from .browser import LocalBrowserStrategy
|
||||
|
||||
__all__ = ["LocalBrowserStrategy"]
|
||||
@@ -1,455 +0,0 @@
|
||||
"""
|
||||
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.")
|
||||
@@ -1,6 +0,0 @@
|
||||
"""REMOTE mode strategy implementations."""
|
||||
|
||||
from .browser import RemoteBrowserStrategy
|
||||
from .filesystem import RemoteFilesystemStrategy
|
||||
|
||||
__all__ = ["RemoteBrowserStrategy", "RemoteFilesystemStrategy"]
|
||||
@@ -1,257 +0,0 @@
|
||||
"""
|
||||
Remote Browser Strategy implementation.
|
||||
Connects to the DSS API to retrieve browser state and logs via Shadow State pattern.
|
||||
"""
|
||||
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import logging
|
||||
import base64
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from ..base import BrowserStrategy
|
||||
from ...core.context import DSSContext
|
||||
|
||||
# Configure module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteBrowserStrategy(BrowserStrategy):
|
||||
"""
|
||||
Implements browser interaction via remote API calls.
|
||||
Relies on the browser-side Logger to sync state to the server.
|
||||
"""
|
||||
|
||||
def __init__(self, context: DSSContext):
|
||||
"""Initialize with context."""
|
||||
self.context = context
|
||||
|
||||
async def _get_logs_from_api(self, session_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch all logs for a specific session from the remote API.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to query. Uses default if None.
|
||||
|
||||
Returns:
|
||||
List of log entries.
|
||||
"""
|
||||
if session_id is None:
|
||||
session_id = self.context.session_id or "latest"
|
||||
|
||||
base_url = self.context.get_api_url()
|
||||
|
||||
# Ensure base_url doesn't have trailing slash for clean concatenation
|
||||
base_url = base_url.rstrip('/')
|
||||
url = f"{base_url}/api/browser-logs/{session_id}"
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10.0)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 404:
|
||||
logger.warning(f"Session {session_id} not found on remote server.")
|
||||
return []
|
||||
|
||||
if response.status != 200:
|
||||
logger.error(f"Failed to fetch logs: {response.status} {response.reason}")
|
||||
return []
|
||||
|
||||
data = await response.json()
|
||||
|
||||
# The API is expected to return the exportJSON() structure from browser-logger.js
|
||||
# Structure: { sessionId: "...", logs: [...], diagnostic: {...} }
|
||||
return data.get("logs", [])
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Network error fetching browser logs: {str(e)}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in RemoteBrowserStrategy: {str(e)}")
|
||||
return []
|
||||
|
||||
async def get_console_logs(
|
||||
self,
|
||||
session_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
level: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get browser console logs from the remote API.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to retrieve logs for.
|
||||
limit: Maximum number of logs to return.
|
||||
level: Filter by log level (log, info, warn, error).
|
||||
"""
|
||||
logs = await self._get_logs_from_api(session_id)
|
||||
|
||||
# Filter by console category mostly, but also capture uncaught errors
|
||||
console_logs = [
|
||||
l for l in logs
|
||||
if l.get("category") in ["console", "uncaughtError", "unhandledRejection"]
|
||||
]
|
||||
|
||||
# Filter by level if requested
|
||||
if level:
|
||||
console_logs = [l for l in console_logs if l.get("level") == level]
|
||||
|
||||
# Sort by timestamp descending (newest first)
|
||||
console_logs.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
|
||||
|
||||
return console_logs[:limit]
|
||||
|
||||
async def capture_screenshot(
|
||||
self,
|
||||
selector: Optional[str] = None,
|
||||
full_page: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Capture a screenshot.
|
||||
|
||||
In REMOTE mode, this requests the server to perform the capture or returns
|
||||
a placeholder URL if the server capability isn't available.
|
||||
|
||||
Returns:
|
||||
URL to screenshot or placeholder message.
|
||||
"""
|
||||
# Placeholder implementation until server-side rendering/CDP proxy is ready.
|
||||
# Ideally, we would POST to /api/commands/{session_id}/screenshot
|
||||
logger.warning("Remote screenshot capture is not yet fully implemented on server.")
|
||||
|
||||
# Return placeholder URL
|
||||
return "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
|
||||
async def get_dom_snapshot(self) -> str:
|
||||
"""
|
||||
Get the current DOM snapshot via Shadow State.
|
||||
|
||||
Retrieves the latest log entry with category='snapshot' containing
|
||||
the full HTML state captured by the browser.
|
||||
"""
|
||||
logs = await self._get_logs_from_api()
|
||||
|
||||
# Filter for snapshots
|
||||
snapshots = [
|
||||
l for l in logs
|
||||
if l.get("category") == "snapshot" and "snapshot" in l.get("data", {})
|
||||
]
|
||||
|
||||
if not snapshots:
|
||||
return "<!-- No Shadow State snapshot available for this session -->"
|
||||
|
||||
# Get the latest one
|
||||
latest = max(snapshots, key=lambda x: x.get("timestamp", 0))
|
||||
|
||||
# Extract HTML from the snapshot data object safely
|
||||
# Structure in browser-logger: entry.data.snapshot.html
|
||||
try:
|
||||
html = latest.get("data", {}).get("snapshot", {}).get("html", "")
|
||||
if html:
|
||||
return html
|
||||
return "<!-- Corrupted Shadow State snapshot data -->"
|
||||
except (KeyError, AttributeError):
|
||||
return "<!-- Corrupted or unexpected snapshot data format -->"
|
||||
|
||||
async def get_errors(
|
||||
self,
|
||||
severity: Optional[str] = None,
|
||||
limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get error logs from the remote API.
|
||||
|
||||
Args:
|
||||
severity: Filter by severity (not implemented yet).
|
||||
limit: Maximum number of errors to return.
|
||||
|
||||
Returns:
|
||||
List of error entries.
|
||||
"""
|
||||
logs = await self._get_logs_from_api()
|
||||
|
||||
# Filter for errors
|
||||
errors = [l for l in logs if l.get("level") == "error"]
|
||||
|
||||
# Sort newest first
|
||||
errors.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
|
||||
|
||||
return errors[:limit]
|
||||
|
||||
async def run_accessibility_audit(
|
||||
self,
|
||||
selector: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get accessibility audit results from Shadow State.
|
||||
|
||||
In REMOTE mode, this retrieves the accessibility data captured by the
|
||||
browser-side logger using the captureAccessibilitySnapshot() method.
|
||||
|
||||
Args:
|
||||
selector: Not used in REMOTE mode (filter not supported).
|
||||
|
||||
Returns:
|
||||
Accessibility audit results if available in Shadow State.
|
||||
"""
|
||||
logs = await self._get_logs_from_api()
|
||||
|
||||
# Look for accessibility audits in the logs
|
||||
audits = [
|
||||
l for l in logs
|
||||
if l.get("category") == "accessibility" or l.get("category") == "accessibilitySnapshot"
|
||||
]
|
||||
|
||||
if not audits:
|
||||
return {
|
||||
"violations": [],
|
||||
"passes": [],
|
||||
"incomplete": [],
|
||||
"message": "No accessibility audit found in Shadow State. Trigger audit from browser console using __DSS_BROWSER_LOGS.audit()"
|
||||
}
|
||||
|
||||
# Get the latest audit
|
||||
latest = max(audits, key=lambda x: x.get("timestamp", 0))
|
||||
data = latest.get("data", {})
|
||||
|
||||
# Extract accessibility results
|
||||
if "results" in data:
|
||||
return data["results"]
|
||||
elif "accessibility" in data:
|
||||
return data["accessibility"]
|
||||
else:
|
||||
return data
|
||||
|
||||
async def get_performance_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get performance metrics from Shadow State.
|
||||
|
||||
In REMOTE mode, this retrieves Core Web Vitals and performance data
|
||||
captured by the browser-side logger.
|
||||
|
||||
Returns:
|
||||
Dictionary with performance metrics if available.
|
||||
"""
|
||||
logs = await self._get_logs_from_api()
|
||||
|
||||
# Look for performance metrics in the logs
|
||||
perf_logs = [
|
||||
l for l in logs
|
||||
if l.get("category") in ["performance", "accessibilitySnapshot"]
|
||||
]
|
||||
|
||||
if not perf_logs:
|
||||
return {
|
||||
"error": "No performance data found in Shadow State.",
|
||||
"message": "Performance metrics are captured automatically during page load."
|
||||
}
|
||||
|
||||
# Get the latest performance entry
|
||||
latest = max(perf_logs, key=lambda x: x.get("timestamp", 0))
|
||||
data = latest.get("data", {})
|
||||
|
||||
# Try to extract performance data from accessibilitySnapshot or performance entry
|
||||
if "performance" in data:
|
||||
return {"core_web_vitals": data["performance"]}
|
||||
else:
|
||||
return {"performance_data": data}
|
||||
@@ -1,88 +0,0 @@
|
||||
"""
|
||||
Remote Filesystem Strategy implementation.
|
||||
Filesystem operations are restricted in REMOTE mode for security.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from ..base import FilesystemStrategy
|
||||
from ...core.context import DSSContext
|
||||
|
||||
# Configure module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteFilesystemStrategy(FilesystemStrategy):
|
||||
"""
|
||||
Implements filesystem operations via remote API calls.
|
||||
|
||||
Note: Direct filesystem access is restricted in REMOTE mode for security.
|
||||
Most operations will raise NotImplementedError or return empty results.
|
||||
Users should use LOCAL mode for filesystem operations.
|
||||
"""
|
||||
|
||||
def __init__(self, context: DSSContext):
|
||||
"""Initialize with context."""
|
||||
self.context = context
|
||||
|
||||
async def read_file(self, path: str) -> str:
|
||||
"""
|
||||
Read file contents.
|
||||
|
||||
NOT AVAILABLE in REMOTE mode for security reasons.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Always, as direct file access is restricted.
|
||||
"""
|
||||
logger.error("Filesystem read operations are not available in REMOTE mode.")
|
||||
raise NotImplementedError(
|
||||
"Direct filesystem access is restricted in REMOTE mode. "
|
||||
"Please use LOCAL mode for file operations, or use API-based file uploads."
|
||||
)
|
||||
|
||||
async def list_directory(self, path: str) -> List[str]:
|
||||
"""
|
||||
List directory contents.
|
||||
|
||||
NOT AVAILABLE in REMOTE mode for security reasons.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Always, as directory listing is restricted.
|
||||
"""
|
||||
logger.error("Filesystem list operations are not available in REMOTE mode.")
|
||||
raise NotImplementedError(
|
||||
"Directory listing is restricted in REMOTE mode. "
|
||||
"Please use LOCAL mode for filesystem operations."
|
||||
)
|
||||
|
||||
async def search_files(self, pattern: str, path: str = ".") -> List[str]:
|
||||
"""
|
||||
Search for files matching a pattern.
|
||||
|
||||
NOT AVAILABLE in REMOTE mode for security reasons.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Always, as file search is restricted.
|
||||
"""
|
||||
logger.error("Filesystem search operations are not available in REMOTE mode.")
|
||||
raise NotImplementedError(
|
||||
"File search is restricted in REMOTE mode. "
|
||||
"Please use LOCAL mode for filesystem operations."
|
||||
)
|
||||
|
||||
async def get_file_info(self, path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get file metadata.
|
||||
|
||||
NOT AVAILABLE in REMOTE mode for security reasons.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Always, as file info access is restricted.
|
||||
"""
|
||||
logger.error("Filesystem info operations are not available in REMOTE mode.")
|
||||
raise NotImplementedError(
|
||||
"File metadata access is restricted in REMOTE mode. "
|
||||
"Please use LOCAL mode for filesystem operations."
|
||||
)
|
||||
Reference in New Issue
Block a user