import puppeteer, { Browser, Page } from 'puppeteer'; import { ScreenshotOptions, SelectorScreenshotOptions, ScreenshotResult, TiledScreenshotResult, ScreencastOptions, ScreencastResult, ConsoleCaptureOptions, ConsoleCaptureResult, ConsoleMessage, } from '../types.js'; import { assertSafeCaptureUrl } from './urlSecurity.js'; import { logger } from '../utils/logger.js'; logger.debug('Screenshot module loaded'); let browser: Browser | null = null; let browserLaunchPromise: Promise | null = null; let lastActivityTime: number = Date.now(); let inactivityTimer: NodeJS.Timeout | null = null; // Configuration const BROWSER_IDLE_TIMEOUT_MS = 60000; // Close browser after 1 minute of inactivity const MIN_BROWSER_LIFETIME_MS = 5000; // Keep browser alive for at least 5 seconds function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isBrowserConnected(currentBrowser: Browser | null): boolean { return currentBrowser?.connected ?? false; } // Browser lifecycle management function updateActivityTime() { lastActivityTime = Date.now(); resetInactivityTimer(); } function resetInactivityTimer() { // Clear existing timer if (inactivityTimer) { clearTimeout(inactivityTimer); inactivityTimer = null; } // Don't set a new timer if browser is not running if (!isBrowserConnected(browser)) { return; } // Set new timer inactivityTimer = setTimeout(async () => { const timeSinceLastActivity = Date.now() - lastActivityTime; const browserAge = Date.now() - lastActivityTime; // Only close if browser has been idle long enough and alive long enough if ( timeSinceLastActivity >= BROWSER_IDLE_TIMEOUT_MS && browserAge >= MIN_BROWSER_LIFETIME_MS ) { logger.info( `Browser idle for ${timeSinceLastActivity}ms, closing to save resources...` ); await closeBrowser(); } else { // If not ready to close yet, reset the timer resetInactivityTimer(); } }, BROWSER_IDLE_TIMEOUT_MS); // Allow process to exit if this is the only thing keeping it alive inactivityTimer.unref(); } async function launchBrowser(): Promise { logger.info('Launching new browser instance...'); logger.debug('Puppeteer executable path:', puppeteer.executablePath()); logger.debug('Browser launch options:', { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '...etc'], }); const newBrowser = await puppeteer.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--disable-gpu', '--no-first-run', '--no-zygote', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-features=TranslateUI', '--disable-ipc-flooding-protection', '--disable-default-apps', '--no-default-browser-check', ], ignoreDefaultArgs: ['--enable-automation'], }); logger.info('Browser launched successfully'); logger.debug('Browser process PID:', newBrowser.process()?.pid); // Handle browser disconnection newBrowser.on('disconnected', () => { logger.warn('Browser disconnected event received'); logger.debug('Browser instance:', { isConnected: isBrowserConnected(newBrowser), }); if (browser === newBrowser) { browser = null; browserLaunchPromise = null; } }); // Start health checking when browser is launched startHealthCheck(); logger.debug('Health check started'); // Initialize activity tracking updateActivityTime(); logger.debug('Activity tracking initialized'); return newBrowser; } async function getBrowser(forceRestart: boolean = false): Promise { logger.debug('getBrowser called', { forceRestart, hasBrowser: !!browser }); // Force restart if requested if (forceRestart && browser) { logger.info('Force restarting browser...'); await closeBrowser(); } // Check if we have a connected browser if (browser && isBrowserConnected(browser)) { return browser; } // If a launch is already in progress, wait for it if (browserLaunchPromise) { try { browser = await browserLaunchPromise; if (browser && isBrowserConnected(browser)) { return browser; } } catch (error) { logger.error('Failed to wait for browser launch:', error); browserLaunchPromise = null; } } // Launch a new browser try { logger.debug('Starting browser launch...'); browserLaunchPromise = launchBrowser(); browser = await browserLaunchPromise; browserLaunchPromise = null; logger.info('Browser obtained successfully'); return browser; } catch (error: any) { logger.error('Failed to launch browser:', error.message); logger.debug('Browser launch error details:', { name: error.name, stack: error.stack, code: error.code, }); browserLaunchPromise = null; browser = null; throw error; } } export async function closeBrowser(): Promise { logger.debug('closeBrowser called'); stopHealthCheck(); // Always stop health check when closing browser // Clear inactivity timer if (inactivityTimer) { clearTimeout(inactivityTimer); inactivityTimer = null; } if (browser && isBrowserConnected(browser)) { logger.info('Closing browser...'); try { await browser.close(); logger.info('Browser closed successfully'); } catch (error) { logger.error('Error closing browser:', error); } browser = null; browserLaunchPromise = null; } else { logger.debug('No browser to close or already disconnected'); } } // Periodically check browser health let healthCheckInterval: NodeJS.Timeout | null = null; function startHealthCheck() { if (healthCheckInterval) return; healthCheckInterval = setInterval(async () => { if (browser && !isBrowserConnected(browser)) { logger.warn('Browser health check failed - browser disconnected'); browser = null; browserLaunchPromise = null; } else if (browser && isBrowserConnected(browser)) { try { // Check memory usage const pages = await browser.pages(); logger.debug(`Health check: ${pages.length} pages open`); // Close any extra pages (keep only the initial blank page) if (pages.length > 1) { logger.info( `Closing ${pages.length - 1} unused pages to free memory` ); for (let i = 1; i < pages.length; i++) { await pages[i].close().catch(() => {}); } } } catch (error) { logger.error('Error during health check:', error); } } }, 30000); // Check every 30 seconds // Allow process to exit if this is the only thing keeping it alive healthCheckInterval.unref(); } function stopHealthCheck() { if (healthCheckInterval) { clearInterval(healthCheckInterval); healthCheckInterval = null; } } async function setupPage(browser: Browser): Promise { logger.debug('Creating new page...'); const page = await browser.newPage(); logger.debug('Page created successfully'); // Configure page settings with longer timeout for problematic sites await page.setDefaultNavigationTimeout(60000); await page.setDefaultTimeout(60000); // Disable unnecessary features that might cause issues await page.setJavaScriptEnabled(true); await page.setOfflineMode(false); // Block unnecessary resources that might cause navigation issues await page.setRequestInterception(true); page.on('request', async request => { const resourceType = request.resourceType(); try { await assertSafeCaptureUrl(request.url()); // Allow navigation and essential resources, block potential problematic ones if (['font', 'media'].includes(resourceType)) { await request.abort(); } else { await request.continue(); } } catch (error) { logger.warn( `Blocked unsafe browser request to ${request.url()}: ${getErrorMessage(error)}` ); await request.abort(); } }); // Set up error handlers page.on('error', error => { logger.error('Page crashed:', error.message); logger.debug('Page crash details:', error); }); page.on('pageerror', error => { logger.warn('Page JavaScript error:', getErrorMessage(error)); }); // Handle frame lifecycle events page.on('frameattached', frame => { logger.debug(`Frame attached: ${frame.url()}`); }); page.on('framedetached', frame => { logger.debug(`Frame detached: ${frame.url()}`); }); page.on('framenavigated', frame => { logger.debug(`Frame navigated: ${frame.url()}`); }); return page; } async function navigateWithRetry( page: Page, url: string, options: ScreenshotOptions, browserRestartCallback?: () => Promise ): Promise { await assertSafeCaptureUrl(url); const maxRetries = 3; let lastError; let currentPage = page; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Check if page is still valid before attempting navigation if (currentPage.isClosed()) { logger.warn('Page is closed, attempting to create new page...'); if (browserRestartCallback) { currentPage = await browserRestartCallback(); } else { throw new Error( 'Page is closed and no recovery callback provided' ); } } logger.info( `Navigating to ${url} (attempt ${attempt}/${maxRetries})...` ); // Create a promise that rejects if frame gets detached const frameDetachedPromise = new Promise((_, reject) => { const handler = () => reject(new Error('Frame detached during navigation')); currentPage.once('framedetached', handler); // Clean up handler if navigation succeeds currentPage.once('load', () => currentPage.off('framedetached', handler) ); }); // Race between navigation and frame detachment await Promise.race([ currentPage.goto(url, { waitUntil: options.waitUntil || 'domcontentloaded', timeout: 60000, }), frameDetachedPromise, ]); // Check if page is still valid after navigation if (currentPage.isClosed()) { throw new Error('Page was closed after navigation'); } // Wait a bit to ensure page is stable await currentPage.evaluate( () => new Promise(resolve => setTimeout(resolve, 100)) ); return currentPage; // Success } catch (error: any) { lastError = error; logger.warn(`Navigation attempt ${attempt} failed:`, error.message); // Determine if we need a browser restart const needsBrowserRestart = error.message.includes('Protocol error') || error.message.includes('Target closed') || error.message.includes('Session closed') || error.message.includes('Browser disconnected') || error.message.includes( 'Navigation failed because browser has disconnected' ); if (attempt < maxRetries) { // Wait before retrying await new Promise(resolve => setTimeout(resolve, 1000 * attempt) ); if (needsBrowserRestart && browserRestartCallback) { logger.info( 'Critical error detected, restarting browser...' ); try { currentPage = await browserRestartCallback(); } catch (restartError) { logger.error( 'Failed to restart browser:', restartError ); throw restartError; } } else if (currentPage.isClosed() && browserRestartCallback) { logger.info('Page closed, creating new page...'); currentPage = await browserRestartCallback(); } } } } throw lastError || new Error('Navigation failed after retries'); } export async function captureScreenshot( options: ScreenshotOptions ): Promise { logger.info('captureScreenshot called with options:', { url: options.url, fullPage: options.fullPage, viewport: options.viewport, waitUntil: options.waitUntil, waitFor: options.waitFor, }); // Update activity time when screenshot is requested updateActivityTime(); await assertSafeCaptureUrl(options.url); // Always capture full page with tiling if (options.fullPage !== false) { logger.debug('Delegating to captureTiledScreenshot'); return captureTiledScreenshot(options); } // Viewport-only capture logger.info(`Taking viewport screenshot of ${options.url}`); let browser: Browser | null = null; let page: Page | null = null; let attemptCount = 0; const maxAttempts = 2; while (attemptCount < maxAttempts) { try { attemptCount++; // Get or restart browser browser = await getBrowser(attemptCount > 1); page = await setupPage(browser); // Set viewport const viewport = { width: options.viewport?.width || 1072, height: options.viewport?.height || 1072, }; await page.setViewport(viewport); // Create recovery callback const recoveryCallback = async (): Promise => { logger.info('Recovering from error, creating new page...'); browser = await getBrowser(true); const newPage = await setupPage(browser); await newPage.setViewport(viewport); return newPage; }; // Navigate to the page with recovery page = await navigateWithRetry( page, options.url, options, recoveryCallback ); // Wait additional time if specified if (options.waitFor) { await page.evaluate( ms => new Promise(resolve => setTimeout(resolve, ms)), options.waitFor ); } // Take screenshot const screenshot = Buffer.from( await page.screenshot({ type: 'png', fullPage: false, encoding: 'binary', }) ); const result: ScreenshotResult = { url: options.url, screenshot, timestamp: new Date(), viewport, format: 'png', }; // Clean up the page after successful capture if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error: any) { logger.error( `Error taking screenshot (attempt ${attemptCount}/${maxAttempts}):`, error ); // Clean up the page if (page && !page.isClosed()) { await page.close().catch(() => {}); } // If this was our last attempt, throw the error if (attemptCount >= maxAttempts) { throw error; } // Otherwise, wait a bit before retrying logger.info('Retrying with fresh browser...'); await new Promise(resolve => setTimeout(resolve, 2000)); } } throw new Error('Failed to capture screenshot after all attempts'); } export async function captureSelectorScreenshot( options: SelectorScreenshotOptions ): Promise { logger.info('captureSelectorScreenshot called with options:', { url: options.url, selector: options.selector, viewport: options.viewport, waitUntil: options.waitUntil, waitFor: options.waitFor, selectorTimeoutMS: options.selectorTimeoutMS, }); updateActivityTime(); await assertSafeCaptureUrl(options.url); let browser: Browser | null = null; let page: Page | null = null; let attemptCount = 0; const maxAttempts = 2; while (attemptCount < maxAttempts) { try { attemptCount++; browser = await getBrowser(attemptCount > 1); page = await setupPage(browser); const viewport = { width: options.viewport?.width || 1072, height: options.viewport?.height || 1072, }; await page.setViewport(viewport); const recoveryCallback = async (): Promise => { logger.info('Recovering from error, creating new page...'); browser = await getBrowser(true); const newPage = await setupPage(browser); await newPage.setViewport(viewport); return newPage; }; page = await navigateWithRetry( page, options.url, options, recoveryCallback ); if (options.waitFor) { await page.evaluate( ms => new Promise(resolve => setTimeout(resolve, ms)), options.waitFor ); } let elementHandle; try { elementHandle = await page.waitForSelector(options.selector, { timeout: options.selectorTimeoutMS ?? 5000, }); } catch (error) { throw new Error( `Selector "${options.selector}" was not found on ${options.url}`, { cause: error } ); } if (!elementHandle) { throw new Error( `Selector "${options.selector}" was not found on ${options.url}` ); } const screenshot = Buffer.from( await elementHandle.screenshot({ type: 'png', encoding: 'binary', }) ); const result: ScreenshotResult = { url: options.url, screenshot, timestamp: new Date(), viewport, format: 'png', }; if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error) { logger.error( `Error taking selector screenshot (attempt ${attemptCount}/${maxAttempts}):`, error ); if (page && !page.isClosed()) { await page.close().catch(() => {}); } if (attemptCount >= maxAttempts) { throw error; } logger.info('Retrying selector capture with fresh browser...'); await new Promise(resolve => setTimeout(resolve, 2000)); } } throw new Error('Failed to capture selector screenshot after all attempts'); } // Export browser statistics for monitoring export function getBrowserStats() { return { hasBrowser: !!browser, isConnected: isBrowserConnected(browser), lastActivityTime: new Date(lastActivityTime).toISOString(), timeSinceLastActivity: Date.now() - lastActivityTime, hasInactivityTimer: !!inactivityTimer, idleTimeoutMs: BROWSER_IDLE_TIMEOUT_MS, }; } export async function warmupBrowser(): Promise { logger.info('Warming up browser for faster first requests...'); try { // Pre-launch browser to avoid startup delay on first request const warmupBrowser = await getBrowser(); logger.info('Browser warmed up successfully'); // Optionally create a page and navigate to a simple page to fully warm up const page = await warmupBrowser.newPage(); await page.goto('data:text/html,Warmup', { waitUntil: 'load', timeout: 5000, }); await page.close(); logger.debug('Browser warmup page test completed'); } catch (error) { logger.warn( 'Browser warmup failed (first request may be slower):', error ); // Don't throw - let the server start anyway } } // Clean up on process exit process.on('SIGINT', async () => { logger.debug('SIGINT received in screenshot module'); stopHealthCheck(); await closeBrowser(); process.exit(0); }); process.on('SIGTERM', async () => { logger.debug('SIGTERM received in screenshot module'); stopHealthCheck(); await closeBrowser(); process.exit(0); }); process.on('exit', async () => { logger.debug('Process exit in screenshot module'); stopHealthCheck(); await closeBrowser(); }); // Handle uncaught errors process.on('uncaughtException', async error => { logger.error('Uncaught exception in screenshot module:', error); stopHealthCheck(); await closeBrowser(); process.exit(1); }); process.on('unhandledRejection', async error => { logger.error('Unhandled rejection in screenshot module:', error); stopHealthCheck(); await closeBrowser(); process.exit(1); }); async function captureTiledScreenshot( options: ScreenshotOptions ): Promise { await assertSafeCaptureUrl(options.url); const tileSize = options.viewport?.width || 1072; logger.info(`Taking tiled screenshot of ${options.url}`); let browser: Browser | null = null; let page: Page | null = null; let attemptCount = 0; const maxAttempts = 2; while (attemptCount < maxAttempts) { try { attemptCount++; // Get or restart browser browser = await getBrowser(attemptCount > 1); page = await setupPage(browser); // Set viewport to capture full width in tile size await page.setViewport({ width: tileSize, height: tileSize, }); // Create recovery callback const recoveryCallback = async (): Promise => { logger.info('Recovering from error, creating new page...'); browser = await getBrowser(true); const newPage = await setupPage(browser); await newPage.setViewport({ width: tileSize, height: tileSize, }); return newPage; }; // Navigate to the page with recovery page = await navigateWithRetry( page, options.url, options, recoveryCallback ); // Wait additional time if specified if (options.waitFor) { await page.evaluate( ms => new Promise(resolve => setTimeout(resolve, ms)), options.waitFor ); } // Get the full page dimensions const dimensions = await page.evaluate(() => { const doc = (globalThis as any).document; return { width: Math.max( doc.documentElement.scrollWidth, doc.body.scrollWidth, doc.documentElement.offsetWidth, doc.body.offsetWidth, doc.documentElement.clientWidth ), height: Math.max( doc.documentElement.scrollHeight, doc.body.scrollHeight, doc.documentElement.offsetHeight, doc.body.offsetHeight, doc.documentElement.clientHeight ), }; }); // Width should be constrained by our viewport width for tiling const fullWidth = Math.min(dimensions.width, tileSize); const fullHeight = dimensions.height; logger.info( `Full page dimensions: ${fullWidth}x${fullHeight} (viewport width: ${tileSize})` ); // Calculate number of tiles needed const cols = Math.ceil(fullWidth / tileSize); const rows = Math.ceil(fullHeight / tileSize); const tiles = []; logger.info( `Capturing ${rows}x${cols} tiles (${rows * cols} total) sequentially...` ); // Capture tiles one by one for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const x = col * tileSize; const y = row * tileSize; const width = Math.min(tileSize, fullWidth - x); const height = Math.min(tileSize, fullHeight - y); // Skip empty tiles if they occur due to rounding if (width <= 0 || height <= 0) continue; logger.debug( `Capturing tile ${row},${col} at ${x},${y} (${width}x${height})...` ); // Capture this specific tile directly from the page const tileBuffer = Buffer.from( await page.screenshot({ type: 'png', encoding: 'binary', clip: { x, y, width, height, }, }) ); tiles.push({ screenshot: tileBuffer, index: row * cols + col, row, col, x, y, width, height, }); } } const result: TiledScreenshotResult = { url: options.url, tiles, timestamp: new Date(), fullWidth, fullHeight, tileSize, format: 'png', }; // Clean up the page after successful capture if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error: any) { logger.error( `Error taking tiled screenshot (attempt ${attemptCount}/${maxAttempts}):`, error ); // Clean up the page if (page && !page.isClosed()) { await page.close().catch(() => {}); } // If this was our last attempt, throw the error if (attemptCount >= maxAttempts) { throw error; } // Otherwise, wait a bit before retrying logger.info('Retrying with fresh browser...'); await new Promise(resolve => setTimeout(resolve, 2000)); } } throw new Error('Failed to capture tiled screenshot after all attempts'); } export async function captureScreencast( options: ScreencastOptions ): Promise { logger.info('captureScreencast called with options:', { url: options.url, duration: options.duration, interval: options.interval, viewport: options.viewport, waitUntil: options.waitUntil, waitFor: options.waitFor, hasJsEvaluate: !!options.jsEvaluate, }); // Update activity time when screencast is requested updateActivityTime(); await assertSafeCaptureUrl(options.url); const frames: ScreencastResult['frames'] = []; const startTime = new Date(); let browser: Browser | null = null; let page: Page | null = null; try { // Get browser instance browser = await getBrowser(); page = await setupPage(browser); // Set viewport (only capture top tile - 1072x1072) const viewport = { width: options.viewport?.width || 1072, height: options.viewport?.height || 1072, }; await page.setViewport(viewport); logger.info(`Starting screencast of ${options.url}`); // Navigate to the page await page.goto(options.url, { waitUntil: options.waitUntil || 'domcontentloaded', timeout: 60000, }); // Wait additional time if specified if (options.waitFor) { await page.evaluate( ms => new Promise(resolve => setTimeout(resolve, ms)), options.waitFor ); } // Handle JavaScript execution with configurable screenshot intervals let jsInstructionCount = 0; const screenshotInterval = options.interval * 1000; // Convert seconds to milliseconds const jsExecutionInterval = 1000; // Execute JS instructions every 1 second if (options.jsEvaluate) { const jsInstructions = Array.isArray(options.jsEvaluate) ? options.jsEvaluate : [options.jsEvaluate]; jsInstructionCount = jsInstructions.length; logger.info( `Processing ${jsInstructionCount} JavaScript instruction(s) with ${screenshotInterval}ms screenshot intervals` ); const startTime = Date.now(); let nextJsIndex = 0; let frameIndex = 0; // Run for the duration needed to execute all JS instructions const jsDuration = jsInstructions.length * jsExecutionInterval; while (Date.now() - startTime < jsDuration) { const elapsed = Date.now() - startTime; // Check if it's time to execute the next JS instruction if ( nextJsIndex < jsInstructions.length && elapsed >= nextJsIndex * jsExecutionInterval ) { logger.info( `Executing JavaScript instruction ${nextJsIndex + 1}/${jsInstructions.length}: ${jsInstructions[nextJsIndex].substring(0, 50)}...` ); try { await page.evaluate(jsInstructions[nextJsIndex]); logger.debug( `JavaScript instruction ${nextJsIndex + 1} completed` ); } catch (error) { logger.error( `JavaScript instruction ${nextJsIndex + 1} failed:`, error ); throw new Error( `Failed to execute JavaScript instruction ${nextJsIndex + 1}: ${error}` ); } nextJsIndex++; } // Take screenshot const screenshot = Buffer.from( await page.screenshot({ type: 'png', fullPage: false, encoding: 'binary', }) ); frames.push({ screenshot, timestamp: new Date(), index: frameIndex, }); frameIndex++; logger.debug( `Captured high-frequency frame ${frameIndex} at ${elapsed}ms` ); // Wait for next screenshot interval const nextScreenshotTime = startTime + frameIndex * screenshotInterval; const waitTime = Math.max(0, nextScreenshotTime - Date.now()); if (waitTime > 0) { await new Promise(resolve => setTimeout(resolve, waitTime)); } } // Update jsInstructionCount to reflect actual frames captured during JS execution jsInstructionCount = frameIndex; } // Calculate remaining time and frames needed at configured intervals const remainingDuration = options.duration * 1000 - jsInstructionCount * screenshotInterval; // Remaining time in ms const remainingFrames = Math.max( 0, Math.floor(remainingDuration / screenshotInterval) ); logger.info( `Captured ${jsInstructionCount} frames during JS execution. Capturing ${remainingFrames} additional frames at ${screenshotInterval}ms intervals for remaining ${remainingDuration}ms` ); // Capture remaining frames at configured intervals for (let i = 0; i < remainingFrames; i++) { const frameStart = Date.now(); // Take screenshot of viewport (top tile only) const screenshot = Buffer.from( await page.screenshot({ type: 'png', fullPage: false, encoding: 'binary', }) ); const frameIndex = jsInstructionCount + i; frames.push({ screenshot, timestamp: new Date(), index: frameIndex, }); logger.debug( `Captured duration frame ${frameIndex + 1} (${i + 1}/${remainingFrames})` ); // Wait for next interval (if not the last frame) if (i < remainingFrames - 1) { const elapsed = Date.now() - frameStart; const waitTime = Math.max(0, screenshotInterval - elapsed); if (waitTime > 0) { await new Promise(resolve => setTimeout(resolve, waitTime)); } } } const endTime = new Date(); const result: ScreencastResult = { url: options.url, frames, startTime, endTime, duration: options.duration, interval: options.interval, viewport, format: 'png', }; logger.info(`Screencast completed: ${frames.length} frames captured`); // Clean up the page after successful capture if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error: any) { logger.error('Error capturing screencast:', error); // Clean up the page if (page && !page.isClosed()) { await page.close().catch(() => {}); } throw error; } } export async function captureConsole( options: ConsoleCaptureOptions ): Promise { logger.info('captureConsole called with options:', { url: options.url, jsCommand: options.jsCommand, duration: options.duration, waitUntil: options.waitUntil, }); // Update activity time when console capture is requested updateActivityTime(); await assertSafeCaptureUrl(options.url); const messages: ConsoleMessage[] = []; const startTime = new Date(); const duration = options.duration || 4; // Default 4 seconds let browser: Browser | null = null; let page: Page | null = null; try { // Get browser instance browser = await getBrowser(); page = await setupPage(browser); // Set up console message listener page.on('console', msg => { const type = msg.type() as ConsoleMessage['type']; const text = msg.text(); const timestamp = new Date(); // Try to get the actual arguments const args: any[] = []; msg.args().forEach(arg => { args.push(arg.toString()); }); messages.push({ type, text, timestamp, args: args.length > 0 ? args : undefined, }); logger.debug(`Console ${type}: ${text}`); }); // Set up page error listener page.on('pageerror', error => { messages.push({ type: 'error', text: getErrorMessage(error), timestamp: new Date(), }); logger.debug(`Page error: ${getErrorMessage(error)}`); }); logger.info(`Starting console capture for ${options.url}`); // Navigate to the page await page.goto(options.url, { waitUntil: options.waitUntil || 'domcontentloaded', timeout: 60000, }); // Execute JS command if provided if (options.jsCommand) { logger.info(`Executing JS command: ${options.jsCommand}`); try { await page.evaluate(options.jsCommand); logger.debug('JS command executed successfully'); } catch (error) { logger.error('Failed to execute JS command:', error); messages.push({ type: 'error', text: `Failed to execute JS command: ${error}`, timestamp: new Date(), }); } } // Wait for the specified duration logger.info(`Capturing console for ${duration} seconds...`); await new Promise(resolve => setTimeout(resolve, duration * 1000)); const endTime = new Date(); const result: ConsoleCaptureResult = { url: options.url, messages, startTime, endTime, duration, executedCommand: options.jsCommand, }; logger.info( `Console capture completed: ${messages.length} messages captured` ); // Clean up the page after successful capture if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error: any) { logger.error('Error capturing console:', error); // Clean up the page if (page && !page.isClosed()) { await page.close().catch(() => {}); } throw error; } }