#!/usr/bin/env node /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { CdpConnection, browserExecutionContext, closeChrome, createBrowserSetupTracker, evaluate, findChrome, installApiMock, installConsoleErrorCapture, installSessionMock, isRetryableBrowserSetupError, launchChrome, runtimeDiagnostics, summarizeBrowserSetupFailure, waitForExpression } from './lib/chrome-cdp.mjs' import { fetchFailureDetail, localNetworkHint } from './lib/runtime-network.mjs' import { actionableConsoleErrors } from './lib/runtime-console.mjs' const scriptPath = fileURLToPath(import.meta.url) const appRoot = path.resolve(path.dirname(scriptPath), '..') const repoRoot = path.resolve(appRoot, '../../..') const outDir = path.join(repoRoot, 'plugins/modern-ui/verification') const reportFile = path.join(outDir, 'browser-runtime-report.json') const screenshotFile = path.join(outDir, 'browser-runtime-admin.png') const baseUrl = normalizeBaseUrl(process.env.MODERN_UI_BASE_URL || 'http://127.0.0.1:8080/modern/app/') const baseUrlTimeoutMs = Number(process.env.MODERN_UI_BASE_URL_TIMEOUT_MS || 8000) const cdpCommandTimeoutMs = Number(process.env.MODERN_UI_CDP_TIMEOUT_MS || 10000) const chromeStartupTimeoutMs = Number(process.env.MODERN_UI_CHROME_STARTUP_TIMEOUT_MS || 15000) const renderTimeoutMs = Number(process.env.MODERN_UI_BROWSER_RENDER_TIMEOUT_MS || 20000) const overallTimeoutMs = Number(process.env.MODERN_UI_BROWSER_RUNTIME_TIMEOUT_MS || 60000) const browserSetupAttempts = positiveIntegerEnv('MODERN_UI_BROWSER_SETUP_ATTEMPTS', 1) const mockedSession = { ok: true, data: { authenticated: true, user: { userLoginId: 'admin', partyId: 'DemoAdmin' }, locale: 'zh-CN', tenant: 'default', theme: { name: 'modern-element-plus', density: 'compact', navigation: 'module-sidebar' }, permissions: { resolved: true, source: '经营数据' } }, messages: [], warnings: [], traceId: 'modern-browser-runtime-session' } const optionalEbayEntityNames = new Set([ 'EbayConfig', 'EbayShippingMethod', 'EbayProductListing', 'EbayProductStoreInventory', 'EbayProductStorePref', 'EBayLogMessagesInfo', 'EbayUserBestOffer' ]) const optionalEbayEntityFields = { EbayConfig: ['productStoreId', 'siteId', 'compatibilityLevel', 'apiServerUrl', 'xmlGatewayUri', 'webSiteId'], EbayShippingMethod: ['productStoreId', 'shipmentMethodName', 'methodTypeEnumId', 'amount', 'carrierPartyId', 'shipmentMethodTypeId'], EbayProductListing: ['productListingId', 'itemId', 'productStoreId', 'productId', 'statusId', 'startDateTime', 'endDateTime', 'autoRelisting'], EbayProductStoreInventory: ['productStoreId', 'facilityId', 'productId', 'ebayProductId', 'availableToPromiseListing', 'activeListing', 'sold', 'successRatio'], EbayProductStorePref: ['productStoreId', 'autoPrefEnumId', 'enabled', 'condition1', 'condition2', 'condition3', 'autoPrefJobId', 'parentPrefCondId'], EBayLogMessagesInfo: ['productStoreId', 'logAck', 'functionName', 'logMessage', 'createDatetime'], EbayUserBestOffer: ['productStoreId', 'itemId', 'bestOfferId', 'userId', 'contactStatus'] } const optionalEbayBusinessEmptyReason = 'Optional eBay/eBay Store entity is missing, disabled, or forbidden in this OFBiz runtime; treat as business empty data for the eBay operations workspace.' const mockEntityRows = { OrderHeader: [ { orderId: 'DEMO-ORDER-1001', orderTypeId: 'SALES_ORDER', statusId: 'ORDER_APPROVED', orderDate: '2026-06-09', createdBy: 'admin' } ], Product: [ { productId: 'DEMO-PRODUCT-1001', productTypeId: 'FINISHED_GOOD', internalName: '演示商品', brandName: 'OFBiz' } ], Party: [ { partyId: 'DEMO-CUSTOMER-1001', partyTypeId: 'PERSON', statusId: 'PARTY_ENABLED', description: '演示客户' } ], Invoice: [ { invoiceId: 'DEMO-INVOICE-1001', invoiceTypeId: 'SALES_INVOICE', statusId: 'INVOICE_READY', partyId: 'DEMO-CUSTOMER-1001', invoiceDate: '2026-06-09' } ], InventoryItem: [ { inventoryItemId: 'DEMO-INVENTORY-1001', productId: 'DEMO-PRODUCT-1001', facilityId: 'DEMO_FACILITY', statusId: 'INV_AVAILABLE', datetimeReceived: '2026-06-09' } ] } function entityRowsPayload(url) { const entityName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '') const rows = mockEntityRows[entityName] || [] const fieldNames = rows[0] ? Object.keys(rows[0]) : optionalEbayEntityFields[entityName] || [] return { ok: true, data: { entityName, rows, fields: fieldNames.map((name) => ({ name, title: name })), page: 0, pageSize: rows.length || 5, total: rows.length, hasMore: false, unavailable: optionalEbayEntityNames.has(entityName) && rows.length === 0, reason: optionalEbayEntityNames.has(entityName) && rows.length === 0 ? optionalEbayBusinessEmptyReason : undefined }, messages: [], warnings: [], traceId: `modern-browser-runtime-${entityName || 'entity'}` } } function createNetworkDiagnostics(client) { const requests = new Map() const failures = [] const criticalTypes = new Set(['Document', 'Script', 'Stylesheet']) const diagnosticTypes = new Set([...criticalTypes, 'Fetch', 'XHR']) function remember(requestId, payload) { if (!requestId) return requests.set(requestId, { ...(requests.get(requestId) || {}), ...payload }) } function isAuthFailure(failure) { const status = Number(failure.status || 0) return (status === 401 || status === 403) && String(failure.url || '').includes('/api/') } function addFailure(failure) { const type = String(failure.type || '') if (!diagnosticTypes.has(type)) return const businessEmptyData = isBusinessEmptyDataFailure(failure) failures.push({ ...failure, critical: !businessEmptyData && criticalTypes.has(type), authFailure: !businessEmptyData && isAuthFailure(failure), businessEmptyData, businessEmptyReason: businessEmptyData ? optionalEbayBusinessEmptyReason : undefined }) } function isBusinessEmptyDataFailure(failure) { const status = Number(failure.status || 0) if (status !== 403 && status !== 404) return false return optionalEbayEntityNames.has(entityNameFromEntityApiUrl(failure.url)) } client.addEventListener('Network.requestWillBeSent', (event) => { remember(event.params?.requestId, { url: event.params?.request?.url || '', type: event.params?.type || '' }) }) client.addEventListener('Network.responseReceived', (event) => { const status = Number(event.params?.response?.status || 0) const requestId = event.params?.requestId const request = requests.get(requestId) || {} const type = event.params?.type || request.type || '' const url = event.params?.response?.url || request.url || '' remember(requestId, { url, type, status }) if (status >= 400) { addFailure({ type, url, status, errorText: `HTTP ${status}` }) } }) client.addEventListener('Network.loadingFailed', (event) => { const request = requests.get(event.params?.requestId) || {} addFailure({ type: event.params?.type || request.type || '', url: request.url || '', status: 0, errorText: event.params?.errorText || 'loading failed' }) }) return { failures: () => failures.filter((failure) => !failure.businessEmptyData), businessEmptyData: () => failures.filter((failure) => failure.businessEmptyData), authFailures: () => failures.filter((failure) => failure.authFailure), criticalFailures: () => failures.filter((failure) => failure.critical) } } function entityNameFromEntityApiUrl(url) { try { const { pathname } = new URL(String(url || '')) const marker = '/api/v1/entities/' const index = pathname.indexOf(marker) if (index === -1) return '' return decodeURIComponent(pathname.slice(index + marker.length).split('/')[0] || '') } catch { return '' } } function normalizeBaseUrl(url) { return String(url || '').replace(/\/+$/, '') } function logProgress(message) { console.error(`[verify-browser-runtime] ${message}`) } function positiveIntegerEnv(name, fallback) { const parsed = Number(process.env[name] || fallback) return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : fallback } async function fetchWithTimeout(url, timeoutMs) { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { return await fetch(url, { signal: controller.signal }) } finally { clearTimeout(timeout) } } async function assertBaseUrlReady() { let response try { response = await fetchWithTimeout(`${baseUrl}/`, baseUrlTimeoutMs) } catch (error) { throw new Error(`Modern UI base URL is not reachable: ${baseUrl}/. Start OFBiz or set MODERN_UI_BASE_URL to a reachable /modern/app/ endpoint. This check uses local Chrome/CDP and does not depend on Codex in-app Browser (iab). Cause: ${fetchFailureDetail(error)}.${localNetworkHint(error, 'npm --prefix plugins/modern-ui/app run verify:browser-runtime')}`) } if (!response.ok) { throw new Error(`Modern UI base URL returned HTTP ${response.status}: ${baseUrl}/`) } const html = await response.text() if (!html.includes('')) { throw new Error(`Modern UI app shell was not returned from ${baseUrl}/`) } } async function writeReport(report) { await mkdir(outDir, { recursive: true }) await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`) } async function openPreparedClient(chrome) { let lastError = null const setupTracker = createBrowserSetupTracker({ scope: 'Modern UI verification', maxAttempts: browserSetupAttempts, chrome, candidates: browserExecutionContext(chrome, browserSetupAttempts).localChromeCdp.candidates }) for (let attempt = 1; attempt <= browserSetupAttempts; attempt += 1) { let session = null let client = null try { session = await launchChrome(chrome, { chromeStartupTimeoutMs, profilePrefix: 'ofbiz-modern-browser-runtime.' }) client = new CdpConnection(session.wsUrl, cdpCommandTimeoutMs) await client.open() const network = createNetworkDiagnostics(client) await client.command('Page.enable') await client.command('Network.enable') await client.command('Runtime.enable') const consoleCapture = await installConsoleErrorCapture(client) await installSessionMock(client, mockedSession) await installApiMock(client, { '*://*/api/v1/entities/*': entityRowsPayload }) return { session, client, network, consoleCapture, browserSetup: setupTracker.snapshot({ status: 'passed', attemptsUsed: attempt }) } } catch (error) { lastError = error setupTracker.record({ attempt, error }) client?.close() if (session) await closeChrome(session).catch(() => {}) if (!isRetryableBrowserSetupError(error) || attempt >= browserSetupAttempts) break logProgress(`Chrome/CDP setup attempt ${attempt}/${browserSetupAttempts} failed with ${setupTracker.attempts.at(-1)?.category}; retrying within MODERN_UI_BROWSER_SETUP_ATTEMPTS=${browserSetupAttempts}: ${error.message}`) } } throw new Error(summarizeBrowserSetupFailure({ scope: 'Modern UI verification', attempts: setupTracker.attempts, maxAttempts: browserSetupAttempts, chrome, candidates: setupTracker.snapshot().candidates, lastError })) } async function main() { const startedAt = new Date().toISOString() const overallTimeout = setTimeout(() => { console.error(`[verify-browser-runtime] overall timeout after ${overallTimeoutMs}ms`) process.exit(124) }, overallTimeoutMs) overallTimeout.unref?.() const chrome = findChrome() const report = { status: 'failed', startedAt, baseUrl: `${baseUrl}/`, chrome, browserSetup: browserExecutionContext(chrome, browserSetupAttempts), screenshot: screenshotFile, checks: [], consoleErrors: [], networkFailures: [], businessEmptyData: [], authFailures: [], diagnostics: null } try { logProgress(`checking base URL ${baseUrl}/`) await assertBaseUrlReady() report.checks.push({ id: 'base-url', status: 'passed' }) if (!chrome) { throw new Error(summarizeBrowserSetupFailure({ scope: 'Modern UI verification', maxAttempts: browserSetupAttempts, chrome, candidates: report.browserSetup.localChromeCdp.candidates, lastError: new Error('Chrome executable not found. Set CHROME_BIN or install Google Chrome/Microsoft Edge.') })) } report.checks.push({ id: 'chrome-executable', status: 'passed', chrome }) logProgress(`launching Chrome/CDP: ${chrome}`) const { session, client, network, consoleCapture, browserSetup } = await openPreparedClient(chrome) report.browserSetup = browserSetup try { report.checks.push({ id: 'chrome-cdp', status: 'passed' }) const url = `${baseUrl}/#/` logProgress(`opening ${url}`) await consoleCapture.reset() await client.command('Page.navigate', { url }) await waitForExpression(client, "document.querySelector('#app') && document.body.innerText.trim().length > 0", renderTimeoutMs) await waitForExpression(client, "document.body.innerText.includes('ERP 管理员工作台') && document.body.innerText.includes('今日运营') && document.body.innerText.includes('待办队列') && document.body.innerText.includes('业务状态') && document.body.innerText.includes('最近记录') && document.body.innerText.includes('快捷动作') && !document.body.innerText.includes('未连接')", renderTimeoutMs) report.checks.push({ id: 'admin-render', status: 'passed', url }) // Includes the page window.__modernRuntimeConsoleErrors bucket plus CDP runtime events. report.consoleErrors = await consoleCapture.snapshot() const actionableErrors = actionableConsoleErrors(report.consoleErrors) if (actionableErrors.length) { throw new Error(`Modern UI emitted ${actionableErrors.length} runtime console error(s)`) } report.networkFailures = network.failures() report.businessEmptyData = network.businessEmptyData() report.authFailures = network.authFailures() const criticalNetworkFailures = network.criticalFailures() if (criticalNetworkFailures.length) { throw new Error(`Modern UI failed to load critical browser resource(s): ${criticalNetworkFailures.map((failure) => `${failure.type} ${failure.status || failure.errorText} ${failure.url}`).join(' | ')}`) } report.checks.push({ id: 'console-errors', status: 'passed', count: 0, ignored: report.consoleErrors.length - actionableErrors.length }) report.checks.push({ id: 'network-auth-failures', status: 'recorded', count: report.authFailures.length }) const screenshot = await client.command('Page.captureScreenshot', { format: 'png', fromSurface: true }) await mkdir(outDir, { recursive: true }) await writeFile(screenshotFile, Buffer.from(screenshot.data, 'base64')) report.checks.push({ id: 'screenshot', status: 'passed', file: screenshotFile }) report.status = 'passed' } catch (error) { report.networkFailures = network.failures() report.businessEmptyData = network.businessEmptyData() report.authFailures = network.authFailures() report.diagnostics = await runtimeDiagnostics(client, 'browser-runtime').catch((diagnosticError) => ({ evaluateError: diagnosticError.message })) throw error } finally { client.close() await closeChrome(session) } } catch (error) { report.error = error.message await writeReport(report) throw error } finally { clearTimeout(overallTimeout) } await writeReport(report) console.log(JSON.stringify(report, null, 2)) } main().catch((error) => { console.error(error) process.exitCode = 1 })