import { accessSync, constants, existsSync } from 'node:fs' import { mkdtemp, rm, readFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { spawn } from 'node:child_process' import { setTimeout as delay } from 'node:timers/promises' export const chromeCandidates = [ process.env.CHROME_BIN, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', 'google-chrome', 'chromium', 'chrome' ].filter(Boolean) export function commandExists(command) { if (!command || command.includes('/')) return command && existsSync(command) return (process.env.PATH || '').split(path.delimiter).some((directory) => { if (!directory) return false try { accessSync(path.join(directory, command), constants.X_OK) return true } catch { return false } }) } export function findChrome() { return chromeCandidates.find(commandExists) || '' } export function localChromeCdpStatus(chrome = findChrome()) { return { available: Boolean(chrome), executable: chrome || null, candidates: chromeCandidates.map((candidate) => ({ command: candidate, available: commandExists(candidate) })) } } export function codexInAppBrowserStatus() { return { available: false, checked: false, reason: 'Codex in-app Browser is not used by this Node verification script. Check agent.browsers.list() from an agent runtime before routing work to iab.' } } export function browserExecutionContext(chrome = findChrome(), maxAttempts = 1) { return { mode: 'local Chrome/CDP', localChromeCdp: localChromeCdpStatus(chrome), codexInAppBrowser: codexInAppBrowserStatus(), boundedAttempts: Math.max(1, Number(maxAttempts) || 1) } } export function classifyBrowserSetupError(error) { const message = String(error?.message || '') const rules = [ ['chrome-not-found', ['Chrome executable not found', 'Set CHROME_BIN']], ['chrome-launch-failed', ['Chrome failed to start', 'Chrome exited before DevTools was ready']], ['cdp-port-unavailable', ['Chrome did not expose DevToolsActivePort']], ['cdp-websocket-unavailable', ['Timed out connecting to Chrome DevTools', 'Chrome DevTools WebSocket failed', 'No Chrome page target available', 'Chrome DevTools socket is not open']], ['cdp-command-timeout', ['Timed out running Chrome DevTools command']], ['runtime-auth-setup-failed', ['Chrome rejected runtime cookie', 'Unable to login to Modern API', 'Modern API login did not return a session cookie']], ['base-url-unreachable', ['Modern UI base URL is not reachable', 'Modern UI base URL is not ready', 'Start OFBiz', 'MODERN_UI_BASE_URL']], ['app-shell-unavailable', ['Modern UI app shell was not returned']] ] for (const [category, needles] of rules) { if (needles.some((needle) => message.includes(needle))) return category } return 'unknown-browser-setup-error' } export function isRetryableBrowserSetupError(error) { return new Set([ 'chrome-launch-failed', 'cdp-port-unavailable', 'cdp-websocket-unavailable', 'cdp-command-timeout' ]).has(classifyBrowserSetupError(error)) } export function createBrowserSetupTracker({ scope, maxAttempts, chrome = '', candidates = [] } = {}) { const attempts = [] const boundedAttempts = Math.max(1, Number(maxAttempts) || 1) return { scope: scope || 'browser setup', maxAttempts: boundedAttempts, chrome: chrome || null, candidates, attempts, record({ attempt, chrome: attemptChrome = chrome, error, retryable } = {}) { const category = classifyBrowserSetupError(error) attempts.push({ attempt, maxAttempts: boundedAttempts, chrome: attemptChrome || null, category, retryable: retryable ?? isRetryableBrowserSetupError(error), message: String(error?.message || error || 'unknown error') }) }, snapshot(extra = {}) { const last = attempts[attempts.length - 1] || null return { scope: scope || 'browser setup', mode: 'local Chrome/CDP', maxAttempts: boundedAttempts, attemptsUsed: attempts.length, chrome: chrome || null, candidates, attempts: attempts.slice(), lastCategory: last?.category || null, localChromeCdp: localChromeCdpStatus(chrome), codexInAppBrowser: codexInAppBrowserStatus(), boundedAttempts: boundedAttempts, ...extra } } } } export function summarizeBrowserSetupFailure({ scope, attempts = [], maxAttempts = 1, chrome = '', candidates = [], lastError = null } = {}) { const boundedAttempts = Math.max(1, Number(maxAttempts) || 1) const last = attempts[attempts.length - 1] || null const category = last?.category || classifyBrowserSetupError(lastError) const lines = [ `Unable to initialize local Chrome/CDP${scope ? ` for ${scope}` : ''} after ${attempts.length || 0}/${boundedAttempts} bounded attempt(s).`, `Failure category: ${category}.`, 'Codex in-app Browser (iab) status: not used by this Node script; do not retry iab unless agent.browsers.list() shows it is available.', `Local Chrome/CDP status: ${chrome ? `candidate ${chrome}` : 'no executable selected'}; ${candidates.length ? `${candidates.filter((item) => item.available).length}/${candidates.length} candidate(s) available` : 'candidate list unavailable'}.`, `Last error: ${last?.message || lastError?.message || 'unknown error'}.` ] if (attempts.length) { lines.push(...attempts.map((attempt) => `attempt ${attempt.attempt}/${attempt.maxAttempts} [${attempt.category}] retryable=${attempt.retryable}: ${attempt.message}`)) } return lines.join('\n') } export function waitForProcessExit(child, timeoutMs) { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) return new Promise((resolve) => { const timeout = setTimeout(() => { child.off('exit', onExit) resolve(false) }, timeoutMs) function onExit() { clearTimeout(timeout) resolve(true) } child.once('exit', onExit) }) } async function waitForDevToolsPort(profile, chromeStartupTimeoutMs, getEarlyExit = () => null) { const activePortFile = path.join(profile, 'DevToolsActivePort') const deadline = Date.now() + chromeStartupTimeoutMs let lastError = null while (Date.now() < deadline) { const earlyExit = getEarlyExit() if (earlyExit) { if (earlyExit.error) { throw new Error(`Chrome failed to start: ${earlyExit.error.message}`) } throw new Error(`Chrome exited before DevTools was ready: code=${earlyExit.code ?? 'null'} signal=${earlyExit.signal ?? 'null'}`) } try { const text = await readFile(activePortFile, 'utf8') const [port] = text.trim().split('\n') if (port) return port } catch (error) { lastError = error } await delay(100) } throw new Error(lastError?.message || `Chrome did not expose DevToolsActivePort within ${chromeStartupTimeoutMs}ms`) } export class CdpConnection { constructor(wsUrl, cdpCommandTimeoutMs = 10000) { this.wsUrl = wsUrl this.cdpCommandTimeoutMs = cdpCommandTimeoutMs this.nextId = 1 this.pending = new Map() this.eventHandlers = new Map() } async open() { this.socket = new WebSocket(this.wsUrl) await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out connecting to Chrome DevTools')), this.cdpCommandTimeoutMs) this.socket.addEventListener('open', () => { clearTimeout(timeout) resolve() }, { once: true }) this.socket.addEventListener('error', () => { clearTimeout(timeout) reject(new Error('Chrome DevTools WebSocket failed')) }, { once: true }) }) this.socket.addEventListener('message', (event) => { const message = JSON.parse(String(event.data)) if (message.id && this.pending.has(message.id)) { const { resolve, reject, timeout } = this.pending.get(message.id) clearTimeout(timeout) this.pending.delete(message.id) if (message.error) { reject(new Error(message.error.message || JSON.stringify(message.error))) } else { resolve(message.result) } } else if (message.method) { for (const handler of this.eventHandlers.get(message.method) || []) { Promise.resolve(handler(message)).catch(() => {}) } } }) } addEventListener(method, handler) { const handlers = this.eventHandlers.get(method) || [] handlers.push(handler) this.eventHandlers.set(method, handlers) } command(method, params = {}, timeoutMs = this.cdpCommandTimeoutMs) { const id = this.nextId++ const payload = JSON.stringify({ id, method, params }) return new Promise((resolve, reject) => { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { reject(new Error(`Chrome DevTools socket is not open for ${method}`)) return } const timeout = setTimeout(() => { this.pending.delete(id) reject(new Error(`Timed out running Chrome DevTools command: ${method}`)) }, timeoutMs) this.pending.set(id, { resolve, reject, timeout }) this.socket.send(payload) }) } close() { for (const [id, pending] of this.pending.entries()) { clearTimeout(pending.timeout) pending.reject(new Error('Chrome DevTools connection closed')) this.pending.delete(id) } try { this.socket?.close() } catch { // Nothing to clean up. } } } export async function launchChrome(chrome, options = {}) { const chromeStartupTimeoutMs = Number(options.chromeStartupTimeoutMs || 15000) const profilePrefix = options.profilePrefix || 'ofbiz-modern-chrome.' const windowSize = options.windowSize || '1440,1000' const profile = await mkdtemp(path.join(os.tmpdir(), profilePrefix)) const args = [ '--headless=new', '--disable-gpu', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-extensions', '--remote-debugging-port=0', `--window-size=${windowSize}`, `--user-data-dir=${profile}`, 'about:blank' ] const child = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'pipe'] }) let stderr = '' let earlyExit = null let launchError = null child.stderr.on('data', (chunk) => { stderr += chunk.toString() }) child.once('error', (error) => { launchError = error }) child.once('exit', (code, signal) => { earlyExit = { code, signal } }) try { const port = await waitForDevToolsPort(profile, chromeStartupTimeoutMs, () => launchError ? { error: launchError } : earlyExit) const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json()) const pageTarget = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl) if (!pageTarget) throw new Error('No Chrome page target available') return { child, profile, wsUrl: pageTarget.webSocketDebuggerUrl, stderr: () => stderr } } catch (error) { try { child.kill('SIGTERM') } catch { // The process may never have spawned. } await waitForProcessExit(child, 1000) await rm(profile, { recursive: true, force: true }) const stderrTail = stderr.trim().slice(-4000) throw new Error(stderrTail ? `${error.message}; Chrome stderr: ${stderrTail}` : error.message) } } export async function closeChrome(session) { if (session.child.exitCode === null && session.child.signalCode === null) { session.child.kill('SIGTERM') } const terminated = await waitForProcessExit(session.child, 1500) if (!terminated && session.child.exitCode === null && session.child.signalCode === null) { session.child.kill('SIGKILL') await waitForProcessExit(session.child, 1500) } await rm(session.profile, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }) } export async function evaluate(client, expression, timeoutMs = 5000) { const result = await client.command('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, timeout: timeoutMs }) if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime.evaluate failed') return result.result?.value } const consoleErrorCaptureSource = ` (() => { const key = '__modernRuntimeConsoleErrors' const serialize = (value) => { try { if (typeof value === 'string') return value if (value && typeof value === 'object' && value.stack) return String(value.stack) if (value && typeof value === 'object' && value.message) return String(value.message) return JSON.stringify(value) } catch { return String(value) } } const bucket = Array.isArray(window[key]) ? window[key] : [] try { Object.defineProperty(window, key, { value: bucket, configurable: true, writable: true }) } catch { window[key] = bucket } if (window.__modernRuntimeConsoleCaptureInstalled) return try { Object.defineProperty(window, '__modernRuntimeConsoleCaptureInstalled', { value: true, configurable: true }) } catch { window.__modernRuntimeConsoleCaptureInstalled = true } const originalError = console.error ? console.error.bind(console) : () => {} console.error = (...args) => { bucket.push(args.map(serialize).filter(Boolean).join(' ')) return originalError(...args) } window.addEventListener('error', (event) => { bucket.push(event.message || (event.error && serialize(event.error)) || 'window error') }) window.addEventListener('unhandledrejection', (event) => { bucket.push(event.reason ? serialize(event.reason) : 'unhandled rejection') }) })() ` function remoteObjectText(remoteObject) { if (!remoteObject) return '' if (typeof remoteObject.description === 'string') return remoteObject.description if (remoteObject.value !== undefined) return String(remoteObject.value) if (remoteObject.unserializableValue !== undefined) return String(remoteObject.unserializableValue) return remoteObject.type || '' } function uniqueConsoleErrors(errors) { const seen = new Set() const unique = [] for (const error of errors) { const text = String(error || '').trim() if (!text || seen.has(text)) continue seen.add(text) unique.push(text) } return unique } export async function installConsoleErrorCapture(client) { const eventErrors = [] client.addEventListener('Runtime.consoleAPICalled', (event) => { if (event.params?.type !== 'error') return const text = (event.params?.args || []).map(remoteObjectText).filter(Boolean).join(' ') eventErrors.push(text || 'console.error') }) client.addEventListener('Runtime.exceptionThrown', (event) => { const details = event.params?.exceptionDetails || {} const text = remoteObjectText(details.exception) || details.text || 'runtime exception' eventErrors.push(text) }) await client.command('Page.addScriptToEvaluateOnNewDocument', { source: consoleErrorCaptureSource }) return { eventErrors: () => uniqueConsoleErrors(eventErrors), async reset() { eventErrors.length = 0 await evaluate(client, 'window.__modernRuntimeConsoleErrors = []').catch(() => undefined) }, async snapshot() { const pageErrors = await evaluate(client, 'window.__modernRuntimeConsoleErrors || []').catch(() => []) return uniqueConsoleErrors([ ...eventErrors, ...(Array.isArray(pageErrors) ? pageErrors : []) ]) } } } export async function waitForExpression(client, expression, timeoutMs = 15000) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { const value = await evaluate(client, `Boolean(${expression})`, 2000).catch(() => false) if (value) return await delay(150) } throw new Error(`Timed out waiting for expression: ${expression}`) } const fetchMockState = new WeakMap() export function installSessionMock(client, sessionPayload) { return installApiMock(client, { '*://*/api/v1/session*': sessionPayload }) } export async function installApiMock(client, handlers) { const entries = Object.entries(handlers || {}) if (!entries.length) return const state = fetchMockState.get(client) || { handlers: new Map(), listening: false } for (const [urlPattern, handler] of entries) { state.handlers.set(urlPattern, handler) } fetchMockState.set(client, state) await client.command('Fetch.enable', { patterns: Array.from(state.handlers.keys()).map((urlPattern) => ({ urlPattern, requestStage: 'Request' })) }) if (state.listening) return state.listening = true client.addEventListener('Fetch.requestPaused', async (event) => { const requestId = event.params?.requestId const url = event.params?.request?.url || '' if (!requestId) return const activeState = fetchMockState.get(client) || state const match = Array.from(activeState.handlers.entries()).find(([urlPattern]) => urlMatchesPattern(url, urlPattern)) if (!match) { await client.command('Fetch.continueRequest', { requestId }).catch(() => {}) return } const payload = typeof match[1] === 'function' ? match[1](url, event.params?.request || {}) : match[1] await client.command('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [ { name: 'Content-Type', value: 'application/json; charset=utf-8' }, { name: 'Cache-Control', value: 'no-store' } ], body: Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') }).catch(() => {}) }) } function urlMatchesPattern(url, pattern) { const escaped = String(pattern) .replace(/[.+?^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') return new RegExp(`^${escaped}$`).test(url) } export async function runtimeDiagnostics(client, id) { const value = await evaluate(client, `(() => { const app = document.querySelector('#app') return { id: ${JSON.stringify(id)}, href: location.href, readyState: document.readyState, hasApp: Boolean(app), appChildCount: app ? app.childElementCount : -1, appHtml: app ? app.innerHTML.slice(0, 800) : '', bodyText: document.body ? document.body.innerText.slice(0, 1200) : '', consoleErrors: window.__modernRuntimeConsoleErrors || [] } })()`, 2000).catch((error) => ({ id, evaluateError: error.message })) return JSON.stringify(value, null, 2) }