Files
ERP/ofbiz-framework/plugins/modern-ui/app/scripts/verify-parity.mjs
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

1193 lines
44 KiB
JavaScript

#!/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 { access, readFile, readdir, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
CdpConnection,
closeChrome,
evaluate,
findChrome,
installSessionMock,
launchChrome,
runtimeDiagnostics,
waitForExpression
} from './lib/chrome-cdp.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 inventoryFile = path.join(repoRoot, 'plugins/modern-api/generated/ui-inventory.json')
const publicInventoryFile = path.join(appRoot, 'public/generated/ui-inventory.json')
const publicPagesDir = path.join(appRoot, 'public/generated/pages')
const builtInventoryFile = path.join(repoRoot, 'plugins/modern-ui/webapp/modern/app/generated/ui-inventory.json')
const builtPagesDir = path.join(repoRoot, 'plugins/modern-ui/webapp/modern/app/generated/pages')
const outDir = path.join(repoRoot, 'plugins/modern-ui/verification')
const outJson = path.join(outDir, 'parity-verification.json')
const outMd = path.join(outDir, 'parity-verification.md')
const javaHome = process.env.JAVA_HOME || '/Users/qiu/Desktop/ERP/.jdks/jdk-17.0.19+10/Contents/Home'
const legacyBaseUrl = process.env.OFBIZ_LEGACY_BASE_URL || 'https://localhost:8443'
const modernBaseUrl = normalizeBaseUrl(process.env.MODERN_UI_BASE_URL || 'http://127.0.0.1:8080/modern/app/')
const apiBaseUrl = normalizeApiBaseUrl(process.env.OFBIZ_API_BASE_URL || process.env.MODERN_UI_API_BASE_URL || modernBaseUrl)
const smokeUsername = process.env.OFBIZ_SMOKE_USERNAME || 'admin'
const smokePassword = process.env.OFBIZ_SMOKE_PASSWORD || 'ofbiz'
const runtimeSmokeMode = process.env.OFBIZ_RUNTIME_SMOKE || 'sample'
const runtimeSmokeLimit = Number(process.env.OFBIZ_RUNTIME_SMOKE_LIMIT || '64')
const runtimeSmokePageIds = (process.env.OFBIZ_RUNTIME_SMOKE_PAGE_IDS || '')
.split(',')
.map((pageId) => pageId.trim())
.filter(Boolean)
const runtimeSmokeIsTargeted = runtimeSmokePageIds.length > 0
const cdpCommandTimeoutMs = Number(process.env.MODERN_UI_CDP_TIMEOUT_MS || 10000)
const chromeStartupTimeoutMs = Number(process.env.MODERN_UI_CHROME_STARTUP_TIMEOUT_MS || 15000)
const modernUiRenderTimeoutMs = Number(process.env.MODERN_UI_PARITY_RENDER_TIMEOUT_MS || 20000)
const modernBaseUrlParts = new URL(`${modernBaseUrl}/`)
const apiBaseUrlParts = new URL(`${apiBaseUrl}/`)
const modernWebappContextPath = inferWebappContextPath(modernBaseUrlParts.pathname)
const modernSecuredLoginIdCookieName = `${webappCookieApplicationName(modernWebappContextPath)}.securedLoginId`
const baseSmokePages = [
'accounting__ManualTransaction',
'accounting__EditBillingAccount',
'accounting__ListChecksToSend',
'accounting__editPaymentApplications',
'marketing__MarketingCampaignReport',
'webpos__ShowCart',
'order__orderview',
'ecommerce__main'
]
const mockedSession = {
ok: true,
data: {
authenticated: true,
user: {
userLoginId: smokeUsername,
partyId: 'ModernParitySmoke'
},
locale: 'zh-CN',
tenant: 'default',
theme: {
name: 'modern-element-plus',
density: 'compact',
navigation: 'module-sidebar'
},
permissions: {
resolved: true,
source: 'OFBiz Security'
}
},
messages: [],
warnings: [],
traceId: 'modern-parity-runtime-session'
}
process.env.NODE_TLS_REJECT_UNAUTHORIZED = process.env.NODE_TLS_REJECT_UNAUTHORIZED || '0'
const explicitRendererTypes = new Set([
'legacy-screen',
'section',
'form',
'table',
'actions',
'menu',
'links',
'permission',
'client-behavior',
'report',
'search-workspace',
'entity-editor',
'domain-workspace',
'tree-workspace',
'calendar',
'lookup-workspace',
'commerce-surface',
'pos-workspace',
'route-workspace',
'template-adapter',
'html-template',
'empty'
])
function isRendererSupported(type = '') {
return explicitRendererTypes.has(type) || type.endsWith('-workspace')
}
function indexBy(items, key) {
const result = new Map()
for (const item of items || []) {
if (item?.[key]) result.set(item[key], item)
}
return result
}
async function readJson(file) {
return JSON.parse(await readFile(file, 'utf8'))
}
async function exists(file) {
try {
await access(file, constants.R_OK)
return true
} catch {
return false
}
}
function responseCookies(response) {
if (typeof response.headers.getSetCookie === 'function') {
return response.headers.getSetCookie()
}
const cookie = response.headers.get('set-cookie')
return cookie ? [cookie] : []
}
function mergeCookies(jar, response) {
for (const cookie of responseCookies(response)) {
const [pair] = cookie.split(';')
const index = pair.indexOf('=')
if (index > 0) {
jar.set(pair.slice(0, index), pair.slice(index + 1))
}
}
}
function cookieHeader(jar) {
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
}
function formBody(fields) {
const body = new URLSearchParams()
for (const [key, value] of Object.entries(fields)) {
body.set(key, value)
}
return body
}
function normalizeBaseUrl(url) {
return String(url || '').replace(/\/+$/, '')
}
function normalizeApiBaseUrl(url) {
const normalized = normalizeBaseUrl(url)
if (normalized.endsWith('/modern/app')) {
return `${normalized.slice(0, -'/modern/app'.length)}/api`
}
if (normalized.endsWith('/modern')) {
return `${normalized.slice(0, -'/modern'.length)}/api`
}
if (normalized.endsWith('/api')) {
return normalized
}
return `${normalized}/api`
}
function inferWebappContextPath(pathname) {
const [firstSegment] = String(pathname || '/').split('/').filter(Boolean)
return firstSegment ? `/${firstSegment}` : '/'
}
function webappCookieApplicationName(contextPath) {
if (!contextPath || contextPath === '/') return 'root'
return contextPath.replace(/^\/+/, '').replace(/\//g, '_')
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, { ...options, signal: controller.signal })
} finally {
clearTimeout(timeout)
}
}
async function countPageFiles(dir) {
try {
return (await readdir(dir)).filter((file) => file.endsWith('.json')).length
} catch {
return -1
}
}
function routePageUrl(route) {
return String(route.pageDefinitionUrl || '')
}
function expectedPageFile(route, pagesDir) {
const url = routePageUrl(route)
if (!url) return ''
return path.join(pagesDir, path.basename(url))
}
async function verifySplitAssets(inventory, inventoryPath, pagesDir, label) {
const pageCount = Object.keys(inventory.pageDefinitions || {}).length
const routeManifest = inventory.routeManifest || []
const checks = []
let snapshot = null
let pageFiles = -1
try {
snapshot = await readJson(inventoryPath)
} catch (error) {
checks.push({
id: `${label}-inventory-readable`,
label: `${label} inventory JSON is readable`,
status: 'failed',
expected: 'readable JSON',
actual: error.message
})
return checks
}
pageFiles = await countPageFiles(pagesDir)
checks.push({
id: `${label}-inventory-readable`,
label: `${label} inventory JSON is readable`,
status: snapshot?.routeManifest?.length ? 'passed' : 'failed',
expected: 'routeManifest present',
actual: snapshot?.routeManifest?.length || 0
})
checks.push({
id: `${label}-page-file-count`,
label: `${label} split PageDefinition files cover every generated page`,
status: pageFiles === pageCount ? 'passed' : 'failed',
expected: pageCount,
actual: pageFiles
})
checks.push({
id: `${label}-index-lightweight`,
label: `${label} index does not embed full page/action definitions`,
status: Object.keys(snapshot.pageDefinitions || {}).length === 0
&& Object.keys(snapshot.actionDefinitions || {}).length === 0 ? 'passed' : 'failed',
expected: 0,
actual: Object.keys(snapshot.pageDefinitions || {}).length + Object.keys(snapshot.actionDefinitions || {}).length
})
checks.push({
id: `${label}-page-definition-urls`,
label: `${label} every route has a split PageDefinition URL`,
status: (snapshot.routeManifest || []).length === routeManifest.length
&& (snapshot.routeManifest || []).every((route) => routePageUrl(route).startsWith('/modern/app/generated/pages/')) ? 'passed' : 'failed',
expected: routeManifest.length,
actual: (snapshot.routeManifest || []).filter((route) => routePageUrl(route).startsWith('/modern/app/generated/pages/')).length
})
const snapshotRoutes = snapshot.routeManifest || []
const representativeRoutes = [
snapshotRoutes.find((route) => route.pageId === 'accounting__ManualTransaction'),
snapshotRoutes.find((route) => route.pageId === 'webpos__ShowCart'),
snapshotRoutes.find((route) => route.pageId === 'order__orderview'),
snapshotRoutes.find((route) => route.pageId === 'ecommerce__main')
].filter(Boolean)
for (const route of representativeRoutes) {
const file = expectedPageFile(route, pagesDir)
checks.push({
id: `${label}-representative-page-${route.pageId}`,
label: `${label} representative page file exists: ${route.pageId}`,
status: file && await exists(file) ? 'passed' : 'failed',
expected: route.pageId,
actual: file ? path.basename(file) : 'missing pageDefinitionUrl'
})
}
return checks
}
function pageChecks(page, route, actionDefinitions) {
const blocks = page.blocks || []
const actions = page.actions || []
const acceptance = page.acceptance || {}
const scenario = acceptance.e2eScenario || {}
const unsupportedBlocks = blocks
.map((block) => block.type)
.filter((type) => !isRendererSupported(type))
const missingActionContracts = actions
.map((action) => typeof action === 'string' ? action : action.actionId)
.filter((actionId) => actionId && !actionDefinitions[actionId])
const pendingBusinessSteps = (scenario.steps || []).filter((step) => step.status === 'pending-business-e2e').length
return {
pageId: page.pageId,
title: page.title,
domain: page.domain,
layout: page.layout,
risk: acceptance.risk || 'low',
routeMapped: Boolean(route?.legacyPath && route?.modernPath),
pageDefinitionReady: Boolean(page.pageId && page.title && blocks.length),
frontendRewriteReady: acceptance.frontendRewriteStatus === 'rewritten-preview-passed'
&& acceptance.uiRewriteStatus === 'element-plus-renderable',
rendererSupported: unsupportedBlocks.length === 0,
actionContractsReady: missingActionContracts.length === 0,
scenarioReady: Boolean(scenario.scenarioId && scenario.steps?.length && scenario.assertions?.length && scenario.apiContracts?.length),
businessParityVerified: acceptance.businessParityStatus === 'verified',
pendingBusinessSteps,
blockCount: blocks.length,
actionCount: actions.length,
checklistPassed: acceptance.checklistPassed || 0,
checklistTotal: acceptance.checklistTotal || 0,
unsupportedBlocks,
missingActionContracts,
legacyPath: page.legacy?.path || '',
modernPath: `/modern/app/#/pages/${page.pageId}`,
scenarioId: scenario.scenarioId || '',
scenarioFlow: scenario.flow || ''
}
}
function pageContractSmoke(page) {
const blocks = page.blocks || []
const formBlocks = blocks.filter((block) => block.type === 'form')
const tableBlocks = blocks.filter((block) => block.type === 'table')
const actions = page.actions || []
const formContracts = formBlocks.filter((block) => block.submitAction?.status).length
const tableContracts = tableBlocks.filter((block) => block.dataSource?.type).length
return {
pageId: page.pageId,
status: formContracts === formBlocks.length && tableContracts === tableBlocks.length ? 'passed' : 'failed',
formBlocks: formBlocks.length,
formContracts,
tableBlocks: tableBlocks.length,
tableContracts,
actionContracts: actions.length,
blockTypes: [...new Set(blocks.map((block) => block.type))],
formContractStatuses: countBy(formBlocks, (block) => block.submitAction?.status || 'missing'),
tableDataSourceTypes: countBy(tableBlocks, (block) => block.dataSource?.type || 'missing')
}
}
function addSmokePage(selected, page, reason) {
if (!page || !page.legacy?.path || selected.has(page.pageId)) return
selected.set(page.pageId, {
page,
reason
})
}
function sortedSmokeCandidates(pages) {
return [...pages]
.filter((page) => page.legacy?.path)
.sort((a, b) => {
const riskWeight = { high: 0, medium: 1, low: 2 }
const aRisk = riskWeight[a.acceptance?.risk] ?? 3
const bRisk = riskWeight[b.acceptance?.risk] ?? 3
const aBlocks = (a.blocks || []).length
const bBlocks = (b.blocks || []).length
return aRisk - bRisk || bBlocks - aBlocks || a.pageId.localeCompare(b.pageId)
})
}
function selectRuntimeSmokePages(pagesById, pages) {
if (runtimeSmokePageIds.length) {
const missing = []
const selected = []
for (const pageId of runtimeSmokePageIds) {
const page = pagesById.get(pageId)
if (!page) {
missing.push(`${pageId}: page definition not found`)
} else if (!page.legacy?.path) {
missing.push(`${pageId}: page has no legacy path`)
} else {
selected.push({ page, reason: `explicit:${pageId}` })
}
}
if (missing.length) {
throw new Error(`Invalid OFBIZ_RUNTIME_SMOKE_PAGE_IDS (${missing.join('; ')})`)
}
return selected
}
const candidates = sortedSmokeCandidates(pages)
if (runtimeSmokeMode === 'all') {
return candidates.map((page) => ({ page, reason: 'all' }))
}
const selected = new Map()
for (const pageId of baseSmokePages) {
addSmokePage(selected, pagesById.get(pageId), 'baseline')
}
const byDomain = new Map()
const byFlow = new Map()
for (const page of candidates) {
const domain = page.domain || 'Unknown'
const flow = page.acceptance?.e2eScenario?.flow || 'route-view'
if (!byDomain.has(domain)) byDomain.set(domain, page)
if (!byFlow.has(flow)) byFlow.set(flow, page)
}
for (const [domain, page] of byDomain) {
addSmokePage(selected, page, `domain:${domain}`)
}
for (const [flow, page] of byFlow) {
addSmokePage(selected, page, `flow:${flow}`)
}
for (const page of candidates.filter((item) => item.acceptance?.risk === 'high')) {
addSmokePage(selected, page, 'high-risk')
if (selected.size >= runtimeSmokeLimit) break
}
for (const page of candidates) {
addSmokePage(selected, page, 'fill')
if (selected.size >= runtimeSmokeLimit) break
}
return [...selected.values()].slice(0, runtimeSmokeLimit)
}
async function loginLegacySession(webappPath) {
const jar = new Map()
const loginUrl = `${legacyBaseUrl}${webappPath}/control/login`
const loginPage = await fetchWithTimeout(loginUrl)
mergeCookies(jar, loginPage)
const loginResponse = await fetchWithTimeout(loginUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: cookieHeader(jar)
},
redirect: 'manual',
body: formBody({ USERNAME: smokeUsername, PASSWORD: smokePassword })
})
mergeCookies(jar, loginResponse)
const loginText = await loginResponse.text().catch(() => '')
return {
jar,
loginPageStatus: loginPage.status,
loginStatus: loginResponse.status,
loginOk: loginResponse.status < 400 && !loginText.includes('User Name') && !loginText.includes('Password'),
loginTitle: (loginText.match(/<title>([^<]+)<\/title>/i) || [])[1] || ''
}
}
async function verifyLegacyRuntime(page) {
const legacyPath = page.legacy?.path || ''
if (!legacyPath) {
return { status: 'skipped', legacyPath, reason: 'No legacy path' }
}
const webappPath = legacyPath.split('/control/')[0]
try {
const session = await loginLegacySession(webappPath)
const response = await fetchWithTimeout(`${legacyBaseUrl}${legacyPath}`, {
headers: { Cookie: cookieHeader(session.jar) }
}, 12000)
const text = await response.text()
const loginScreen = text.includes('User Name') && text.includes('Password')
const title = (text.match(/<title>([^<]+)<\/title>/i) || [])[1] || ''
const legacyUnavailable = session.loginPageStatus === 404 || session.loginStatus === 404 || response.status === 404
return {
status: legacyUnavailable
? 'legacy-unavailable'
: session.loginOk && response.status < 500 && !loginScreen
? 'passed'
: 'failed',
legacyPath,
httpStatus: response.status,
loginPageStatus: session.loginPageStatus,
loginStatus: session.loginStatus,
title,
loginScreen,
bytes: text.length,
reason: legacyUnavailable ? 'Legacy webapp or route returned 404 in this local OFBiz runtime' : '',
expectedSignals: [page.title, page.legacy?.viewMap, page.domain].filter(Boolean),
matchedSignals: [page.title, page.legacy?.viewMap, page.domain].filter((signal) => text.includes(String(signal)))
}
} catch (error) {
return {
status: 'failed',
legacyPath,
error: error.message
}
}
}
function runtimeSmokeStatus(legacy, modernApi, modernUi, contract) {
const modernPassed = [modernApi, modernUi, contract].every((item) => item.status === 'passed')
if (legacy.status === 'passed' && modernPassed) return 'passed'
if (legacy.status === 'legacy-unavailable' && modernPassed) return 'passed-with-legacy-unavailable'
return 'failed'
}
async function verifyModernApiRuntime(page) {
try {
const response = await fetchWithTimeout(`${apiBaseUrl}/v1/pages/${encodeURIComponent(page.pageId)}`)
const payload = await response.json()
const data = payload?.data || {}
return {
status: response.ok && payload?.ok === true && data.pageId === page.pageId && Array.isArray(data.blocks) ? 'passed' : 'failed',
httpStatus: response.status,
pageId: data.pageId || '',
blockCount: Array.isArray(data.blocks) ? data.blocks.length : 0,
actionCount: Array.isArray(data.actions) ? data.actions.length : 0,
traceId: payload?.traceId || ''
}
} catch (error) {
return {
status: 'failed',
error: error.message
}
}
}
function modernUiExpectedSignals(page) {
const signals = [['处理台', '业务处理台']]
const titleSignals = [
page.title,
page.legacy?.viewMap,
pageDisplayTitleForSmoke(page.title, page.pageId),
...(page.blocks || []).map((block) => pageDisplayTitleForSmoke(block.title))
].filter(Boolean)
const domainSignals = [
page.domain,
domainLabelForSmoke(page.domain)
].filter(Boolean)
if (titleSignals.length) signals.push([...new Set(titleSignals)])
if (domainSignals.length) signals.push([...new Set(domainSignals)])
const blocks = page.blocks || []
if (blocks.some((block) => block.type === 'form')) signals.push(['资料明细', '查询表单'])
if (blocks.some((block) => block.type === 'table')) signals.push(['业务列表', '资料明细'])
if ((page.actions || []).length) signals.push(['处理动作', '业务动作'])
return signals
}
function cleanLabelForSmoke(value) {
return String(value || '')
.replace(/\$\{uiLabelMap\.([^}]+)\}/g, '$1')
.replace(/\$\{([^}]+)\}/g, '$1')
}
function domainLabelForSmoke(value) {
const raw = String(value || '')
const normalized = raw.toLowerCase().trim()
const compact = normalized.replace(/[^a-z0-9]+/g, '')
const labels = {
accounting: '财务',
order: '订单',
catalog: '商品',
product: '商品',
'product / catalog': '商品',
productcatalog: '商品',
party: '客户',
facility: '库存',
shipment: '发运',
workeffort: '任务',
'work effort': '任务',
manufacturing: '生产',
humanres: '人力',
'human resources': '人力',
humanresources: '人力',
content: '内容',
marketing: '营销',
webtools: '系统工具',
webpos: '收银',
ecommerce: '电商',
assetmaint: '资产维护',
'asset maintenance': '资产维护',
assetmaintenance: '资产维护',
example: '扩展应用',
examples: '扩展应用',
exampleext: '扩展应用',
scrum: '项目协作',
project: '项目',
projectmgr: '项目',
'marketplace plugins': '店铺扩展',
marketplaceplugins: '店铺扩展',
reporting: '报表',
report: '报表',
common: '通用',
import: '导入',
myportal: '门户',
login: '登录',
ebay: '电商渠道',
ebaystore: '电商店铺',
firstdata: '支付网关',
birt: '报表',
bi: '商业智能',
backoffice: '后台',
system: '系统'
}
return labels[normalized] || labels[compact] || raw
}
function pageDisplayTitleForSmoke(title, pageId = '') {
const raw = cleanLabelForSmoke(title || String(pageId || '').split('__').pop() || '')
const normalized = raw
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[-_]+/g, ' ')
.trim()
const lower = normalized.toLowerCase()
const labels = {
main: '首页',
'find billing account': '查询结算账户',
'edit billing account': '编辑结算账户',
'manual transaction': '手工交易',
manualtransaction: '手工交易',
'accounting manual transaction': '财务手工交易',
'accounting manualtransaction': '财务手工交易',
'manual tx': '手工交易',
manualtx: '手工交易',
'manual etx': '手工电子交易',
manualetx: '手工电子交易',
'print checks': '打印支票',
'find orders': '订单查询',
'order find order': '订单查询',
'order find orders': '订单查询',
findorders: '订单查询',
'find product': '商品查询',
findproduct: '商品查询',
findparty: '客户查询',
'find party': '客户查询',
'find facility': '库存设施查询',
findfacility: '库存设施查询',
'find work effort': '工作任务查询',
findworkeffort: '工作任务查询'
}
return labels[lower.replace(/\s+/g, '')] || labels[lower] || normalized || raw
}
function runtimeSignalExpression(groups) {
return groups
.map((group) => `(${group.map((text) => `document.body.innerText.includes(${JSON.stringify(text)})`).join(' || ')})`)
.join(' && ')
}
function missingRuntimeSignalGroups(text, groups) {
return groups.filter((group) => !group.some((signal) => text.includes(signal)))
}
function flattenSignals(groups) {
return groups.map((group) => group.join(' | '))
}
async function openModernUiRuntime(chrome) {
if (!chrome) {
return {
ok: false,
error: 'Chrome executable not found for modern UI render smoke'
}
}
let session = null
let client = null
try {
session = await launchChrome(chrome, {
chromeStartupTimeoutMs,
profilePrefix: 'ofbiz-modern-parity.'
})
client = new CdpConnection(session.wsUrl, cdpCommandTimeoutMs)
await client.open()
await client.command('Page.enable')
await client.command('Network.enable')
await client.command('Runtime.enable')
for (const cookie of await loginApiSession()) {
await setRuntimeCookie(client, cookie)
}
await installSessionMock(client, mockedSession)
return {
ok: true,
session,
client
}
} catch (error) {
client?.close()
if (session) await closeChrome(session).catch(() => undefined)
return {
ok: false,
error: error.message
}
}
}
async function loginApiSession() {
const loginUrl = `${apiBaseUrl}/v1/login`
const response = await fetchWithTimeout(loginUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: smokeUsername,
password: smokePassword
})
}, 12000)
const text = await response.text()
if (!response.ok) {
throw new Error(`Unable to login to Modern API for parity runtime verification: HTTP ${response.status} ${text}`)
}
const payload = parseJson(text)
const userLoginId = payload?.data?.user?.userLoginId || smokeUsername
const cookies = responseCookies(response)
.map(parseSetCookie)
.filter((cookie) => cookie.name && cookie.value)
if (!cookies.length) {
throw new Error(`Modern API login did not return a session cookie from ${loginUrl}`)
}
return normalizeLoginCookies(cookies, userLoginId)
}
function parseJson(text) {
try {
return JSON.parse(text)
} catch {
return null
}
}
function parseSetCookie(header) {
const parts = String(header || '').split(';').map((part) => part.trim()).filter(Boolean)
const [nameValue, ...attributes] = parts
const equals = nameValue.indexOf('=')
if (equals === -1) return {}
const cookie = {
name: nameValue.slice(0, equals),
value: nameValue.slice(equals + 1),
domain: apiBaseUrlParts.hostname,
path: '/api'
}
for (const attribute of attributes) {
const [rawKey, ...rawValueParts] = attribute.split('=')
const rawValue = rawValueParts.join('=')
const key = rawKey.toLowerCase()
if (key === 'path' && rawValue) cookie.path = rawValue
if (key === 'domain' && rawValue) cookie.domain = rawValue.replace(/^\./, '')
if (key === 'secure') cookie.secure = true
if (key === 'httponly') cookie.httpOnly = true
}
return cookie
}
function normalizeLoginCookies(cookies, userLoginId) {
const normalized = cookies.map(normalizeCookieForRuntime)
const securedLoginToken = normalized.find((cookie) => cookie.name === 'securedLoginToken')
if (securedLoginToken && !hasCookieForUrl(normalized, securedLoginToken.name, `${modernBaseUrl}/`)) {
normalized.push(normalizeCookieForRuntime({
...securedLoginToken,
domain: modernBaseUrlParts.hostname,
path: '/'
}))
}
if (!normalized.some((cookie) => cookie.name === modernSecuredLoginIdCookieName && pathMatches(modernWebappContextPath, cookie.path || '/'))) {
normalized.push(normalizeCookieForRuntime({
name: modernSecuredLoginIdCookieName,
value: userLoginId,
domain: modernBaseUrlParts.hostname,
path: modernWebappContextPath,
httpOnly: true,
secure: true
}))
}
return normalized
}
function normalizeCookieForRuntime(cookie) {
const normalized = {
...cookie,
domain: cookie.domain || apiBaseUrlParts.hostname,
path: cookie.path || '/'
}
if (normalized.secure && isPlainHttpLocalRuntime()) {
normalized.secure = false
}
return normalized
}
function isPlainHttpLocalRuntime() {
return (modernBaseUrlParts.protocol === 'http:' || apiBaseUrlParts.protocol === 'http:')
&& [modernBaseUrlParts.hostname, apiBaseUrlParts.hostname].every(isLoopbackHost)
}
function isLoopbackHost(hostname) {
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(String(hostname || '').toLowerCase())
}
function hasCookieForUrl(cookies, name, url) {
return cookies.some((cookie) => cookie.name === name && cookieAppliesToUrl(cookie, url))
}
function cookieAppliesToUrl(cookie, url) {
const target = new URL(url)
return domainMatches(target.hostname, cookie.domain || target.hostname)
&& pathMatches(target.pathname || '/', cookie.path || '/')
&& (!cookie.secure || target.protocol === 'https:')
}
function domainMatches(hostname, domain) {
const normalizedHost = String(hostname || '').toLowerCase()
const normalizedDomain = String(domain || '').replace(/^\./, '').toLowerCase()
return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`)
}
function pathMatches(pathname, cookiePath) {
const normalizedPathname = String(pathname || '/')
const normalizedCookiePath = String(cookiePath || '/')
return normalizedPathname === normalizedCookiePath
|| normalizedPathname.startsWith(normalizedCookiePath.endsWith('/') ? normalizedCookiePath : `${normalizedCookiePath}/`)
}
async function setRuntimeCookie(client, cookie) {
const result = await client.command('Network.setCookie', cookie)
if (result?.success === false) {
throw new Error(`Chrome rejected parity runtime cookie ${cookie.name} for domain=${cookie.domain || '-'} path=${cookie.path || '-'}`)
}
}
async function closeModernUiRuntime(runtime) {
if (!runtime?.ok) return
runtime.client.close()
await closeChrome(runtime.session)
}
async function verifyModernUiRuntime(page, runtime) {
const path = `${modernBaseUrl}/#/pages/${page.pageId}`
if (!runtime?.ok) {
return {
status: 'failed',
error: runtime?.error || 'Chrome/CDP runtime is not available for modern UI render smoke',
renderMode: 'chrome-cdp',
path
}
}
const { client } = runtime
try {
await evaluate(client, `window.__modernRuntimeConsoleErrors = []`).catch(() => undefined)
await client.command('Page.navigate', { url: path })
await waitForExpression(client, "document.querySelector('#app') && document.body.innerText.trim().length > 0", modernUiRenderTimeoutMs)
const signals = modernUiExpectedSignals(page)
await waitForExpression(
client,
runtimeSignalExpression(signals),
modernUiRenderTimeoutMs
)
const text = String(await evaluate(client, `document.body ? document.body.innerText : ''`))
const consoleErrors = await evaluate(client, `window.__modernRuntimeConsoleErrors || []`).catch(() => [])
const actionableErrors = actionableConsoleErrors(consoleErrors)
const missingSignals = missingRuntimeSignalGroups(text, signals)
return {
status: missingSignals.length || actionableErrors.length ? 'failed' : 'passed',
httpStatus: 200,
bytes: text.length,
textLength: text.length,
expectedSignals: flattenSignals(signals),
missingSignals: flattenSignals(missingSignals),
consoleErrors,
ignoredConsoleErrors: consoleErrors.length - actionableErrors.length,
renderMode: 'chrome-cdp',
path
}
} catch (error) {
return {
status: 'failed',
error: error.message,
diagnostics: await runtimeDiagnostics(client, page.pageId).catch(() => ''),
renderMode: 'chrome-cdp',
path: `${modernBaseUrl}/#/pages/${page.pageId}`
}
}
}
async function verifyRuntimeSmoke(pagesById, pages) {
const selectedPages = selectRuntimeSmokePages(pagesById, pages)
const results = []
const chrome = findChrome()
const modernRuntime = await openModernUiRuntime(chrome)
try {
for (const item of selectedPages) {
const page = item.page
const [legacy, modernApi, modernUi] = await Promise.all([
verifyLegacyRuntime(page),
verifyModernApiRuntime(page),
verifyModernUiRuntime(page, modernRuntime)
])
const contract = pageContractSmoke(page)
const status = runtimeSmokeStatus(legacy, modernApi, modernUi, contract)
results.push({
pageId: page.pageId,
title: page.title,
domain: page.domain,
risk: page.acceptance?.risk || 'low',
reason: item.reason,
scenarioFlow: page.acceptance?.e2eScenario?.flow || '',
status,
legacy,
modernApi,
modernUi,
contract
})
}
} finally {
await closeModernUiRuntime(modernRuntime)
}
return results
}
function countBy(items, keyFn) {
const counts = {}
for (const item of items) {
const key = keyFn(item) || 'Unknown'
counts[key] = (counts[key] || 0) + 1
}
return counts
}
function topEntries(counts, limit = 24) {
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
.slice(0, limit)
}
function gate(id, label, passed, actual, expected) {
return {
id,
label,
status: passed === null ? 'skipped' : passed ? 'passed' : 'failed',
actual,
expected
}
}
function markdown(report) {
const gateLines = report.gates
.map((item) => `| ${item.status.toUpperCase()} | ${item.id} | ${item.actual} | ${item.expected} | ${item.label} |`)
.join('\n')
const assetLines = report.assetGates
.map((item) => `| ${item.status.toUpperCase()} | ${item.id} | ${item.actual} | ${item.expected} | ${item.label} |`)
.join('\n')
const riskLines = report.highRiskQueue
.slice(0, 30)
.map((page) => `| ${page.pageId} | ${page.domain || '-'} | ${page.scenarioFlow || '-'} | ${page.pendingBusinessSteps} | ${page.legacyPath || '-'} |`)
.join('\n')
const unsupportedLines = report.unsupportedBlockTypes.length
? report.unsupportedBlockTypes.map((item) => `| ${item.name} | ${item.count} |`).join('\n')
: '| - | 0 |'
const runtimeLines = report.runtimeSmoke
.map((item) => `| ${item.status.toUpperCase()} | ${item.pageId} | ${item.reason} | ${item.domain || '-'} | ${item.scenarioFlow || '-'} | ${item.legacy.status} / ${item.legacy.httpStatus || '-'} | ${item.modernApi.status} / ${item.modernApi.httpStatus || '-'} | ${item.modernUi.status} / ${item.modernUi.httpStatus || '-'} | ${item.contract.formContracts}/${item.contract.formBlocks} | ${item.contract.tableContracts}/${item.contract.tableBlocks} |`)
.join('\n')
return `# Modern UI Admin Runtime Rendering and Parity Verification
Generated: ${report.generatedAt}
## Summary
Structural route/action coverage is complete only when missingRoutes=0 and missingActions=0. Full OFBiz business parity is not complete while pendingE2ePages or highRiskPendingPages is greater than 0.
Final gate: ${report.summary.finalGate}. Frontend rewrite coverage gate: ${report.summary.frontendRewriteCoverageGate}. Business depth parity gate: ${report.summary.businessDepthParityGate}. Report JSON: ${report.summary.reportPath}.
\`\`\`json
${JSON.stringify(report.summary, null, 2)}
\`\`\`
## Gates
| Status | Gate | Actual | Expected | Label |
| --- | --- | ---: | ---: | --- |
${gateLines}
## Split Assets
| Status | Gate | Actual | Expected | Label |
| --- | --- | ---: | ---: | --- |
${assetLines}
## Unsupported Block Types
| Type | Count |
| --- | ---: |
${unsupportedLines}
## Runtime Smoke
| Status | Page ID | Reason | Domain | Flow | Legacy | Modern API | Modern UI | Form Contracts | Table Contracts |
| --- | --- | --- | --- | --- | --- | --- | --- | ---: | ---: |
${runtimeLines}
## High Risk Business E2E Queue
| Page ID | Domain | Flow | Pending Business Steps | Legacy URL |
| --- | --- | --- | ---: | --- |
${riskLines}
## Completion Boundary
This report separates frontend rewrite coverage from deep business parity. It proves frontend rewrite readiness for generated Element Plus admin/runtime rendering coverage when frontendRewriteGate is passed and missingRoutes/missingActions are zero. Runtime smoke proves representative old routes, Modern API PageDefinition delivery, modern SPA entry, and generated control contracts. It intentionally does not mark businessDepthParityGate as passed while pendingBusinessE2ePages is greater than 0, and it must not be cited as 100% full OFBiz business parity while fullBusinessParityVerified is false.
`
}
async function main() {
const inventory = await readJson(inventoryFile)
const pages = Object.values(inventory.pageDefinitions || {})
const pagesById = indexBy(pages, 'pageId')
const routeManifest = inventory.routeManifest || []
const actionDefinitions = inventory.actionDefinitions || {}
const routeByPageId = indexBy(routeManifest, 'pageId')
const checks = pages.map((page) => pageChecks(page, routeByPageId.get(page.pageId), actionDefinitions))
const unsupportedBlockTypes = topEntries(countBy(
pages.flatMap((page) => (page.blocks || []).map((block) => block.type).filter((type) => !isRendererSupported(type))),
(type) => type
), 50)
const highRiskQueue = checks
.filter((page) => page.risk === 'high' && !page.businessParityVerified)
.sort((a, b) => b.pendingBusinessSteps - a.pendingBusinessSteps || a.pageId.localeCompare(b.pageId))
const publicAssetGates = await verifySplitAssets(inventory, publicInventoryFile, publicPagesDir, 'public')
const builtAssetGates = await verifySplitAssets(inventory, builtInventoryFile, builtPagesDir, 'built')
const runtimeSmoke = await verifyRuntimeSmoke(pagesById, pages)
const runtimeSmokePassed = runtimeSmoke.filter((item) => item.status === 'passed').length
const runtimeSmokePassedWithLegacyUnavailable = runtimeSmoke.filter((item) => item.status === 'passed-with-legacy-unavailable').length
const runtimeSmokeFailed = runtimeSmoke.filter((item) => item.status === 'failed').length
const runtimeSmokeLegacyUnavailable = runtimeSmoke.filter((item) => item.legacy.status === 'legacy-unavailable').length
const runtimeSmokeDomains = Object.keys(countBy(runtimeSmoke, (item) => item.domain)).length
const runtimeSmokeFlows = Object.keys(countBy(runtimeSmoke, (item) => item.scenarioFlow)).length
const runtimeSmokeHighRisk = runtimeSmoke.filter((item) => item.risk === 'high').length
const javaRuntimeAvailable = await exists(path.join(javaHome, 'bin/java'))
const missingRoutes = inventory.coverage?.missingRoutes || 0
const missingActions = inventory.coverage?.missingActions || 0
const inventoryPendingE2ePages = inventory.coverage?.pendingE2ePages
const inventoryHighRiskPendingPages = inventory.coverage?.highRiskParityPages
const pendingBusinessE2ePages = checks.filter((page) => !page.businessParityVerified).length
const frontendReadyPages = checks.filter((page) => page.frontendRewriteReady).length
const rendererSupportedPages = checks.filter((page) => page.rendererSupported).length
const actionReadyPages = checks.filter((page) => page.actionContractsReady).length
const scenarioReadyPages = checks.filter((page) => page.scenarioReady).length
const gates = [
gate('route-coverage', 'Every legacy route has a generated PageDefinition', missingRoutes === 0, missingRoutes, 0),
gate('action-coverage', 'Every controller/service action has an ActionDefinition', missingActions === 0, missingActions, 0),
gate('frontend-rewrite-coverage', 'Every generated page is marked Element Plus runtime rendering ready', frontendReadyPages === pages.length, frontendReadyPages, pages.length),
gate('renderer-support', 'Every generated block type is handled by ErpPageRenderer or ErpAdapterBlock', rendererSupportedPages === pages.length, rendererSupportedPages, pages.length),
gate('action-contracts', 'Every page action has an API contract mapping', actionReadyPages === pages.length, actionReadyPages, pages.length),
gate('scenario-readiness', 'Every page has old-vs-new parity scenario metadata', scenarioReadyPages === pages.length, scenarioReadyPages, pages.length),
gate(
'representative-runtime-smoke',
runtimeSmokeIsTargeted
? 'Targeted smoke run is useful for debugging but is not representative acceptance evidence'
: 'Representative old URLs, Modern API pages, modern SPA routes, and contracts are reachable or legacy-only 404 is classified separately',
runtimeSmokeIsTargeted ? null : runtimeSmoke.length > 0 && runtimeSmokeFailed === 0,
runtimeSmokePassed + runtimeSmokePassedWithLegacyUnavailable,
runtimeSmokeIsTargeted ? 'representative sample' : runtimeSmoke.length
),
gate('java-runtime', 'Local JDK is available for OFBiz/API smoke testing', javaRuntimeAvailable, javaRuntimeAvailable ? 1 : 0, 1),
gate(
'pending-e2e-summary-integrity',
'Inventory pendingE2ePages matches computed unverified business parity pages',
inventoryPendingE2ePages == null || inventoryPendingE2ePages === pendingBusinessE2ePages,
inventoryPendingE2ePages ?? pendingBusinessE2ePages,
pendingBusinessE2ePages
),
gate(
'high-risk-pending-summary-integrity',
'Inventory highRiskParityPages matches computed high risk pending business parity pages',
inventoryHighRiskPendingPages == null || inventoryHighRiskPendingPages === highRiskQueue.length,
inventoryHighRiskPendingPages ?? highRiskQueue.length,
highRiskQueue.length
),
gate('business-e2e-parity', 'Every page has verified old-vs-new business E2E', pendingBusinessE2ePages === 0, pendingBusinessE2ePages, 0)
]
const assetGates = publicAssetGates.concat(builtAssetGates)
const blockingGates = gates.filter((item) => item.id !== 'business-e2e-parity' && item.status !== 'skipped')
const frontendRewriteGate = blockingGates.concat(assetGates).every((item) => item.status === 'passed')
const businessParityGate = pendingBusinessE2ePages === 0
const report = {
generatedAt: new Date().toISOString(),
summary: {
status: frontendRewriteGate && businessParityGate
? 'complete'
: frontendRewriteGate
? 'frontend-rewrite-passed-business-e2e-pending'
: 'failed',
frontendRewriteGate: frontendRewriteGate ? 'passed' : 'failed',
businessParityGate: businessParityGate ? 'passed' : 'pending',
frontendRewriteCoverageGate: frontendRewriteGate ? 'passed' : 'failed',
businessDepthParityGate: businessParityGate ? 'passed' : 'pending',
finalGate: frontendRewriteGate && businessParityGate
? 'full-business-parity-complete'
: frontendRewriteGate
? 'frontend-coverage-passed-business-depth-parity-pending'
: 'frontend-coverage-failed',
totalPages: pages.length,
legacyViewRoutes: routeManifest.length,
actionDefinitions: Object.keys(actionDefinitions).length,
missingRoutes,
missingActions,
frontendReadyPages,
rendererSupportedPages,
actionContractReadyPages: actionReadyPages,
scenarioReadyPages,
pendingE2ePages: pendingBusinessE2ePages,
pendingBusinessE2ePages,
highRiskPendingPages: highRiskQueue.length,
fullBusinessParityVerified: businessParityGate,
fullCompletionClaimAllowed: frontendRewriteGate && businessParityGate,
canClaimFrontendRewriteCoverageComplete: frontendRewriteGate,
canClaimFullBusinessParityComplete: frontendRewriteGate && businessParityGate,
mustNotClaim100PercentBusinessParity: !businessParityGate,
completionBoundary: frontendRewriteGate && businessParityGate
? 'full-ofbiz-business-parity-complete'
: frontendRewriteGate
? 'structural-route-action-coverage-complete-business-e2e-pending'
: 'frontend-rewrite-or-structural-coverage-failed',
completionBlockers: [
pendingBusinessE2ePages > 0 ? 'pendingE2ePages' : '',
highRiskQueue.length > 0 ? 'highRiskPendingPages' : ''
].filter(Boolean),
runtimeSmokePages: runtimeSmoke.length,
runtimeSmokePassed,
runtimeSmokePassedWithLegacyUnavailable,
runtimeSmokeFailed,
runtimeSmokeLegacyUnavailable,
runtimeSmokeDomains,
runtimeSmokeFlows,
runtimeSmokeHighRisk,
runtimeSmokeMode,
runtimeSmokeTargeted: runtimeSmokeIsTargeted,
runtimeSmokeRepresentative: !runtimeSmokeIsTargeted,
runtimeSmokePageIds,
runtimeSmokeLimit: runtimeSmokeMode === 'all' ? pages.length : runtimeSmokeLimit,
unsupportedBlockTypeCount: unsupportedBlockTypes.reduce((sum, item) => sum + item.count, 0),
javaRuntimeAvailable,
javaHome,
reportPath: outJson,
markdownReportPath: outMd
},
gates,
assetGates,
unsupportedBlockTypes,
byRisk: countBy(checks, (page) => page.risk),
byDomain: topEntries(countBy(checks, (page) => page.domain), 40),
byScenarioFlow: topEntries(countBy(checks, (page) => page.scenarioFlow), 40),
runtimeSmoke,
highRiskQueue,
pageChecks: checks
}
await writeFile(outJson, `${JSON.stringify(report, null, 2)}\n`)
await writeFile(outMd, markdown(report))
console.log(JSON.stringify(report.summary, null, 2))
if (!frontendRewriteGate) {
process.exitCode = 1
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})