SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
#!/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('<!doctype html') && !html.includes('<div id="app">')) {
|
||||
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
|
||||
})
|
||||
Reference in New Issue
Block a user