init
This commit is contained in:
374
.opencode/skills/chrome-devtools/scripts/lib/browser.js
Normal file
374
.opencode/skills/chrome-devtools/scripts/lib/browser.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Shared browser utilities for Chrome DevTools scripts
|
||||
* Supports persistent browser sessions via WebSocket endpoint file
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import debug from 'debug';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const log = debug('chrome-devtools:browser');
|
||||
|
||||
// Session file stores WebSocket endpoint for browser reuse across processes
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SESSION_FILE = path.join(__dirname, '..', '.browser-session.json');
|
||||
const AUTH_SESSION_FILE = path.join(__dirname, '..', '.auth-session.json');
|
||||
|
||||
let browserInstance = null;
|
||||
let pageInstance = null;
|
||||
|
||||
/**
|
||||
* Resolve headless mode based on explicit value or OS auto-detection.
|
||||
* - Explicit 'true'/'false' or boolean always wins
|
||||
* - CI environments (CI, GITHUB_ACTIONS, GITLAB_CI, JENKINS_URL) → headless
|
||||
* - Linux → headless (servers/WSL typically have no display)
|
||||
* - macOS/Windows → headed for better debugging
|
||||
* @param {string|boolean|undefined} value - CLI arg value or boolean
|
||||
* @returns {boolean} - true for headless, false for headed
|
||||
*/
|
||||
export function resolveHeadless(value) {
|
||||
if (value === false || value === 'false') return false;
|
||||
if (value === true || value === 'true') return true;
|
||||
|
||||
// Auto-detect: CI → headless
|
||||
if (process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI || process.env.JENKINS_URL) {
|
||||
log('Auto-detected CI environment → headless');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Linux → headless (includes WSL, remote servers)
|
||||
if (process.platform === 'linux') {
|
||||
log('Auto-detected Linux → headless');
|
||||
return true;
|
||||
}
|
||||
|
||||
// macOS/Windows → headed for debugging
|
||||
log(`Auto-detected ${process.platform} → headed`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default Chrome profile path based on OS
|
||||
* @returns {string} - Path to Chrome's default user data directory
|
||||
*/
|
||||
function getDefaultChromeProfilePath() {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return `${process.env.HOME}/Library/Application Support/Google/Chrome`;
|
||||
case 'win32':
|
||||
return `${process.env.LOCALAPPDATA}/Google/Chrome/User Data`;
|
||||
default: // Linux and others
|
||||
return `${process.env.HOME}/.config/google-chrome`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read session info from file
|
||||
*/
|
||||
function readSession() {
|
||||
try {
|
||||
if (fs.existsSync(SESSION_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
// Check if session is not too old (max 1 hour)
|
||||
if (Date.now() - data.timestamp < 3600000) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to read session:', e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write session info to file
|
||||
*/
|
||||
function writeSession(wsEndpoint) {
|
||||
try {
|
||||
fs.writeFileSync(SESSION_FILE, JSON.stringify({
|
||||
wsEndpoint,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
} catch (e) {
|
||||
log('Failed to write session:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session file
|
||||
*/
|
||||
function clearSession() {
|
||||
try {
|
||||
if (fs.existsSync(SESSION_FILE)) {
|
||||
fs.unlinkSync(SESSION_FILE);
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to clear session:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save auth session (cookies, storage) for persistence
|
||||
* @param {Object} authData - Auth data to save
|
||||
*/
|
||||
export function saveAuthSession(authData) {
|
||||
try {
|
||||
const existing = readAuthSession() || {};
|
||||
const merged = { ...existing, ...authData, timestamp: Date.now() };
|
||||
fs.writeFileSync(AUTH_SESSION_FILE, JSON.stringify(merged, null, 2));
|
||||
log('Auth session saved');
|
||||
} catch (e) {
|
||||
log('Failed to save auth session:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read auth session from file
|
||||
* @returns {Object|null} - Auth session data or null
|
||||
*/
|
||||
export function readAuthSession() {
|
||||
try {
|
||||
if (fs.existsSync(AUTH_SESSION_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(AUTH_SESSION_FILE, 'utf8'));
|
||||
// Auth sessions valid for 24 hours
|
||||
if (Date.now() - data.timestamp < 86400000) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to read auth session:', e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear auth session file
|
||||
*/
|
||||
export function clearAuthSession() {
|
||||
try {
|
||||
if (fs.existsSync(AUTH_SESSION_FILE)) {
|
||||
fs.unlinkSync(AUTH_SESSION_FILE);
|
||||
log('Auth session cleared');
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to clear auth session:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply saved auth session to page
|
||||
* @param {Object} page - Puppeteer page instance
|
||||
* @param {string} url - Target URL for domain context
|
||||
*/
|
||||
export async function applyAuthSession(page, url) {
|
||||
const authData = readAuthSession();
|
||||
if (!authData) {
|
||||
log('No auth session found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Apply cookies
|
||||
if (authData.cookies && authData.cookies.length > 0) {
|
||||
await page.setCookie(...authData.cookies);
|
||||
log(`Applied ${authData.cookies.length} cookies`);
|
||||
}
|
||||
|
||||
// Apply localStorage (requires navigation first)
|
||||
if (authData.localStorage && Object.keys(authData.localStorage).length > 0) {
|
||||
await page.evaluate((data) => {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
});
|
||||
}, authData.localStorage);
|
||||
log('Applied localStorage data');
|
||||
}
|
||||
|
||||
// Apply sessionStorage
|
||||
if (authData.sessionStorage && Object.keys(authData.sessionStorage).length > 0) {
|
||||
await page.evaluate((data) => {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
});
|
||||
}, authData.sessionStorage);
|
||||
log('Applied sessionStorage data');
|
||||
}
|
||||
|
||||
// Apply extra headers
|
||||
if (authData.headers) {
|
||||
await page.setExtraHTTPHeaders(authData.headers);
|
||||
log('Applied HTTP headers');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
log('Failed to apply auth session:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch or connect to browser
|
||||
* If a session file exists with valid wsEndpoint, connects to existing browser
|
||||
* Otherwise launches new browser and saves wsEndpoint for future connections
|
||||
*/
|
||||
export async function getBrowser(options = {}) {
|
||||
// If we already have a connected browser in this process, reuse it
|
||||
if (browserInstance && browserInstance.isConnected()) {
|
||||
log('Reusing existing browser instance from process');
|
||||
return browserInstance;
|
||||
}
|
||||
|
||||
// Try to connect to existing browser from session file
|
||||
const session = readSession();
|
||||
if (session && session.wsEndpoint) {
|
||||
try {
|
||||
log('Attempting to connect to existing browser session');
|
||||
browserInstance = await puppeteer.connect({
|
||||
browserWSEndpoint: session.wsEndpoint
|
||||
});
|
||||
log('Connected to existing browser');
|
||||
return browserInstance;
|
||||
} catch (e) {
|
||||
log('Failed to connect to existing browser:', e.message);
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
// Connect via provided wsEndpoint or browserUrl
|
||||
if (options.wsEndpoint || options.browserUrl) {
|
||||
log('Connecting to browser via provided endpoint');
|
||||
browserInstance = await puppeteer.connect({
|
||||
browserWSEndpoint: options.wsEndpoint,
|
||||
browserURL: options.browserUrl
|
||||
});
|
||||
return browserInstance;
|
||||
}
|
||||
|
||||
// Resolve Chrome profile path
|
||||
let userDataDir = options.userDataDir || options.profile;
|
||||
if (options.useDefaultProfile) {
|
||||
userDataDir = getDefaultChromeProfilePath();
|
||||
log(`Using default Chrome profile: ${userDataDir}`);
|
||||
}
|
||||
|
||||
// Destructure known properties — only pass Puppeteer-valid options to launch()
|
||||
const { headless, args: extraArgs, viewport, useDefaultProfile, profile, browserUrl, wsEndpoint: _ws, userDataDir: _udd, ...restOptions } = options;
|
||||
|
||||
// Launch new browser
|
||||
const launchOptions = {
|
||||
headless: resolveHeadless(headless),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
...(extraArgs || [])
|
||||
],
|
||||
defaultViewport: viewport || {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
...(userDataDir && { userDataDir }),
|
||||
...restOptions
|
||||
};
|
||||
|
||||
log('Launching new browser');
|
||||
browserInstance = await puppeteer.launch(launchOptions);
|
||||
|
||||
// Save wsEndpoint for future connections
|
||||
const wsEndpoint = browserInstance.wsEndpoint();
|
||||
writeSession(wsEndpoint);
|
||||
log('Browser launched, session saved');
|
||||
|
||||
return browserInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page or create new one
|
||||
*/
|
||||
export async function getPage(browser) {
|
||||
if (pageInstance && !pageInstance.isClosed()) {
|
||||
log('Reusing existing page');
|
||||
return pageInstance;
|
||||
}
|
||||
|
||||
const pages = await browser.pages();
|
||||
if (pages.length > 0) {
|
||||
pageInstance = pages[0];
|
||||
} else {
|
||||
pageInstance = await browser.newPage();
|
||||
}
|
||||
|
||||
return pageInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close browser and clear session
|
||||
*/
|
||||
export async function closeBrowser() {
|
||||
if (browserInstance) {
|
||||
await browserInstance.close();
|
||||
browserInstance = null;
|
||||
pageInstance = null;
|
||||
clearSession();
|
||||
log('Browser closed, session cleared');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from browser without closing it
|
||||
* Use this to keep browser running for future script executions
|
||||
*/
|
||||
export async function disconnectBrowser() {
|
||||
if (browserInstance) {
|
||||
browserInstance.disconnect();
|
||||
browserInstance = null;
|
||||
pageInstance = null;
|
||||
log('Disconnected from browser (browser still running)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse command line arguments
|
||||
*/
|
||||
export function parseArgs(argv, options = {}) {
|
||||
const args = {};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2);
|
||||
const nextArg = argv[i + 1];
|
||||
|
||||
if (nextArg && !nextArg.startsWith('--')) {
|
||||
args[key] = nextArg;
|
||||
i++;
|
||||
} else {
|
||||
args[key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output JSON result
|
||||
*/
|
||||
export function outputJSON(data) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Output error
|
||||
*/
|
||||
export function outputError(error) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
}, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user