yt-project / browser.js
3v324v23's picture
fix: load extension from writable temp directory in launchPersistentContext
bd800bf
Raw
History Blame Contribute Delete
28.4 kB
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;
}