import { chromium } from 'playwright'; import os from 'os'; import path from 'path'; import fs from 'fs'; /** * Launches a headless Chromium instance and injects the session cookie. * #10: Adds browser disconnect handler for crash recovery. * @param {object} session - The user's active session state object */ export async function initBrowser(session) { if (session.browser) { try { await session.browser.close(); } catch (e) { console.warn('[Browser] Close warning:', e.message); } } // Ensure virtual display for headed Chromium (HF start.sh sets DISPLAY=:99) if (!process.env.DISPLAY) { process.env.DISPLAY = ':99'; } const srcExtensionPath = path.join(process.cwd(), 'extension'); const extensionPath = path.join(os.tmpdir(), `playwright-ext-${Date.now()}`); const userDataDir = path.join(os.tmpdir(), `playwright-profile-${Date.now()}`); try { fs.cpSync(srcExtensionPath, extensionPath, { recursive: true }); console.log(`[Browser] Extension copied to temp path: ${extensionPath}`); } catch (cpErr) { console.error(`[Browser] Failed to copy extension to temp directory: ${cpErr.message}`); } console.log(`[Browser] Launching Chromium with Extension loaded from: ${extensionPath}`); console.log(`[Browser] DISPLAY=${process.env.DISPLAY}`); try { const extFiles = fs.readdirSync(extensionPath); console.log(`[Browser Diagnostic] Extension directory files: ${extFiles.join(', ')}`); } catch (readdirErr) { console.error(`[Browser Diagnostic] Failed to read extension directory: ${readdirErr.message}`); } session.context = await chromium.launchPersistentContext(userDataDir, { headless: false, // Required for Chrome Extensions args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-software-rasterizer', '--disable-web-security', `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}` ], userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', viewport: { width: 1440, height: 900 }, locale: 'en-US', timezoneId: 'America/New_York', geolocation: { longitude: -74.006, latitude: 40.7128 }, permissions: ['geolocation'], extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' } }); // Set browser reference to context object for matching close API signature session.browser = session.context; // #10: Browser crash recovery — auto-clean session on disconnect session.context.on('close', () => { console.error('[Browser] ⚠️ Chromium browser context closed!'); session.browser = null; session.context = null; session.page = null; session.isProcessing = false; }); // Inject anti-detection script to hide webdriver flag. // Do NOT stub chrome.runtime.sendMessage — that is not available in the page // world anyway; a fake empty runtime object only confuses diagnostics. await session.context.addInitScript(() => { try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (_) {} }); let cookieValue = process.env.LOVABLE_SESSION_COOKIE; if (!cookieValue || cookieValue === 'your_session_cookie_value_here') { throw new Error('LOVABLE_SESSION_COOKIE is not configured.'); } // Clean the cookie value from any accidental whitespace/newlines from copy-pasting cookieValue = cookieValue.trim().replace(/[\r\n]/g, ''); console.log(`[Browser] Injecting session cookie (length: ${cookieValue.length})`); // Inject cookie to both domain targets to cover host and subdomains safely await session.context.addCookies([ { name: 'lovable-session-id-v2', value: cookieValue, domain: '.lovable.dev', path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }, { name: 'lovable-session-id-v2', value: cookieValue, domain: 'lovable.dev', path: '/', httpOnly: true, secure: true, sameSite: 'Lax' } ]); // Persistent contexts automatically open a default page on start, reuse it const pages = session.context.pages(); session.page = pages.length > 0 ? pages[0] : await session.context.newPage(); session.consoleLogs = session.consoleLogs || []; session.page.on('console', msg => { const text = `[Browser Console] ${msg.type()}: ${msg.text()}`; console.log(text); session.consoleLogs.push(text); if (session.consoleLogs.length > 200) session.consoleLogs.shift(); if (session.io) { session.io.emit('browser-log', { text }); } }); session.page.on('pageerror', err => { const text = `[Browser PageError] ${err.message}`; console.error(text); session.consoleLogs.push(text); if (session.consoleLogs.length > 200) session.consoleLogs.shift(); if (session.io) { session.io.emit('browser-log', { text }); } }); console.log('[Browser] Ready.'); // Check active service workers shortly after boot setTimeout(async () => { try { const sws = session.context.serviceWorkers(); console.log(`[Browser Diagnostic] Active service workers: ${sws.map(s => s.url()).join(', ')}`); } catch (swErr) { console.error('[Browser Diagnostic] Error listing service workers:', swErr.message); } }, 3000); // Pre-seed extension storage to bypass gates and enable custom UI immediately try { const serviceWorker = session.context.serviceWorkers()[0] || await session.context.waitForEvent("serviceworker", { timeout: 15000 }); if (serviceWorker) { console.log('[Browser] Pre-seeding extension storage to activate floating UI...'); await serviceWorker.evaluate(async () => { await new Promise((resolve) => { chrome.storage.local.set({ ql_channel_redirected: true, ql_license_valid: true, ql_license_key: "INTERNAL", ql_sidebar_mode: false, // Suppress the WhatsApp/YouTube community popup so it never // blocks automation interactions in the sidepanel or floating UI. ql_join_popup_seen_v3: true }, resolve); }); }); console.log('[Browser] Extension storage pre-seeded.'); } else { console.warn('[Browser] Service worker not found for pre-seeding.'); } } catch (err) { console.warn('[Browser] Warning pre-seeding extension storage:', err.message); } } /** * Closes leaked tabs, keeping only session.page alive. */ async function closeLeakedTabs(session) { if (!session.context) return; try { const pages = session.context.pages(); for (const p of pages) { if (p !== session.page) await p.close(); } } catch (err) { console.warn('[Browser] Tab cleanup warning:', err.message); } } /** * #6: Smart wait — waits for actual project links instead of a blind 4s timeout. * Navigates to the dashboard and scrapes project list. */ export async function scrapeProjects(session) { if (session.isNavigating) { console.log('[Browser] Navigation/Scrape already in progress. Rejecting concurrent call.'); return session.projects || []; } session.isNavigating = true; try { if (!session.page) await initBrowser(session); await closeLeakedTabs(session); console.log('[Browser] Navigating to projects list...'); await session.page.goto('https://lovable.dev/dashboard/projects', { waitUntil: 'load', timeout: 45000 }); const currentUrl = session.page.url(); console.log(`[Browser] URL: ${currentUrl}`); if (currentUrl.includes('/sign-in') || currentUrl.includes('/login')) { throw new Error('Session cookie expired. Please update LOVABLE_SESSION_COOKIE.'); } // #6: Smart wait — wait for actual project links to render, fallback to 4s try { await session.page.waitForSelector('a[href*="/projects/"]', { timeout: 12000 }); } catch { await session.page.waitForTimeout(4000); } const rawProjects = await session.page.evaluate(() => { return Array.from(document.querySelectorAll('a')) .map(a => ({ name: (a.innerText || '').replace(/\s+/g, ' ').trim(), href: (a.getAttribute('href') || '').trim() })) .filter(item => item.href.includes('/projects/') || item.href.includes('/workspace/')); }); console.log(`[Browser] Scraped ${rawProjects.length} raw links.`); const uniqueProjects = []; const seenUrls = new Set(); for (const item of rawProjects) { let url = item.href; if (!url.startsWith('http')) { url = 'https://lovable.dev' + (url.startsWith('/') ? '' : '/') + url; } const skip = url.endsWith('/projects') || url.endsWith('/projects/') || url.endsWith('/new') || url.includes('/new-project') || url.includes('/create') || url.includes('/settings') || url.includes('/members') || item.name.length < 2 || /create project|new project/i.test(item.name); if (!skip && !seenUrls.has(url)) { seenUrls.add(url); uniqueProjects.push({ name: item.name, url }); } } if (uniqueProjects.length === 0) { const diagnostics = await session.page.evaluate(() => { return { title: document.title, bodyLen: (document.body?.innerText || '').length, htmlSnippet: (document.body?.innerHTML || '').slice(0, 500), anchorsCount: document.querySelectorAll('a').length }; }); console.warn('[Browser] ⚠️ 0 projects matched filters. Diagnostics:', diagnostics); } console.log(`[Browser] Scraped and filtered ${uniqueProjects.length} unique projects.`); session.projects = uniqueProjects; return uniqueProjects; } finally { session.isNavigating = false; } } /** * #7: Smart wait — waits for editor element instead of blind 5s timeout. * Opens a specific project workspace. */ export async function openProjectWorkspace(session, projectUrl) { if (session.isNavigating) { console.log('[Browser] Navigation/Workspace load already in progress. Rejecting concurrent call.'); return; } session.isNavigating = true; try { if (!session.page) await initBrowser(session); await closeLeakedTabs(session); console.log(`[Browser] Opening workspace: ${projectUrl}`); await session.page.goto(projectUrl, { waitUntil: 'load', timeout: 60000 }); // #7: Wait for actual editor to appear instead of blind 5s wait try { await session.page.waitForSelector('div[contenteditable="true"], textarea', { timeout: 12000 }); console.log('[Browser] Editor element detected.'); } catch { console.log('[Browser] Editor not found within 12s, continuing anyway...'); await session.page.waitForTimeout(2000); } } finally { session.isNavigating = false; } } /** * Submit a prompt into Lovable's chat UI via the extension's floating input box. * The sidepanel approach is skipped — security-hardening.js breaks el.closest() * which causes the sidepanel page to crash before sp-msg renders. * The floating UI injected by content.js on lovable.dev is reliable and fast. */ export async function submitPrompt(session, promptText) { const { page } = session; if (!page) throw new Error('No active page. Select a project first.'); const text = String(promptText || '').trim(); if (!text) throw new Error('Prompt text is empty.'); console.log('[Browser] Resolving Extension ID from service worker...'); let extensionId = ''; try { const serviceWorker = session.context.serviceWorkers()[0] || await session.context.waitForEvent("serviceworker", { timeout: 10000 }); if (serviceWorker) { extensionId = serviceWorker.url().split('/')[2]; console.log(`[Browser] Found Extension ID: ${extensionId}`); } } catch (err) { console.warn('[Browser] Could not resolve extension ID from service worker:', err.message); } // Use the floating UI injected by content.js on the Lovable project page. // The sidepanel approach is intentionally skipped: security-hardening.js breaks // el.closest() (Object is frozen), causing the sidepanel page to crash before // sp-msg renders, wasting 50+ seconds on two doomed retries. console.log('[Browser] Using floating UI on Lovable project page...'); await page.bringToFront(); async function waitForFloatingInput(timeoutMs) { const extInput = page.locator('textarea#ql-msg').first(); try { await extInput.waitFor({ state: 'visible', timeout: timeoutMs }); return extInput; } catch (_) { return null; } } let extInput = await waitForFloatingInput(8000); if (!extInput) { console.log("[Browser] Floating extension textarea not found. Falling back to Lovable's native UI..."); try { const nativeInput = await page.evaluateHandle(() => { const editables = Array.from(document.querySelectorAll('div[contenteditable="true"], textarea')); return editables.find(el => { const rect = el.getBoundingClientRect(); if (rect.width < 100 || rect.height < 25) return false; const placeholder = (el.getAttribute('placeholder') || el.getAttribute('data-placeholder') || '').toLowerCase(); return placeholder.includes('update') || placeholder.includes('ask') || placeholder.includes('message') || placeholder.includes('change') || (el.tagName === 'DIV' && el.getAttribute('contenteditable') === 'true'); }) || editables[0]; }).then(handle => handle.asElement()); if (!nativeInput) throw new Error('Could not find any editable chat input on the page.'); await nativeInput.scrollIntoViewIfNeeded(); await nativeInput.click({ timeout: 5000 }); await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); await page.keyboard.press('Backspace'); const isDiv = await nativeInput.evaluate(el => el.tagName === 'DIV'); if (isDiv) { await nativeInput.focus(); await page.keyboard.insertText(text); } else { await nativeInput.fill(text); } await page.waitForTimeout(500); const sendBtn = page.locator('button[type="submit"], button:has-text("Send"), form button').first(); try { await sendBtn.waitFor({ state: 'visible', timeout: 5000 }); await sendBtn.click({ timeout: 5000 }); console.log('[Browser] ✅ Prompt submitted via native send button.'); } catch (btnErr) { console.log('[Browser] Native send button not found or not clickable. Pressing Control+Enter...'); await page.keyboard.press('Control+Enter'); console.log('[Browser] ✅ Prompt submitted via Control+Enter.'); } await page.waitForTimeout(1500); return; } catch (fallbackErr) { const pageTitle = await page.title(); const currentUrl = page.url(); throw new Error(`Failed to submit prompt: Both extension input and native input fallback failed. URL: ${currentUrl}. Title: ${pageTitle}. Error: ${fallbackErr.message}`); } } await extInput.click({ timeout: 10000 }); await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); await page.keyboard.press('Backspace'); await extInput.fill(text); await page.waitForTimeout(200); const extSendBtn = page.locator('button#ql-send').first(); try { await extSendBtn.waitFor({ state: 'visible', timeout: 10000 }); await extSendBtn.click({ timeout: 10000 }); } catch (err) { throw new Error('Extension send button (button#ql-send) not clickable: ' + err.message); } console.log('[Browser] ✅ Prompt submitted via webpage floating UI.'); await page.waitForTimeout(1500); } /** * #8: Optimized observer — uses targeted selectors instead of scanning all DOM elements. * #11: Consecutive error breaker — forces isProcessing=false after 3 consecutive scan failures. * * Observes Lovable DOM for build progress, questions, and completion. * @param {object} session * @param {function} onUpdate - (statusText, fileOps[], progressText, terminalLogs) => void * @param {function} onQuestion - (questionText, options[]) => void * @param {function} onFinished - (url, fullResponse) => void * @param {function} onTimeout - () => void */ export async function observeBuild(session, onUpdate, onQuestion, onFinished, onTimeout) { const { page } = session; if (!page) return; console.log('[Observer] Starting build observation...'); let lastHash = ''; let questionActive = false; let consecutiveErrors = 0; // #11: Error counter const MAX_OBSERVE_MS = 5 * 60 * 1000; const MAX_CONSECUTIVE_ERRORS = 3; // #11: Breaker threshold const startTime = Date.now(); while (session.isProcessing) { if (Date.now() - startTime > MAX_OBSERVE_MS) { console.log('[Observer] 5-minute timeout reached.'); session.isProcessing = false; if (onTimeout) await onTimeout(); break; } await page.waitForTimeout(2500); if (!session.isProcessing) break; try { // #8: Optimized — targeted selectors, no full-page div scan const scanData = await page.evaluate(() => { // 1. Deployment URL — only scan anchor elements const previewAnchor = Array.from(document.querySelectorAll('a[href*=".lovable.app"]')) .find(a => !a.getAttribute('href').includes('lovable.dev/')); const finishedUrl = previewAnchor ? previewAnchor.getAttribute('href') : null; // Also check for completion indicators beyond just preview URL const bodyText = document.body.innerText || ''; const hasCompletionSignal = /changes deployed|build succeeded|successfully deployed/i.test(bodyText); // 2. Question dialogs — scan only buttons const choiceKeywords = ['yes', 'no', 'overwrite', 'skip', 'keep', 'replace', 'accept', 'deny', 'continue', 'approve', 'allow']; const optionButtons = Array.from(document.querySelectorAll('button')).filter(btn => { const text = (btn.innerText || '').trim().toLowerCase(); return text && text.length <= 25 && choiceKeywords.some(kw => text.includes(kw)); }); let questionPanel = null; if (optionButtons.length > 0) { const parent = optionButtons[0].parentElement; const qText = parent ? (parent.innerText || '').split('\n')[0] : 'Lovable requires confirmation:'; questionPanel = { text: qText, options: optionButtons.map((btn, idx) => ({ text: (btn.innerText || '').trim(), index: idx })) }; } // 3. Step progress const progressMatch = bodyText.match(/Step (\d+) of (\d+)/i) || bodyText.match(/(\d+)\/(\d+)\s+steps/i); const progressText = progressMatch ? progressMatch[0] : ''; // 4. File operations — scan only code elements and specific spans const filePattern = /[\w.-]+\.(tsx|ts|css|json|js|jsx|html|md)/i; const fileOps = []; const seenPaths = new Set(); for (const el of document.querySelectorAll('code, span[class], p > code')) { const text = (el.innerText || '').trim(); if (text.length < 4 || text.length > 80) continue; const match = text.match(/([\w.-]+\/[\w.-]+\.\w+)|([\w.-]+\.\w+)/); if (match && filePattern.test(match[0]) && !seenPaths.has(match[0])) { seenPaths.add(match[0]); const parentText = (el.parentElement?.innerText || '').toLowerCase(); let op = 'edit'; if (/create|new|add|generating/.test(parentText)) op = 'create'; else if (/delete|remove/.test(parentText)) op = 'delete'; fileOps.push({ path: match[0], op }); } } // 5. Status text — scan only p, li, span (not every div) const keywords = ['applying', 'installing', 'creating', 'editing', 'generating', 'npm run', 'compiling', 'updating', 'building', 'processing']; const progressEls = Array.from(document.querySelectorAll('p, li, span')) .filter(el => { const t = (el.innerText || '').trim().toLowerCase(); return t.length > 3 && t.length < 100 && keywords.some(kw => t.includes(kw)); }); let statusText = ''; if (progressEls.length > 0) { let card = progressEls[progressEls.length - 1]; for (let i = 0; i < 4; i++) { if (card.parentElement && card.parentElement !== document.body) card = card.parentElement; } const clone = card.cloneNode(true); clone.querySelectorAll('pre, code, button, svg, style, script, textarea, input').forEach(e => e.remove()); const lines = clone.innerText.split('\n').map(l => l.trim()) .filter(l => l.length > 3 && !/project|account|share|thinking/i.test(l)); statusText = [...new Set(lines)].slice(-6).join('\n'); } // 6. Terminal / CLI logs — targeted selectors const termEls = Array.from(document.querySelectorAll('pre, [class*="terminal"], [class*="console"], [class*="logs"]')); let terminalLogs = ''; const cliBlocks = termEls.map(el => (el.innerText || '').trim()) .filter(t => t.length >= 15 && t.length <= 10000 && /npm|install|vite|build|transform|error|✓|failed|dist\//i.test(t)); if (cliBlocks.length > 0) { terminalLogs = cliBlocks[cliBlocks.length - 1].split('\n') .map(l => l.trim()).filter(l => l.length > 0).slice(-12).join('\n'); } return { finishedUrl, hasCompletionSignal, questionPanel, statusText, fileOps: fileOps.slice(0, 15), progressText, terminalLogs }; }); // Reset error counter on successful scan consecutiveErrors = 0; // Cache scan data session.lastScanData = scanData; // Finished — either preview URL found OR completion text detected if (scanData.finishedUrl || scanData.hasCompletionSignal) { const url = scanData.finishedUrl || ''; console.log(`[Observer] Build complete. URL: ${url || '(no preview URL)'}`); // #9: Extract full response with targeted approach let fullResponse = ''; try { fullResponse = await page.evaluate(() => { // Target the last large message block — look for the assistant's response area // Lovable renders responses in specific containers, look for the most recent one const candidates = Array.from(document.querySelectorAll('[class*="message"], [class*="response"], [class*="assistant"], [class*="chat"], [role="article"]')); let bestCard = null; let bestLength = 0; // If specific class selectors didn't work, fall back to finding large text blocks const searchPool = candidates.length > 0 ? candidates : Array.from(document.querySelectorAll('div')).filter(d => d.children.length < 40 && d !== document.body); for (const div of searchPool) { const text = (div.innerText || '').trim(); if (text.length > 100 && text.length < 15000 && text.length > bestLength) { const hasStructure = text.includes('•') || text.includes('-') || /^\d+\./m.test(text) || text.split('\n').filter(l => l.trim().length > 20).length >= 3; if (hasStructure) { bestLength = text.length; bestCard = div; } } } if (!bestCard) return ''; const clone = bestCard.cloneNode(true); clone.querySelectorAll('button, svg, style, script, textarea, input, nav').forEach(e => e.remove()); const lines = []; const walker = document.createTreeWalker(clone, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); let node; const seen = new Set(); while ((node = walker.nextNode())) { if (node.nodeType === Node.TEXT_NODE) { const text = node.nodeValue.trim(); if (text.length > 2 && !seen.has(text)) { seen.add(text); const parent = node.parentElement; const tag = parent?.tagName?.toLowerCase() || ''; if (tag === 'li') lines.push(`• ${text}`); else if (/^h[1-3]$/.test(tag)) lines.push(`\n📌 ${text}`); else if (tag === 'code' || tag === 'pre') lines.push(` ${text}`); else lines.push(text); } } } const deduped = []; for (const line of lines) { if (deduped.length === 0 || deduped[deduped.length - 1] !== line) deduped.push(line); } return deduped.join('\n'); }); } catch (err) { console.warn('[Observer] Response extraction failed:', err.message); } session.isProcessing = false; await onFinished(url, fullResponse); break; } // Question if (scanData.questionPanel && !questionActive) { questionActive = true; session.activeQuestionOptions = scanData.questionPanel.options; await onQuestion(scanData.questionPanel.text, scanData.questionPanel.options); } // Status update if (!scanData.questionPanel) { const displayStatus = scanData.statusText || (scanData.fileOps.length > 0 ? 'Processing files...' : ''); const hash = JSON.stringify({ s: displayStatus, f: scanData.fileOps.length, t: scanData.terminalLogs.length, p: scanData.progressText }); if (hash !== lastHash) { lastHash = hash; await onUpdate(displayStatus, scanData.fileOps, scanData.progressText, scanData.terminalLogs); } } // Reset question lock if (!scanData.questionPanel && questionActive) { questionActive = false; session.activeQuestionOptions = null; } } catch (err) { consecutiveErrors++; console.warn(`[Observer] Scan error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, err.message); // #11: Break out after 3 consecutive failures — page is probably dead if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { console.error('[Observer] Too many consecutive errors. Force-stopping observer.'); session.isProcessing = false; if (onTimeout) await onTimeout(); break; } } } } /** * #2: Uses Playwright's native locator.click() instead of page.evaluate(btn.click()). * Clicks an interactive option button by matching its text content (self-healing). */ export async function clickOptionButton(session, buttonText) { const { page } = session; if (!page) throw new Error('No active page.'); console.log(`[Browser] Clicking option: "${buttonText}"...`); // #2: Use Playwright native locator — fires React event handlers correctly try { const btn = page.locator('button').filter({ hasText: new RegExp(buttonText, 'i') }).first(); if (await btn.isVisible({ timeout: 3000 })) { await btn.click(); console.log('[Browser] Option clicked via Playwright locator.'); return; } } catch {} // Fallback: try exact text match with getByRole try { await page.getByRole('button', { name: buttonText, exact: false }).first().click(); console.log('[Browser] Option clicked via getByRole.'); return; } catch {} throw new Error(`Button "${buttonText}" not found in DOM.`); } /** * Captures a screenshot. Saves to OS temp directory. */ export async function takeBrowserScreenshot(session) { if (!session.page) throw new Error('No active page.'); const filePath = path.join(os.tmpdir(), `lovable-screenshot-${Date.now()}.png`); await session.page.screenshot({ path: filePath }); return filePath; }