diff --git a/README.md b/README.md index aea5b8a..06ca076 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Options: --storage PATH Storage directory (default: claude_sync_data) --headless Run browser in headless mode --quiet Suppress progress output + --strict Stop on first error and produce detailed failure report ``` ## Project Structure @@ -124,14 +125,23 @@ claude_sync/ ## How It Works 1. **Browser Automation**: Uses Playwright to control Chrome via DevTools Protocol -2. **Project Discovery**: Navigates to Claude.ai projects page and clicks "View All" to load all projects -3. **Content Extraction**: For each project: +2. **Cookie Handling**: Automatically detects and accepts GDPR/cookie consent notices +3. **Project Discovery**: Navigates to Claude.ai projects page and clicks "View All" to load all projects +4. **Content Extraction**: For each project: - Navigates to the project page - Extracts list of knowledge files - - Clicks on each file to open the modal + - Uses context manager to safely open/close file modals - Extracts the file content from the modal - Saves to local storage -4. **Progress Tracking**: Provides real-time updates on sync progress +5. **Modal Management**: + - Context manager ensures modals are always closed + - Force cleanup runs between projects + - Periodic cleanup every 5 files within a project + - Aggressive modal removal if stuck states detected +6. **Progress Tracking**: Provides real-time updates on sync progress +7. **Error Handling**: + - Normal mode: Continues on errors and reports them at the end + - Strict mode: Stops on first error and generates detailed failure report ## Architecture @@ -151,6 +161,22 @@ Claude.ai → Browser Automation → HTML Extraction → Local Storage └── Progress Updates ────┘ ``` +## Strict Mode + +When running with `--strict`, the sync will stop immediately on the first error and generate a detailed failure report: + +```bash +python sync_cli.py sync --strict +``` + +The failure report includes: +- Exact file and project that failed +- Error details and timestamp +- Progress at time of failure +- All previous errors encountered + +Report is saved to: `claude_sync_data/.metadata/strict_mode_failure.json` + ## Development ### Running Tests diff --git a/claude_sync/browser/config.py b/claude_sync/browser/config.py index 65f5a49..b890027 100644 --- a/claude_sync/browser/config.py +++ b/claude_sync/browser/config.py @@ -32,6 +32,10 @@ class BrowserConfig(BaseModel): default=720, description="Browser viewport height" ) + strict_mode: bool = Field( + default=False, + description="Stop on first error and produce detailed failure report" + ) def get_chrome_args(self) -> List[str]: """Get Chrome launch arguments for memory optimization and stability.""" diff --git a/claude_sync/browser/connection.py b/claude_sync/browser/connection.py index cec68e9..99a8417 100644 --- a/claude_sync/browser/connection.py +++ b/claude_sync/browser/connection.py @@ -2,6 +2,7 @@ import logging from pathlib import Path from typing import List, Optional +from contextlib import asynccontextmanager import aiofiles from playwright.async_api import BrowserContext, Page, Download @@ -53,6 +54,9 @@ async def navigate(self, url: str, timeout: int = 60000) -> None: logger.info(f"Navigating to: {url}") await page.goto(url, wait_until="domcontentloaded", timeout=timeout) self._current_page = page + + # Check for and handle GDPR/cookie notices + await self._handle_cookie_notices() async def get_page_content(self) -> str: """Get current page HTML content. @@ -164,147 +168,119 @@ async def download_file_content(self, file_name: str) -> Optional[str]: Returns: File content or None if failed """ - page = await self.get_or_create_page() - try: - # Find the file thumbnail by name - thumbnails = await page.query_selector_all('div[data-testid="file-thumbnail"]') - - for thumb in thumbnails: - # Check if this is our file - h3 = await thumb.query_selector('h3') - if h3: - name = await h3.text_content() - if name and name.strip() == file_name: - # Click the thumbnail to open the modal - button = await thumb.query_selector('button') - if button: - logger.info(f"Clicking on file: {file_name}") - await button.click() - - # Wait for modal to appear - await page.wait_for_timeout(1000) - - # Strategy 1: Look for modal/dialog content - # Wait a bit for modal to fully render - await page.wait_for_timeout(1500) - - # Use JavaScript to find the actual content more reliably - content_data = await page.evaluate(''' - () => { - // Find the modal - const modal = document.querySelector('[role="dialog"]'); - if (!modal) return null; - - // Look for the main content div - it has specific styling - // The actual content is usually in a monospace font div - const contentSelectors = [ - 'div[class*="font-mono"]', - 'div[class*="whitespace-pre-wrap"]', - 'pre', - 'code', - ]; - - for (const selector of contentSelectors) { - const elements = modal.querySelectorAll(selector); - for (const el of elements) { - const text = el.textContent?.trim() || ''; - // Skip metadata lines - if (text.includes('KB') && text.includes('lines') && text.length < 100) continue; - if (text.includes('Formatting may be') && text.length < 100) continue; - - // If it's substantial content, return it - if (text.length > 100) { - return { - content: text, - selector: selector, - className: el.className - }; - } - } - } - - // Fallback: get the longest text in the modal - let longestText = ''; - const allElements = modal.querySelectorAll('*'); - for (const el of allElements) { - const text = el.textContent?.trim() || ''; - if (text.length > longestText.length && - !text.includes('KB') && - !text.includes('Formatting may be')) { - longestText = text; - } - } - - return longestText ? { content: longestText, selector: 'fallback' } : null; - } - ''') - - if content_data and content_data.get('content'): - logger.info(f"Found content via {content_data.get('selector', 'unknown')} ({len(content_data['content'])} chars)") - - # Close modal - await self._close_modal(page) + async with self.open_file_modal(file_name) as page: + # Use JavaScript to find the actual content more reliably + content_data = await page.evaluate(''' + () => { + // Find the modal + const modal = document.querySelector('[role="dialog"]'); + if (!modal) return null; + + // Look for the main content div - it has specific styling + // The actual content is usually in a monospace font div + const contentSelectors = [ + 'div[class*="font-mono"]', + 'div[class*="whitespace-pre-wrap"]', + 'pre', + 'code', + ]; + + for (const selector of contentSelectors) { + const elements = modal.querySelectorAll(selector); + for (const el of elements) { + const text = el.textContent?.trim() || ''; + // Skip metadata lines + if (text.includes('KB') && text.includes('lines') && text.length < 100) continue; + if (text.includes('Formatting may be') && text.length < 100) continue; - return content_data['content'].strip() - - # Strategy 2: If no modal found, try getting all text from page - # Sometimes content appears in the main view - await page.wait_for_timeout(1000) - - # Get the full page text and look for file content - # This is less precise but can work as a fallback - all_text = await page.evaluate(''' - () => { - // Get all text content from the page - const walker = document.createTreeWalker( - document.body, - NodeFilter.SHOW_TEXT, - { - acceptNode: function(node) { - // Skip script and style tags - const parent = node.parentElement; - if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - } - }, - false - ); - - const texts = []; - let node; - while (node = walker.nextNode()) { - const text = node.textContent.trim(); - if (text.length > 50) { // Skip short texts - texts.push(text); - } + // If it's substantial content, return it + if (text.length > 100) { + return { + content: text, + selector: selector, + className: el.className + }; + } + } + } + + // Fallback: get the longest text in the modal + let longestText = ''; + const allElements = modal.querySelectorAll('*'); + for (const el of allElements) { + const text = el.textContent?.trim() || ''; + if (text.length > longestText.length && + !text.includes('KB') && + !text.includes('Formatting may be')) { + longestText = text; + } + } + + return longestText ? { content: longestText, selector: 'fallback' } : null; + } + ''') + + if content_data and content_data.get('content'): + logger.info(f"Found content via {content_data.get('selector', 'unknown')} ({len(content_data['content'])} chars)") + return content_data['content'].strip() + + # Strategy 2: If no modal found, try getting all text from page + # Sometimes content appears in the main view + await page.wait_for_timeout(1000) + + # Get the full page text and look for file content + # This is less precise but can work as a fallback + all_text = await page.evaluate(''' + () => { + // Get all text content from the page + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { + acceptNode: function(node) { + // Skip script and style tags + const parent = node.parentElement; + if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') { + return NodeFilter.FILTER_REJECT; } - - // Return the longest text block (likely the file content) - return texts.sort((a, b) => b.length - a.length)[0] || ''; + return NodeFilter.FILTER_ACCEPT; } - ''') - - if all_text and len(all_text) > 100: - logger.info(f"Found content via text extraction ({len(all_text)} chars)") - await self._close_modal(page) - return all_text - - # If still no content, log what we see for debugging - logger.warning(f"Could not find content for {file_name} after clicking") - - # Try to close any modal - await self._close_modal(page) - - return None - - logger.error(f"File '{file_name}' not found on page") - return None - + }, + false + ); + + const texts = []; + let node; + while (node = walker.nextNode()) { + const text = node.textContent.trim(); + if (text.length > 50) { // Skip short texts + texts.push(text); + } + } + + // Return the longest text block (likely the file content) + return texts.sort((a, b) => b.length - a.length)[0] || ''; + } + ''') + + if all_text and len(all_text) > 100: + logger.info(f"Found content via text extraction ({len(all_text)} chars)") + return all_text + + # If still no content, log what we see for debugging + logger.warning(f"Could not find content for {file_name} after clicking") + return None + except Exception as e: logger.error(f"Failed to download file '{file_name}': {e}") + # Context manager ensures modal is closed even on error + + # If we're getting repeated failures, try aggressive cleanup + if "not found on page" not in str(e): + logger.warning("Attempting aggressive modal cleanup after download failure") + await self.force_close_all_modals() + return None async def _close_modal(self, page: Page) -> None: @@ -333,6 +309,289 @@ async def _close_modal(self, page: Page) -> None: except Exception as e: logger.debug(f"Error closing modal: {e}") + async def force_close_all_modals(self) -> None: + """Aggressively close all modals, dialogs, and popups to return to clean state.""" + page = await self.get_or_create_page() + logger.info("Force closing all modals and popups") + + try: + # Strategy 1: Use JavaScript to find and close all dialogs/modals + await page.evaluate(''' + () => { + // First, close any open editable fields (like project description) + const activeElement = document.activeElement; + if (activeElement && (activeElement.tagName === 'TEXTAREA' || + activeElement.tagName === 'INPUT' || + activeElement.contentEditable === 'true')) { + activeElement.blur(); + // Click outside the element to ensure it closes + document.body.click(); + } + + // Find all elements with role="dialog" + const dialogs = document.querySelectorAll('[role="dialog"], [aria-modal="true"]'); + dialogs.forEach(dialog => { + // Try to find close button within dialog using valid CSS selectors + const closeSelectors = [ + 'button[aria-label*="close" i]', + 'button[aria-label*="Close" i]', + '[class*="close-button"]', + '[class*="close-btn"]', + '[class*="modal-close"]', + 'button[title*="close" i]', + 'button[title*="Close" i]' + ]; + + let closeBtn = null; + for (const selector of closeSelectors) { + closeBtn = dialog.querySelector(selector); + if (closeBtn) break; + } + + // Also look for buttons with close text + if (!closeBtn) { + const buttons = dialog.querySelectorAll('button'); + for (const btn of buttons) { + const text = btn.textContent || btn.innerText || ''; + if (text.match(/^(×|X|Close|Cancel)$/i)) { + closeBtn = btn; + break; + } + } + } + + if (closeBtn) { + closeBtn.click(); + } else { + // If no close button, try to remove the dialog + dialog.style.display = 'none'; + // Also try to remove parent overlay if exists + const parent = dialog.parentElement; + if (parent && (parent.className.includes('overlay') || + parent.className.includes('modal') || + parent.className.includes('backdrop'))) { + parent.style.display = 'none'; + } + } + }); + + // Find and hide any overlay/backdrop elements + const overlays = document.querySelectorAll( + '[class*="overlay"], [class*="backdrop"], [class*="modal-backdrop"], ' + + '[class*="modal-overlay"], [class*="dialog-overlay"]' + ); + overlays.forEach(overlay => { + overlay.style.display = 'none'; + }); + + // Remove any body classes that might indicate modal state + document.body.classList.remove('modal-open', 'has-modal', 'overflow-hidden'); + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + } + ''') + + # Strategy 2: Press Escape multiple times + for _ in range(3): + await page.keyboard.press('Escape') + await page.wait_for_timeout(100) + + # Strategy 3: Try to click on body/main content to dismiss any click-away modals + await page.evaluate(''' + () => { + // Click on body + document.body.click(); + + // Try to find main content area and click it + const main = document.querySelector('main, [role="main"], #main, .main-content'); + if (main) { + main.click(); + } + } + ''') + + # Strategy 4: Try common keyboard shortcuts + await page.keyboard.press('Escape') + await page.wait_for_timeout(100) + + # Strategy 5: Force close any specific Claude.ai modals + await page.evaluate(''' + () => { + // Look for Claude-specific modal patterns + const modals = document.querySelectorAll( + 'div[data-radix-portal], ' + // Radix UI portals + 'div[data-reach-dialog-overlay], ' + // Reach UI dialogs + 'div[class*="DialogOverlay"], ' + + 'div[class*="Modal"], ' + + 'div[class*="Popup"]' + ); + modals.forEach(modal => { + modal.remove(); + }); + } + ''') + + # Wait a bit for any animations to complete + await page.wait_for_timeout(500) + + logger.info("Completed force close of all modals") + + except Exception as e: + logger.error(f"Error during force close modals: {e}") + # Even if there's an error, try one last escape + try: + await page.keyboard.press('Escape') + except: + pass + + async def _handle_cookie_notices(self) -> None: + """Detect and handle GDPR/cookie consent notices.""" + page = await self.get_or_create_page() + + try: + # Common cookie consent button selectors + cookie_selectors = [ + # Generic accept buttons + 'button:has-text("Accept all")', + 'button:has-text("Accept All")', + 'button:has-text("Accept")', + 'button:has-text("I agree")', + 'button:has-text("I Agree")', + 'button:has-text("Agree")', + 'button:has-text("OK")', + 'button:has-text("Got it")', + 'button:has-text("Continue")', + # Cookie-specific + 'button[id*="cookie-accept"]', + 'button[class*="cookie-accept"]', + 'button[id*="accept-cookies"]', + 'button[class*="accept-cookies"]', + # GDPR-specific + 'button[id*="gdpr-accept"]', + 'button[class*="gdpr-accept"]', + # OneTrust common IDs + '#onetrust-accept-btn-handler', + 'button#onetrust-accept-btn-handler', + # Other common frameworks + '.cookie-consent-accept', + '.gdpr-accept', + '[data-testid*="cookie-accept"]', + '[data-testid*="accept-cookies"]', + ] + + # Wait a short time for any cookie notice to appear + await page.wait_for_timeout(1000) + + # Try each selector + for selector in cookie_selectors: + try: + button = await page.query_selector(selector) + if button and await button.is_visible(): + logger.info(f"Found cookie consent button with selector: {selector}") + await button.click() + await page.wait_for_timeout(500) + return + except Exception: + continue + + # Check for cookie banner by common class/id patterns + banner_selectors = [ + '[class*="cookie-banner"]', + '[class*="cookie-consent"]', + '[class*="gdpr"]', + '[id*="cookie-banner"]', + '[id*="cookie-consent"]', + '[role="dialog"][aria-label*="cookie" i]', + '[role="dialog"][aria-label*="consent" i]', + ] + + for selector in banner_selectors: + try: + banner = await page.query_selector(selector) + if banner and await banner.is_visible(): + logger.info(f"Cookie banner detected with selector: {selector}") + # Banner exists but we couldn't find accept button + logger.warning("Cookie banner found but no accept button located") + break + except Exception: + continue + + except Exception as e: + logger.debug(f"Error handling cookie notices: {e}") + + @asynccontextmanager + async def open_file_modal(self, file_name: str): + """Context manager for opening a file modal with automatic cleanup. + + Args: + file_name: Name of file to open + + Yields: + Page object with modal open + + Example: + async with connection.open_file_modal("myfile.txt") as page: + content = await extract_content(page) + """ + page = await self.get_or_create_page() + modal_opened = False + + # Ensure clean state before opening new modal + await self.ensure_clean_state() + + try: + # Find and click the file thumbnail + thumbnails = await page.query_selector_all('div[data-testid="file-thumbnail"]') + + for thumb in thumbnails: + h3 = await thumb.query_selector('h3') + if h3: + name = await h3.text_content() + if name and name.strip() == file_name: + button = await thumb.query_selector('button') + if button: + logger.info(f"Opening modal for file: {file_name}") + await button.click() + modal_opened = True + + # Wait for modal to appear + await page.wait_for_timeout(1500) + + # Yield control to caller + yield page + break + else: + raise Exception(f"File '{file_name}' not found on page") + + finally: + # Always try to close modal on exit + if modal_opened: + logger.debug(f"Closing modal for file: {file_name}") + await self._close_modal(page) + # Extra wait to ensure modal is fully closed + await page.wait_for_timeout(500) + + # Double-check modal is closed by looking for dialog elements + dialog_count = await page.locator('[role="dialog"], [aria-modal="true"]').count() + if dialog_count > 0: + logger.warning(f"Modal still open after close attempt, using force close") + await self.force_close_all_modals() + + async def is_modal_open(self) -> bool: + """Check if any modal/dialog is currently open.""" + page = await self.get_or_create_page() + try: + dialog_count = await page.locator('[role="dialog"], [aria-modal="true"]').count() + return dialog_count > 0 + except: + return False + + async def ensure_clean_state(self) -> None: + """Ensure we're in a clean state with no modals open.""" + if await self.is_modal_open(): + logger.info("Modal detected, cleaning up state") + await self.force_close_all_modals() + async def close(self) -> None: """Close current page.""" if self._current_page and not self._current_page.is_closed(): diff --git a/claude_sync/sync/orchestrator.py b/claude_sync/sync/orchestrator.py index 5335649..fcc7a07 100644 --- a/claude_sync/sync/orchestrator.py +++ b/claude_sync/sync/orchestrator.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import List, Optional, Dict, Any, Callable from datetime import datetime +import json from claude_sync.browser import BrowserConfig, ChromeManager, ChromeConnection from claude_sync.models import Project, KnowledgeFile @@ -12,6 +13,38 @@ logger = logging.getLogger(__name__) +class StrictModeError(Exception): + """Raised when sync fails in strict mode.""" + + def __init__(self, message: str, error_detail: Dict[str, Any], progress: 'SyncProgress'): + super().__init__(message) + self.error_detail = error_detail + self.progress = progress + self.report = self._generate_report() + + def _generate_report(self) -> Dict[str, Any]: + """Generate detailed failure report.""" + return { + "error": self.error_detail, + "progress_at_failure": { + "completed_projects": self.progress.completed_projects, + "total_projects": self.progress.total_projects, + "completed_files": self.progress.completed_files, + "total_files": self.progress.total_files, + "current_project": self.progress.current_project, + "current_file": self.progress.current_file + }, + "all_errors": self.progress.errors, + "timestamp": datetime.now().isoformat() + } + + def save_report(self, path: Path) -> None: + """Save failure report to file.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w') as f: + json.dump(self.report, f, indent=2) + + class SyncProgress: """Tracks sync progress.""" @@ -128,6 +161,19 @@ async def sync_all(self, filter_projects: Optional[List[str]] = None) -> Dict[st logger.info(f"Sync completed in {duration:.1f}s") return summary + except StrictModeError as e: + # Save failure report + report_path = self.storage.base_path / ".metadata" / "strict_mode_failure.json" + e.save_report(report_path) + logger.error(f"Strict mode failure. Report saved to: {report_path}") + + return { + "success": False, + "error": str(e), + "strict_mode_report": e.report, + "report_path": str(report_path), + "progress": self.progress.to_dict() + } except Exception as e: logger.error(f"Sync failed: {e}") return { @@ -181,13 +227,21 @@ async def _sync_project( self._update_progress() # Download each file - for file in files: + for i, file in enumerate(files): await self._sync_knowledge_file(connection, project, file) + + # Every 5 files, do a force close to ensure clean state + if (i + 1) % 5 == 0 and i < len(files) - 1: + logger.debug(f"Periodic modal cleanup after {i + 1} files") + await connection.force_close_all_modals() # Mark project complete self.progress.completed_projects += 1 self._update_progress() + # Force close any lingering modals before moving to next project + await connection.force_close_all_modals() + except Exception as e: logger.error(f"Failed to sync project {project.name}: {e}") self.progress.errors.append({ @@ -242,12 +296,22 @@ async def _sync_knowledge_file( except Exception as e: logger.error(f"Failed to sync file {file.name}: {e}") - self.progress.errors.append({ + error_detail = { "type": "file_sync", "project": project.name, "file": file.name, - "error": str(e) - }) + "error": str(e), + "timestamp": datetime.now().isoformat() + } + self.progress.errors.append(error_detail) + + # In strict mode, stop immediately and generate report + if self.browser_config.strict_mode: + raise StrictModeError( + f"Strict mode: Failed to sync file {file.name} in project {project.name}", + error_detail, + self.progress + ) async def _alternative_download( self, diff --git a/sync_cli.py b/sync_cli.py index de81134..a94aefe 100755 --- a/sync_cli.py +++ b/sync_cli.py @@ -35,7 +35,7 @@ async def sync_all(args): storage_path = Path(args.storage or "claude_sync_data") logger.info(f"Storage path: {storage_path}") - config = BrowserConfig(headless=args.headless) + config = BrowserConfig(headless=args.headless, strict_mode=args.strict) orchestrator = SyncOrchestrator( storage_path, browser_config=config, @@ -58,6 +58,17 @@ async def sync_all(args): print(f" - {error['type']}: {error.get('file', error.get('project'))}") else: print(f"\n✗ Sync failed: {result.get('error', 'Unknown error')}") + + # Show strict mode report if available + if "strict_mode_report" in result: + print(f"\n📋 Strict Mode Failure Report:") + report = result["strict_mode_report"] + print(f" Failed file: {report['error']['file']}") + print(f" Project: {report['error']['project']}") + print(f" Error: {report['error']['error']}") + print(f" Progress: {report['progress_at_failure']['completed_files']}/{report['progress_at_failure']['total_files']} files") + print(f" Report saved to: {result['report_path']}") + sys.exit(1) @@ -66,7 +77,7 @@ async def sync_project(args): storage_path = Path(args.storage or "claude_sync_data") logger.info(f"Storage path: {storage_path}") - config = BrowserConfig(headless=args.headless) + config = BrowserConfig(headless=args.headless, strict_mode=args.strict) orchestrator = SyncOrchestrator( storage_path, browser_config=config, @@ -117,36 +128,53 @@ async def list_projects(args): def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser(description="Sync Claude.ai data locally") - parser.add_argument( + + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # Common arguments for all commands + common_parser = argparse.ArgumentParser(add_help=False) + common_parser.add_argument( "--storage", help="Storage directory (default: claude_sync_data)", default="claude_sync_data" ) - parser.add_argument( + common_parser.add_argument( "--headless", action="store_true", help="Run browser in headless mode" ) - parser.add_argument( + common_parser.add_argument( "--quiet", action="store_true", help="Suppress progress output" ) - - subparsers = parser.add_subparsers(dest="command", help="Commands") + common_parser.add_argument( + "--strict", + action="store_true", + help="Stop on first error and produce detailed failure report" + ) # Sync all command - sync_all_parser = subparsers.add_parser("sync", help="Sync all projects") + sync_all_parser = subparsers.add_parser( + "sync", + help="Sync all projects", + parents=[common_parser] + ) # Sync project command sync_project_parser = subparsers.add_parser( "sync-project", - help="Sync a specific project" + help="Sync a specific project", + parents=[common_parser] ) sync_project_parser.add_argument("project", help="Project name to sync") # List command - list_parser = subparsers.add_parser("list", help="List synced projects") + list_parser = subparsers.add_parser( + "list", + help="List synced projects", + parents=[common_parser] + ) args = parser.parse_args() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..8d94edf --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,268 @@ +"""Tests for CLI functionality.""" +import pytest +import asyncio +from pathlib import Path +from unittest.mock import patch, MagicMock, AsyncMock +import sys +import argparse + +from sync_cli import main, sync_all, sync_project, list_projects + + +class TestCLI: + """Test CLI argument parsing and command execution.""" + + def test_sync_command_with_custom_storage(self, tmp_path, monkeypatch): + """Test sync command with custom storage directory.""" + custom_storage = tmp_path / "custom_storage" + + # Mock sys.argv + test_args = ['sync_cli.py', 'sync', '--storage', str(custom_storage)] + monkeypatch.setattr(sys, 'argv', test_args) + + # Mock asyncio.run to capture the args + captured_args = None + async def mock_sync_all(args): + nonlocal captured_args + captured_args = args + return {"success": True, "projects_synced": 0, "files_synced": 0, "duration_seconds": 0, "errors": []} + + with patch('sync_cli.sync_all', mock_sync_all): + with patch('asyncio.run', lambda coro: asyncio.get_event_loop().run_until_complete(coro)): + main() + + assert captured_args is not None + assert captured_args.storage == str(custom_storage) + assert captured_args.command == 'sync' + + def test_sync_project_command_with_custom_storage(self, tmp_path, monkeypatch): + """Test sync-project command with custom storage directory.""" + custom_storage = tmp_path / "project_storage" + + # Mock sys.argv + test_args = ['sync_cli.py', 'sync-project', 'TestProject', '--storage', str(custom_storage)] + monkeypatch.setattr(sys, 'argv', test_args) + + # Mock asyncio.run to capture the args + captured_args = None + async def mock_sync_project(args): + nonlocal captured_args + captured_args = args + return {"success": True, "files_synced": 0, "duration_seconds": 0} + + with patch('sync_cli.sync_project', mock_sync_project): + with patch('asyncio.run', lambda coro: asyncio.get_event_loop().run_until_complete(coro)): + main() + + assert captured_args is not None + assert captured_args.storage == str(custom_storage) + assert captured_args.project == 'TestProject' + assert captured_args.command == 'sync-project' + + def test_list_command_with_custom_storage(self, tmp_path, monkeypatch): + """Test list command with custom storage directory.""" + custom_storage = tmp_path / "list_storage" + + # Mock sys.argv + test_args = ['sync_cli.py', 'list', '--storage', str(custom_storage)] + monkeypatch.setattr(sys, 'argv', test_args) + + # Mock the list function + captured_args = None + async def mock_list_projects(args): + nonlocal captured_args + captured_args = args + + with patch('sync_cli.list_projects', mock_list_projects): + with patch('asyncio.run', lambda coro: asyncio.get_event_loop().run_until_complete(coro)): + main() + + assert captured_args is not None + assert captured_args.storage == str(custom_storage) + assert captured_args.command == 'list' + + def test_headless_and_quiet_options(self, monkeypatch): + """Test --headless and --quiet options.""" + # Mock sys.argv + test_args = ['sync_cli.py', 'sync', '--headless', '--quiet'] + monkeypatch.setattr(sys, 'argv', test_args) + + # Mock asyncio.run to capture the args + captured_args = None + async def mock_sync_all(args): + nonlocal captured_args + captured_args = args + return {"success": True, "projects_synced": 0, "files_synced": 0, "duration_seconds": 0, "errors": []} + + with patch('sync_cli.sync_all', mock_sync_all): + with patch('asyncio.run', lambda coro: asyncio.get_event_loop().run_until_complete(coro)): + main() + + assert captured_args is not None + assert captured_args.headless is True + assert captured_args.quiet is True + + def test_no_command_shows_help(self, monkeypatch, capsys): + """Test that no command shows help.""" + # Mock sys.argv + test_args = ['sync_cli.py'] + monkeypatch.setattr(sys, 'argv', test_args) + + with pytest.raises(SystemExit): + main() + + captured = capsys.readouterr() + assert "usage:" in captured.out + assert "Commands" in captured.out + + @pytest.mark.asyncio + async def test_sync_all_function(self, tmp_path): + """Test sync_all function with custom storage.""" + custom_storage = tmp_path / "sync_test" + + # Create mock args + args = argparse.Namespace( + storage=str(custom_storage), + headless=False, + quiet=False, + strict=False + ) + + with patch('sync_cli.SyncOrchestrator') as mock_orchestrator_class: + mock_orchestrator = MagicMock() + mock_orchestrator_class.return_value = mock_orchestrator + mock_orchestrator.sync_all = AsyncMock(return_value={ + "success": True, + "projects_synced": 2, + "files_synced": 5, + "duration_seconds": 10.5, + "errors": [] + }) + + await sync_all(args) + + # Verify orchestrator was created with correct path + mock_orchestrator_class.assert_called_once() + call_args = mock_orchestrator_class.call_args[0] + assert call_args[0] == Path(custom_storage) + + @pytest.mark.asyncio + async def test_sync_project_function(self, tmp_path): + """Test sync_project function with custom storage.""" + custom_storage = tmp_path / "project_test" + + # Create mock args + args = argparse.Namespace( + storage=str(custom_storage), + project="TestProject", + headless=True, + quiet=True, + strict=False + ) + + with patch('sync_cli.SyncOrchestrator') as mock_orchestrator_class: + mock_orchestrator = MagicMock() + mock_orchestrator_class.return_value = mock_orchestrator + mock_orchestrator.sync_project = AsyncMock(return_value={ + "success": True, + "files_synced": 3, + "duration_seconds": 5.2 + }) + + await sync_project(args) + + # Verify orchestrator was created with correct path + mock_orchestrator_class.assert_called_once() + call_args = mock_orchestrator_class.call_args[0] + assert call_args[0] == Path(custom_storage) + + # Verify sync_project was called with correct project name + mock_orchestrator.sync_project.assert_called_once_with("TestProject") + + def test_tilde_expansion(self, monkeypatch): + """Test that ~ is properly expanded in storage path.""" + # Mock sys.argv with tilde path + test_args = ['sync_cli.py', 'sync', '--storage', '~/test_storage'] + monkeypatch.setattr(sys, 'argv', test_args) + + # Mock asyncio.run to capture the args + captured_args = None + async def mock_sync_all(args): + nonlocal captured_args + captured_args = args + return {"success": True, "projects_synced": 0, "files_synced": 0, "duration_seconds": 0, "errors": []} + + with patch('sync_cli.sync_all', mock_sync_all): + with patch('asyncio.run', lambda coro: asyncio.get_event_loop().run_until_complete(coro)): + main() + + assert captured_args is not None + # The tilde should still be in the raw argument + assert captured_args.storage == '~/test_storage' + # But Path() in the sync functions will expand it + + def test_strict_mode_option(self, monkeypatch): + """Test --strict option is passed correctly.""" + # Mock sys.argv + test_args = ['sync_cli.py', 'sync', '--strict'] + monkeypatch.setattr(sys, 'argv', test_args) + + # Mock asyncio.run to capture the args + captured_args = None + async def mock_sync_all(args): + nonlocal captured_args + captured_args = args + return {"success": True, "projects_synced": 0, "files_synced": 0, "duration_seconds": 0, "errors": []} + + with patch('sync_cli.sync_all', mock_sync_all): + with patch('asyncio.run', lambda coro: asyncio.get_event_loop().run_until_complete(coro)): + main() + + assert captured_args is not None + assert captured_args.strict is True + assert captured_args.command == 'sync' + + @pytest.mark.asyncio + async def test_sync_all_with_strict_mode_failure(self, tmp_path, capsys): + """Test sync_all handling of strict mode failure.""" + custom_storage = tmp_path / "strict_test" + + # Create mock args + args = argparse.Namespace( + storage=str(custom_storage), + headless=False, + quiet=False, + strict=True + ) + + # Mock orchestrator to return strict mode failure + with patch('sync_cli.SyncOrchestrator') as mock_orchestrator_class: + mock_orchestrator = MagicMock() + mock_orchestrator_class.return_value = mock_orchestrator + mock_orchestrator.sync_all = AsyncMock(return_value={ + "success": False, + "error": "Strict mode: Failed to sync file test.txt in project TestProject", + "strict_mode_report": { + "error": { + "file": "test.txt", + "project": "TestProject", + "error": "Failed to download file content" + }, + "progress_at_failure": { + "completed_files": 3, + "total_files": 10 + } + }, + "report_path": str(custom_storage / ".metadata" / "strict_mode_failure.json") + }) + + with pytest.raises(SystemExit) as exc_info: + await sync_all(args) + + assert exc_info.value.code == 1 + + # Check output includes strict mode report + captured = capsys.readouterr() + assert "Strict Mode Failure Report" in captured.out + assert "test.txt" in captured.out + assert "TestProject" in captured.out \ No newline at end of file diff --git a/tests/unit/test_connection_modal_handling.py b/tests/unit/test_connection_modal_handling.py new file mode 100644 index 0000000..65fcb90 --- /dev/null +++ b/tests/unit/test_connection_modal_handling.py @@ -0,0 +1,309 @@ +"""Tests for modal handling in ChromeConnection.""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from playwright.async_api import Page, Locator + +from claude_sync.browser.connection import ChromeConnection + + +class TestModalHandling: + """Test modal handling functionality.""" + + @pytest.mark.asyncio + async def test_close_modal_with_button(self): + """Test closing modal using close button.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + # Mock close button + mock_close_btn = AsyncMock() + mock_close_btn.is_visible = AsyncMock(return_value=True) + mock_close_btn.click = AsyncMock() + + # Setup query_selector to return close button + mock_page.query_selector = AsyncMock(return_value=mock_close_btn) + mock_page.wait_for_timeout = AsyncMock() + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + await connection._close_modal(mock_page) + + # Verify close button was clicked + mock_close_btn.click.assert_called_once() + + @pytest.mark.asyncio + async def test_close_modal_with_escape(self): + """Test closing modal using Escape key when no button found.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + # Mock no close button found + mock_page.query_selector = AsyncMock(return_value=None) + mock_page.keyboard = AsyncMock() + mock_page.keyboard.press = AsyncMock() + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + await connection._close_modal(mock_page) + + # Verify Escape was pressed + mock_page.keyboard.press.assert_called_with('Escape') + + @pytest.mark.asyncio + async def test_force_close_all_modals(self): + """Test force closing all modals.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + # Mock page methods + mock_page.evaluate = AsyncMock() + mock_page.keyboard = AsyncMock() + mock_page.keyboard.press = AsyncMock() + mock_page.wait_for_timeout = AsyncMock() + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + await connection.force_close_all_modals() + + # Verify JavaScript was executed + assert mock_page.evaluate.call_count >= 3 # Multiple evaluate calls + + # Verify Escape was pressed multiple times + assert mock_page.keyboard.press.call_count >= 3 + + # Check that the JavaScript code includes editable field handling + first_eval_call = mock_page.evaluate.call_args_list[0][0][0] + assert 'activeElement' in first_eval_call + assert 'blur()' in first_eval_call + + @pytest.mark.asyncio + async def test_is_modal_open(self): + """Test checking if modal is open.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + # Mock locator + mock_locator = AsyncMock(spec=Locator) + mock_locator.count = AsyncMock(return_value=1) + mock_page.locator = MagicMock(return_value=mock_locator) + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + result = await connection.is_modal_open() + + assert result is True + mock_page.locator.assert_called_with('[role="dialog"], [aria-modal="true"]') + + @pytest.mark.asyncio + async def test_is_modal_open_no_modal(self): + """Test checking when no modal is open.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + # Mock locator with no results + mock_locator = AsyncMock(spec=Locator) + mock_locator.count = AsyncMock(return_value=0) + mock_page.locator = MagicMock(return_value=mock_locator) + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + result = await connection.is_modal_open() + + assert result is False + + @pytest.mark.asyncio + async def test_ensure_clean_state_with_modal(self): + """Test ensure_clean_state when modal is open.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + # Mock modal is open + with patch.object(connection, 'is_modal_open', return_value=True): + with patch.object(connection, 'force_close_all_modals') as mock_force_close: + await connection.ensure_clean_state() + + # Verify force close was called + mock_force_close.assert_called_once() + + @pytest.mark.asyncio + async def test_ensure_clean_state_no_modal(self): + """Test ensure_clean_state when no modal is open.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + # Mock no modal is open + with patch.object(connection, 'is_modal_open', return_value=False): + with patch.object(connection, 'force_close_all_modals') as mock_force_close: + await connection.ensure_clean_state() + + # Verify force close was NOT called + mock_force_close.assert_not_called() + + @pytest.mark.asyncio + async def test_open_file_modal_context_manager(self): + """Test open_file_modal context manager.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + # Mock thumbnail and button + mock_thumb = AsyncMock() + mock_h3 = AsyncMock() + mock_h3.text_content = AsyncMock(return_value="test_file.txt") + mock_thumb.query_selector = AsyncMock(side_effect=lambda sel: mock_h3 if sel == 'h3' else AsyncMock()) + + mock_button = AsyncMock() + mock_button.click = AsyncMock() + mock_thumb.query_selector = AsyncMock(side_effect=lambda sel: mock_h3 if sel == 'h3' else mock_button) + + # Mock query_selector_all to return our thumbnail + mock_page.query_selector_all = AsyncMock(return_value=[mock_thumb]) + mock_page.wait_for_timeout = AsyncMock() + mock_page.locator = MagicMock() + mock_locator = AsyncMock() + mock_locator.count = AsyncMock(return_value=0) # No modal after close + mock_page.locator.return_value = mock_locator + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + # Mock ensure_clean_state and _close_modal + with patch.object(connection, 'ensure_clean_state') as mock_ensure_clean: + with patch.object(connection, '_close_modal') as mock_close_modal: + async with connection.open_file_modal("test_file.txt") as page: + assert page == mock_page + # Verify button was clicked + mock_button.click.assert_called_once() + + # Verify cleanup was called + mock_ensure_clean.assert_called_once() + mock_close_modal.assert_called_once() + + @pytest.mark.asyncio + async def test_open_file_modal_with_stuck_modal(self): + """Test open_file_modal when modal doesn't close properly.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + # Mock thumbnail and button + mock_thumb = AsyncMock() + mock_h3 = AsyncMock() + mock_h3.text_content = AsyncMock(return_value="test_file.txt") + mock_button = AsyncMock() + mock_button.click = AsyncMock() + mock_thumb.query_selector = AsyncMock(side_effect=lambda sel: mock_h3 if sel == 'h3' else mock_button) + + # Mock query_selector_all to return our thumbnail + mock_page.query_selector_all = AsyncMock(return_value=[mock_thumb]) + mock_page.wait_for_timeout = AsyncMock() + + # Mock locator to indicate modal is still open after close attempt + mock_locator = AsyncMock() + mock_locator.count = AsyncMock(return_value=1) # Modal still open + mock_page.locator = MagicMock(return_value=mock_locator) + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + # Mock methods + with patch.object(connection, 'ensure_clean_state') as mock_ensure_clean: + with patch.object(connection, '_close_modal') as mock_close_modal: + with patch.object(connection, 'force_close_all_modals') as mock_force_close: + async with connection.open_file_modal("test_file.txt") as page: + pass + + # Verify force close was called due to stuck modal + mock_force_close.assert_called_once() + + @pytest.mark.asyncio + async def test_download_file_content_with_error_triggers_cleanup(self): + """Test that download errors trigger modal cleanup.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + # Mock open_file_modal to raise an exception + with patch.object(connection, 'open_file_modal') as mock_open_modal: + mock_open_modal.side_effect = Exception("Download failed") + + with patch.object(connection, 'force_close_all_modals') as mock_force_close: + result = await connection.download_file_content("test_file.txt") + + assert result is None + # Verify force close was called due to error + mock_force_close.assert_called_once() + + @pytest.mark.asyncio + async def test_javascript_syntax_is_valid(self): + """Test that JavaScript code in force_close_all_modals is syntactically valid.""" + # Create mock page and context + mock_page = AsyncMock(spec=Page) + mock_context = AsyncMock() + mock_context.pages = [mock_page] + + # Capture the JavaScript code that's executed + js_code_executed = [] + + async def capture_js(code): + js_code_executed.append(code) + # Simulate successful execution + return None + + mock_page.evaluate = capture_js + mock_page.keyboard = AsyncMock() + mock_page.keyboard.press = AsyncMock() + mock_page.wait_for_timeout = AsyncMock() + + connection = ChromeConnection(mock_context) + connection._current_page = mock_page + + await connection.force_close_all_modals() + + # Verify JavaScript was captured + assert len(js_code_executed) > 0 + + # Check that the JavaScript doesn't contain Playwright-specific selectors + main_js = js_code_executed[0] + assert ':has-text(' not in main_js + assert 'button:has-text(' not in main_js + + # Verify it contains the fixed selectors + assert 'button[aria-label*="close" i]' in main_js + assert 'activeElement' in main_js # Editable field handling + assert 'blur()' in main_js + + # Verify text matching is done correctly + assert 'textContent' in main_js or 'innerText' in main_js + assert 'match(' in main_js # Regex matching for button text \ No newline at end of file