Files
ERP/ofbiz-framework/plugins/modern-ui/app/scripts/verify-preview.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

1850 lines
71 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 { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { setTimeout as delay } from 'node:timers/promises'
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, 'preview-verification.json')
const outMd = path.join(outDir, 'preview-verification.md')
const controlFilterFile = path.join(repoRoot, 'framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/ControlFilter.java')
const controlFilterTestFile = path.join(repoRoot, 'framework/webapp/src/test/java/org/apache/ofbiz/webapp/control/ControlFilterTests.java')
const requestHandlerPropertiesFile = path.join(repoRoot, 'framework/webapp/config/requestHandler.properties')
const baseUrl = process.env.MODERN_UI_BASE_URL || 'http://127.0.0.1:8080/modern/app/'
const cdpCommandTimeoutMs = Number(process.env.MODERN_UI_CDP_TIMEOUT_MS || 10000)
const baseUrlTimeoutMs = Number(process.env.MODERN_UI_BASE_URL_TIMEOUT_MS || 8000)
const configuredOverallTimeoutMs = Number(process.env.MODERN_UI_VERIFY_TIMEOUT_MS || 0)
const chromeStartupTimeoutMs = Number(process.env.MODERN_UI_CHROME_STARTUP_TIMEOUT_MS || 15000)
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)
const dynamicCases = [
{
id: 'login-door',
path: '#/login',
expected: ['登录后进入完整 ERP 后台', '使用 OFBiz 账号继续', '登录 ERP 后台']
},
{
id: 'dashboard',
path: '#/',
expected: ['ERP 管理员工作台', '今日运营', '待办队列', '最近记录', '常用动作', '系统健康与权限', '经营数据', '快速检索']
},
{
id: 'order-workspace',
path: '#/module/order',
expected: ['订单工作台', '业务处理中枢', '业务队列', '交接记录', '执行动作', '处理事项']
},
{
id: 'accounting-workspace',
path: '#/module/accounting',
expected: ['财务工作台', '业务处理中枢', '手工交易', '运行状态']
},
{
id: 'manufacturing-workspace',
path: '#/module/manufacturing',
expected: ['生产工作台', '业务处理中枢', '生产运行', 'MRP 执行']
},
{
id: 'humanres-workspace',
path: '#/module/humanres',
expected: ['人事工作台', '业务处理中枢', '员工查询', '岗位查询']
},
{
id: 'commerce-workspace',
path: '#/module/commerce',
expected: ['电商工作台', '业务处理中枢', '购物车', '订单历史']
},
{
id: 'pos-workspace',
path: '#/module/pos',
expected: ['POS 工作台', '业务处理中枢', '收款', '经理面板']
},
{
id: 'system-admin-workspace',
path: '#/module/system-admin',
expected: ['系统运维工作台', '业务处理中枢', '运行任务', '运行日志', '执行动作']
},
{
id: 'extensions-workspace',
path: '#/extensions',
expected: ['扩展应用管理台', '扩展记录', '价目表导入', '支付网关']
},
{
id: 'system-tools',
path: '#/system',
expected: ['系统维护', '安全与会话', '运行任务', '运行日志', '缓存维护', '导入导出', '权限与业务域']
},
{
id: 'business-center',
path: '#/business',
expected: ['统一业务中心', '业务受理台', '今日承接', '优先处理', '异常队列', '续办记录', '快捷处理', '进入处理']
},
{
id: 'business-center-search',
path: '#/business?query=invoice',
expected: ['统一业务中心', '当前办理口径', 'invoice', '进入处理']
},
{
id: 'page-scenario',
path: '#/pages/accounting__ManualTransaction',
expected: ['手工交易', '处理台', '资料明细', '处理动作', '操作记录', '财务处理', '释放前检查']
},
{
id: 'order-findorders-page',
path: '#/pages/order__findorders',
expected: ['订单查询', '订单运营', '订单队列', '订单列表', '批量处理']
},
{
id: 'order-findreturn-page',
path: '#/pages/order__findreturn',
expected: ['订单退货查询', '退货处理', '退货行', '退款/换货', '接收退货']
},
{
id: 'finance-invoices-page',
path: '#/pages/accounting__findInvoices',
expected: ['发票查询', '财务运营', '财务运营台', '发票队列', '发票列表', '收付款匹配', '账龄风险', '核销处理']
},
{
id: 'finance-payment-overview-page',
path: '#/pages/accounting__paymentOverview',
expected: ['财务付款', '财务运营', '财务运营台', '发票队列', '发票查询', '发票列表', '收付款匹配', '核销处理']
},
{
id: 'pos-business-page',
path: '#/pages/webpos__ShowCart',
expected: ['购物车', '收银工作台', '销售行', '支付授权', '本单合计']
},
{
id: 'pos-payment-page',
path: '#/pages/webpos__Payment',
expected: ['POS付款', '收银工作台', '支付授权', '收款', '经理授权']
},
{
id: 'commerce-cart-page',
path: '#/pages/ecommerce__showcart',
expected: ['购物车', '电商前台', '购物车合计', '进入结账', '促销已应用']
},
{
id: 'catalog-product-page',
path: '#/pages/catalog__EditProduct',
expected: ['商品资料', '商品管理', '商品维护', '目录结构', '同步价格']
},
{
id: 'marketing-report-page',
path: '#/pages/marketing__MarketingCampaignReport',
expected: ['营销活动报表', '营销管理', '营销漏斗', '追踪码', '归因口径']
},
{
id: 'report-export-page',
path: '#/pages/accounting__PrintChecks',
expected: ['打印支票', '财务报表', '财务处理', '报表参数', '输出核对', '导出']
},
{
id: 'manufacturing-plan-page',
path: '#/pages/manufacturing__MRPPRunsComponentsByFeature',
expected: ['MRP 组件需求分析', '生产管理', 'MRP 计划', '释放前模拟', '物料缺口']
},
{
id: 'humanres-employee-page',
path: '#/pages/humanres__FindEmployee',
expected: ['员工查询', '人事管理', '员工档案', '雇佣关系', '岗位编制']
},
{
id: 'humanres-new-employee-page',
path: '#/pages/humanres__NewEmployee',
expected: ['新建员工', '人事管理', '待入职', '人事队列', '人事排程']
},
{
id: 'workeffort-tasks-page',
path: '#/pages/workeffort__mytasks',
expected: ['我的任务', '项目任务', '任务队列', '排程流转', '执行链路']
},
{
id: 'project-manager-page',
path: '#/pages/projectmgr__FindProject',
expected: ['项目查询', '项目任务', '项目', '工时', '排程流转']
},
{
id: 'scrum-sprint-task-page',
path: '#/pages/scrum__SprintTask',
expected: ['任务板', '处理台', '资料明细', '处理动作', '操作记录']
},
{
id: 'party-communication-page',
path: '#/pages/party__MyCommunicationEvents',
expected: ['客户沟通记录', '客户与组织', '客户沟通', '发送前检查', '沟通记录']
},
{
id: 'party-finder-page',
path: '#/pages/party__findparty',
expected: ['客户查询', '客户与组织', '客户沟通', '关系/联系人', '请求转化', '发送前检查']
},
{
id: 'party-new-customer-page',
path: '#/pages/party__NewCustomer',
expected: ['新建客户', '客户与组织', '客户沟通', '关系/联系人', '请求转化', '发送前检查']
},
{
id: 'fulfillment-page',
path: '#/pages/facility__PackOrder',
expected: ['打包发运', '履约发运', '发运包裹', '库存联动', '确认发运']
},
{
id: 'facility-finder-page',
path: '#/pages/facility__FindFacility',
expected: ['库存设施查询', '库存管理', '库存项', '设施库位', '库存流转']
},
{
id: 'inventory-receiving-page',
path: '#/pages/facility__ReceiveInventoryAgainstPurchaseOrder',
expected: ['采购到货接收', '处理台', '资料明细', '处理动作', '操作记录']
},
{
id: 'inventory-status-page',
path: '#/pages/facility__UpdatedInventoryItemStatus',
expected: ['库存维护', '库存管理', '盘点调整', '库存控制']
},
{
id: 'return-page',
path: '#/pages/order__returnitems',
expected: ['退货处理', '退货行', '退款/换货', '接收退货']
},
{
id: 'upload-page',
path: '#/pages/content__UploadImage',
expected: ['上传图片', '处理台', '资料明细', '处理动作', '操作记录']
},
{
id: 'entity-data-source',
path: '#/pages/accounting__EditPartyGlAccount',
expected: ['财务客户维护', 'PartyGlAccount', '处理台', '资料明细', '处理动作', '操作记录']
},
{
id: 'legacy-query-context',
path: '#/pages/accounting__EditBillingAccount?description=LegacyBilling&partyId=DemoCustomer',
expected: ['业务参数已带入当前单据', '说明=LegacyBilling', '客户编号=DemoCustomer']
}
]
const pageDefinitionCases = [
{
id: 'finance-high-risk',
pageId: 'accounting__ManualTransaction',
expectedAdapters: ['finance-workspace'],
expectedRequirements: ['legacy-template-parity', 'finance-state'],
expectedFlow: 'finance-posting'
},
{
id: 'webpos-cart',
pageId: 'webpos__ShowCart',
expectedAdapters: ['pos-workspace'],
expectedRequirements: ['pos-cart', 'payment'],
expectedFlow: 'pos-payment'
},
{
id: 'ecommerce-cart',
pageId: 'ecommerce__showcart',
expectedAdapters: ['commerce-surface'],
expectedRequirements: ['cart', 'catalog-browse'],
expectedFlow: 'order-checkout'
},
{
id: 'catalog-product-workflow',
pageId: 'catalog__EditProduct',
expectedAdapters: ['catalog-workspace'],
expectedRequirements: ['catalog-workflow'],
expectedFlow: 'catalog-management'
},
{
id: 'marketing-analytics',
pageId: 'marketing__MarketingCampaignReport',
expectedAdapters: ['marketing-workspace'],
expectedRequirements: ['marketing-analytics', 'report-preview'],
expectedFlow: 'marketing-analytics'
},
{
id: 'report-export',
pageId: 'accounting__PrintChecks',
expectedAdapters: ['report', 'finance-workspace'],
expectedRequirements: ['report-preview', 'export', 'finance-state'],
expectedFlow: 'finance-posting'
},
{
id: 'manufacturing-plan',
pageId: 'manufacturing__MRPPRunsComponentsByFeature',
expectedAdapters: ['report', 'manufacturing-workspace'],
expectedRequirements: ['report-preview', 'export', 'manufacturing-plan'],
expectedFlow: 'manufacturing-plan'
},
{
id: 'humanres-employee-search',
pageId: 'humanres__FindEmployee',
expectedAdapters: ['search-workspace'],
expectedRequirements: ['lookup', 'actions'],
expectedFlow: 'search-list'
},
{
id: 'party-communication',
pageId: 'party__MyCommunicationEvents',
expectedAdapters: ['communication-workspace'],
expectedRequirements: ['party-context', 'upload'],
expectedFlow: 'party-communication'
},
{
id: 'party-finder',
pageId: 'party__findparty',
expectedAdapters: ['client-behavior', 'communication-workspace'],
expectedRequirements: ['party-context'],
expectedFlow: 'party-communication'
},
{
id: 'fulfillment',
pageId: 'facility__PackOrder',
expectedAdapters: ['shipment-workspace'],
expectedRequirements: ['shipment-flow'],
expectedFlow: 'fulfillment'
},
{
id: 'inventory-control',
pageId: 'facility__FindFacility',
expectedAdapters: ['inventory-workspace'],
expectedRequirements: ['inventory-flow'],
expectedFlow: 'inventory-control'
},
{
id: 'inventory-receiving',
pageId: 'facility__ReceiveInventoryAgainstPurchaseOrder',
expectedAdapters: ['inventory-workspace'],
expectedRequirements: ['inventory-flow'],
expectedFlow: 'inventory-control'
},
{
id: 'inventory-status',
pageId: 'facility__UpdatedInventoryItemStatus',
expectedAdapters: ['inventory-workspace'],
expectedRequirements: ['inventory-flow'],
expectedFlow: 'inventory-control'
},
{
id: 'return-flow',
pageId: 'order__returnitems',
expectedAdapters: ['return-workspace'],
expectedRequirements: ['return-flow'],
expectedFlow: 'return-authorization'
},
{
id: 'upload-approval',
pageId: 'content__UploadImage',
expectedAdapters: [],
expectedRequirements: ['upload'],
expectedFlow: 'upload-approval'
}
]
const interactionCases = [
{
id: 'topbar-quick-action-manual-transaction',
label: 'Topbar quick action opens a current business processing surface',
path: '#/',
steps: [
{
action: 'click',
selector: '.erp-quick-actions .el-button',
waitFor: "document.body.innerText.includes('手工交易') && document.querySelector('[data-modern-quick-action-path=\"#/pages/accounting__ManualTransaction\"]')"
},
{
action: 'click',
selector: '[data-modern-quick-action-path="#/pages/accounting__ManualTransaction"]',
waitFor: "window.location.hash.includes('/pages/accounting__ManualTransaction') && document.querySelector('[data-modern-page-id=\"accounting__ManualTransaction\"]') && document.querySelector('[data-modern-tab-panel=\"overview\"]') && document.querySelector('[data-modern-workspace=\"finance\"]') && document.body.innerText.includes('手工交易') && document.body.innerText.includes('处理台')",
timeoutMs: 8000
}
]
},
{
id: 'topbar-alerts-navigate',
label: 'Topbar work entries open actionable ERP modules',
path: '#/',
steps: [
{
action: 'click',
selector: '.erp-alert-button',
waitFor: "document.body.innerText.includes('待审核订单') && document.querySelector('[data-modern-alert-path=\"#/orders\"]')"
},
{
action: 'click',
selector: '[data-modern-alert-path="#/orders"]',
waitFor: "window.location.hash === '#/orders' && (document.body.innerText.includes('订单运营台') || document.body.innerText.includes('订单工作台')) && document.body.innerText.includes('订单执行台') && document.body.innerText.includes('订单队列')",
timeoutMs: 8000
}
]
},
{
id: 'page-tabs',
label: 'Business page tabs switch between records, actions, and operation log surfaces',
path: '#/pages/accounting__ManualTransaction',
pageId: 'accounting__ManualTransaction',
steps: [
{
action: 'click',
selector: '.modern-tabs .el-tabs__item[aria-controls="pane-records"]',
waitFor: "document.querySelector('.modern-tabs .el-tabs__item[aria-controls=\"pane-records\"]')?.classList.contains('is-active') && (document.querySelector('[data-modern-tab-panel=\"records\"]')?.innerText || '').length > 20"
},
{
action: 'click',
selector: '.modern-tabs .el-tabs__item[aria-controls="pane-actions"]',
waitFor: "document.querySelector('.modern-tabs .el-tabs__item[aria-controls=\"pane-actions\"]')?.classList.contains('is-active') && (document.querySelector('[data-modern-tab-panel=\"actions\"]')?.innerText || '').length > 20"
},
{
action: 'click',
selector: '.modern-tabs .el-tabs__item[aria-controls="pane-flow"]',
waitFor: "document.querySelector('.modern-tabs .el-tabs__item[aria-controls=\"pane-flow\"]')?.classList.contains('is-active') && (document.querySelector('[data-modern-tab-panel=\"flow\"]')?.innerText || '').length > 20"
}
]
},
{
id: 'table-drawer',
label: 'Generated business table opens the row detail drawer',
path: '#/pages/accounting__ManualTransaction',
pageId: 'accounting__ManualTransaction',
steps: [
{
action: 'click',
selector: '[data-modern-workspace="finance"] [data-modern-table-action="detail"]',
waitFor: "document.querySelector('[data-modern=\"erp-data-drawer\"]') && document.body.innerText.includes('记录详情')"
}
]
},
{
id: 'entity-table-pagination-metadata',
label: 'Entity table pagination uses API metadata or honest fallback counts',
path: '#/pages/accounting__EditPartyGlAccount',
pageId: 'accounting__EditPartyGlAccount',
steps: [
{
action: 'assert-expression',
selector: '[data-modern-table-total="true"]',
waitFor: "(() => { const total = document.querySelector('[data-modern-table-total=\"true\"]')?.textContent || ''; return document.querySelector('[data-modern-table-pagination=\"true\"]') && (total.includes('已显示') || total.includes('当前页')) && total.includes(' / ') && total.includes('条') && !total.includes('128'); })()"
}
]
},
{
id: 'form-submit',
label: 'Generated form accepts and preserves operator input',
path: '#/pages/accounting__PrintChecks',
pageId: 'accounting__PrintChecks',
steps: [
{
action: 'fill-first-input',
selector: '[data-modern=\"erp-search-form\"] input:not([readonly]), [data-modern=\"erp-search-form\"] textarea:not([readonly])',
value: 'VERIFY-10021',
waitFor: "document.querySelector('[data-modern=\"erp-search-form\"]') && Array.from(document.querySelectorAll('[data-modern=\"erp-search-form\"] input, [data-modern=\"erp-search-form\"] textarea')).some((input) => input.value.includes('VERIFY-10021'))"
}
]
},
{
id: 'service-form-submit',
label: 'Dedicated extension workspace exposes service actions through the Action API bridge',
path: '#/pages/example__EditExample',
pageId: 'example__EditExample',
steps: [
{
action: 'click',
selector: '[data-modern-action-id=\"example__createExample\"]',
waitFor: "document.querySelector('[data-modern-extension-operations-page=\"true\"]') && (window.__modernActionRequests || []).some((request) => request.url.includes('/api/v1/actions/example__createExample'))",
expectActionRequest: 'example__createExample',
timeoutMs: 8000
}
]
},
{
id: 'event-form-submit',
label: 'Dedicated system workspace exposes web event actions through the Action API bridge',
path: '#/pages/webtools__FindUtilCache',
pageId: 'webtools__FindUtilCache',
steps: [
{
action: 'click',
selector: '[data-modern-action-id=\"webtools__FindUtilCacheClear\"]',
waitFor: "document.querySelector('[data-modern-system-admin-page=\"true\"]') && (window.__modernActionRequests || []).some((request) => request.url.includes('/api/v1/actions/webtools__FindUtilCacheClear'))",
expectActionRequest: 'webtools__FindUtilCacheClear',
timeoutMs: 8000
}
]
},
{
id: 'navigation-form-submit',
label: 'Dedicated work management workspace exposes controller navigation actions through the Action API bridge',
path: '#/pages/workeffort__LookupWorkEffort',
pageId: 'workeffort__LookupWorkEffort',
steps: [
{
action: 'click',
selector: '[data-modern-action-id=\"workeffort__LookupWorkEffort\"]',
waitFor: "window.location.hash.includes('/pages/workeffort__LookupWorkEffort') && document.querySelector('[data-modern-page-id=\"workeffort__LookupWorkEffort\"]') && (window.__modernActionRequests || []).some((request) => request.url.includes('/api/v1/actions/workeffort__LookupWorkEffort'))",
expectActionRequest: 'workeffort__LookupWorkEffort',
timeoutMs: 8000
}
]
},
{
id: 'action-contract-fallback',
label: 'Mapped page action reports action bridge feedback without a running backend',
path: '#/pages/accounting__ManualTransaction',
pageId: 'accounting__ManualTransaction',
steps: [
{
action: 'click',
selector: '[data-modern-action-id="accounting__manualETx"]',
waitFor: "document.querySelector('[data-modern-workspace=\"finance\"]') && (document.body.innerText.includes('暂不可执行') || document.body.innerText.includes('需要登录 OFBiz') || document.querySelector('.el-message'))",
expectActionRequest: 'accounting__manualETx',
timeoutMs: 8000
}
]
},
{
id: 'pos-payment-method',
label: 'POS workspace exposes payment method, amount, and terminal actions',
path: '#/pages/webpos__ShowCart',
pageId: 'webpos__ShowCart',
steps: [
{
action: 'assert-expression',
waitFor: "(document.querySelector('[data-modern-pos-page=\"true\"]') || document.querySelector('[data-modern-workspace=\"pos\"]')) && document.body.innerText.includes('银行卡') && document.body.innerText.includes('挂单') && document.body.innerText.includes('支付授权')"
}
]
},
{
id: 'finance-release-checks',
label: 'Finance workspace exposes balanced entries and release checks',
path: '#/pages/accounting__ManualTransaction',
pageId: 'accounting__ManualTransaction',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-workspace=\"finance\"]') && document.body.innerText.includes('借贷平衡') && document.body.innerText.includes('释放前检查') && document.body.innerText.includes('打印凭证')"
}
]
},
{
id: 'commerce-checkout-surface',
label: 'Ecommerce cart surface exposes cart summary and checkout actions',
path: '#/pages/ecommerce__showcart',
pageId: 'ecommerce__showcart',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-commerce-page=\"true\"]') && document.querySelector('[data-modern-commerce-surface=\"cart\"]') && document.body.innerText.includes('购物车合计') && document.body.innerText.includes('进入结账') && document.body.innerText.includes('保存购物车')"
}
]
},
{
id: 'catalog-product-surface',
label: 'Catalog workspace exposes products, prices, promotions, and category context',
path: '#/pages/catalog__EditProduct',
pageId: 'catalog__EditProduct',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-catalog-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"catalog\"]') && document.body.innerText.includes('商品维护') && document.body.innerText.includes('目录结构') && document.body.innerText.includes('促销')"
}
]
},
{
id: 'marketing-analytics-surface',
label: 'Marketing workspace exposes campaign funnel, tracking attribution, and contact lists',
path: '#/pages/marketing__MarketingCampaignReport',
pageId: 'marketing__MarketingCampaignReport',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-marketing-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"marketing\"]') && document.body.innerText.includes('营销漏斗') && document.body.innerText.includes('追踪码') && document.body.innerText.includes('联系名单')"
}
]
},
{
id: 'report-export-surface',
label: 'Report workspace exposes parameters, output review, export, and print surfaces',
path: '#/pages/accounting__PrintChecks',
pageId: 'accounting__PrintChecks',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-report-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"report\"]') && document.body.innerText.includes('报表参数') && document.body.innerText.includes('输出核对') && document.body.innerText.includes('打印')"
}
]
},
{
id: 'manufacturing-plan-surface',
label: 'Manufacturing workspace exposes MRP, BOM, run status, and release simulation',
path: '#/pages/manufacturing__MRPPRunsComponentsByFeature',
pageId: 'manufacturing__MRPPRunsComponentsByFeature',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-manufacturing-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"manufacturing\"]') && document.body.innerText.includes('MRP') && document.body.innerText.includes('BOM/工艺') && document.body.innerText.includes('释放前模拟')"
}
]
},
{
id: 'humanres-operations-surface',
label: 'HumanRes workspace exposes employee search, employment, positions, and HR queues',
path: '#/pages/humanres__FindEmployee',
pageId: 'humanres__FindEmployee',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-humanres-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"humanres\"]') && document.body.innerText.includes('员工查询') && document.body.innerText.includes('雇佣关系') && document.body.innerText.includes('岗位编制') && document.body.innerText.includes('人事队列')"
}
]
},
{
id: 'work-management-surface',
label: 'Work management workspace exposes tasks, projects, time entry, backlog, and planning flow',
path: '#/pages/workeffort__mytasks',
pageId: 'workeffort__mytasks',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-work-management-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"work-management\"]') && document.body.innerText.includes('任务队列') && document.body.innerText.includes('项目') && document.body.innerText.includes('工时') && document.body.innerText.includes('Backlog')"
}
]
},
{
id: 'party-communication-surface',
label: 'Party workspace exposes communication, relationship, and request conversion surfaces',
path: '#/pages/party__MyCommunicationEvents',
pageId: 'party__MyCommunicationEvents',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-party-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"party\"]') && document.body.innerText.includes('沟通记录') && document.body.innerText.includes('关系/联系人') && document.body.innerText.includes('请求转化')"
}
]
},
{
id: 'fulfillment-surface',
label: 'Fulfillment workspace exposes shipment packages, inventory, and confirmation actions',
path: '#/pages/facility__PackOrder',
pageId: 'facility__PackOrder',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-fulfillment-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"fulfillment\"]') && document.body.innerText.includes('履约进度') && document.body.innerText.includes('库存联动') && document.body.innerText.includes('打印标签')"
}
]
},
{
id: 'return-surface',
label: 'Return workspace exposes RMA, receiving, refund, and rejection actions',
path: '#/pages/order__returnitems',
pageId: 'order__returnitems',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-return-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"return\"]') && document.body.innerText.includes('退货行') && document.body.innerText.includes('退款/换货')"
}
]
},
{
id: 'inventory-surface',
label: 'Inventory workspace exposes stock items, facilities, receiving, and count controls',
path: '#/pages/facility__FindFacility',
pageId: 'facility__FindFacility',
steps: [
{
action: 'assert-expression',
waitFor: "document.querySelector('[data-modern-inventory-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"inventory\"]') && document.body.innerText.includes('库存项') && document.body.innerText.includes('设施库位') && document.body.innerText.includes('收货上架') && document.body.innerText.includes('库存流转')"
}
]
},
{
id: 'upload-surface',
label: 'Upload pages without a typed adapter get a generated upload workspace',
path: '#/pages/content__UploadImage',
pageId: 'content__UploadImage',
steps: [
{
action: 'assert-expression',
waitFor: "((document.querySelector('[data-modern-generated-upload=\"true\"]') && document.querySelector('[data-modern-workspace=\"media\"]')) || (document.querySelector('[data-modern-content-page=\"true\"]') && document.querySelector('[data-modern-workspace=\"content\"]'))) && document.body.innerText.includes('上传')"
}
]
},
{
id: 'legacy-query-prefill',
label: 'Legacy GET query parameters are preserved and surfaced in the business page state',
path: '#/pages/accounting__EditBillingAccount?description=LegacyBilling&partyId=DemoCustomer',
pageId: 'accounting__EditBillingAccount',
steps: [
{
action: 'assert-expression',
waitFor: "document.body.innerText.includes('业务参数已带入当前单据') && document.body.innerText.includes('说明=LegacyBilling') && document.body.innerText.includes('客户编号=DemoCustomer')"
}
]
}
]
const interactionStepCount = interactionCases.reduce((total, item) => total + item.steps.length, 0)
const defaultOverallTimeoutMs = Math.max(900000, 90000 + dynamicCases.length * 9000 + interactionStepCount * 16000)
const overallTimeoutMs = configuredOverallTimeoutMs > 0 ? configuredOverallTimeoutMs : defaultOverallTimeoutMs
function normalizeUrl(routePath) {
const cleanBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
return `${cleanBase}${routePath}`
}
function commandExists(command) {
if (!command || command.includes('/')) {
return command && existsSync(command)
}
return true
}
function findChrome() {
return chromeCandidates.find(commandExists) || ''
}
function logProgress(message) {
console.error(`[verify-admin-rendering] ${message}`)
}
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)
})
}
class CdpConnection {
constructor(wsUrl) {
this.wsUrl = wsUrl
this.nextId = 1
this.pending = new Map()
this.events = []
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')), 10000)
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) {
this.events.push(message)
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 = 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() {
try {
this.socket?.close()
} catch {
// Nothing to clean up.
}
}
}
async function waitForDevToolsPort(profile, 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) {
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`)
}
async function launchCdpChrome(chrome) {
const profile = await mkdtemp(path.join(os.tmpdir(), 'ofbiz-modern-cdp.'))
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',
'--hide-scrollbars',
'--remote-debugging-port=0',
'--window-size=1440,1100',
`--user-data-dir=${profile}`,
'about:blank'
]
const child = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
let earlyExit = null
child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
})
child.once('exit', (code, signal) => {
earlyExit = { code, signal }
})
try {
const port = await waitForDevToolsPort(profile, () => 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,
port,
wsUrl: pageTarget.webSocketDebuggerUrl,
stderr: () => stderr,
async close() {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGTERM')
}
const terminated = await waitForProcessExit(child, 1500)
if (!terminated && child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
await waitForProcessExit(child, 1500)
}
await rm(profile, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
}
}
} catch (error) {
child.kill('SIGTERM')
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)
}
}
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
}
async function waitForExpression(client, expression, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs
let lastError = null
while (Date.now() < deadline) {
try {
const value = await evaluate(client, `Boolean(${expression})`, 2000)
if (value) return true
} catch (error) {
lastError = error
}
await delay(150)
}
throw new Error(`Timed out waiting for expression: ${expression}${lastError ? ` (${lastError.message})` : ''}`)
}
async function clickSelector(client, selector) {
const encoded = JSON.stringify(selector)
await waitForExpression(client, `document.querySelector(${encoded})`)
await evaluate(client, `(() => {
const element = document.querySelector(${encoded});
if (!element) throw new Error('Missing selector ${selector}');
element.scrollIntoView({ block: 'center', inline: 'center' });
element.click();
return true;
})()`)
}
async function fillSelector(client, selector, value) {
const encoded = JSON.stringify(selector)
const encodedValue = JSON.stringify(value)
await waitForExpression(client, `document.querySelector(${encoded})`)
await evaluate(client, `(() => {
const element = document.querySelector(${encoded});
if (!element) throw new Error('Missing selector ${selector}');
element.scrollIntoView({ block: 'center', inline: 'center' });
element.focus();
element.value = ${encodedValue};
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return element.value;
})()`)
}
async function ensureOverviewTab(client) {
const hasOverviewTab = await evaluate(client, `Boolean(document.querySelector('.modern-tabs .el-tabs__item[aria-controls="pane-overview"]'))`)
.catch(() => false)
if (!hasOverviewTab) return
const overviewActive = await evaluate(client, `Boolean(document.querySelector('.modern-tabs .el-tabs__item[aria-controls="pane-overview"]')?.classList.contains('is-active'))`)
.catch(() => false)
if (!overviewActive) {
await clickSelector(client, '.modern-tabs .el-tabs__item[aria-controls="pane-overview"]')
}
await waitForExpression(
client,
`document.querySelector('[data-modern-tab-panel="overview"]') && document.querySelector('.modern-tabs .el-tabs__item[aria-controls="pane-overview"]')?.classList.contains('is-active')`,
5000
)
}
async function navigateCdp(client, routePath) {
const targetUrl = normalizeUrl(routePath)
const targetHash = routePath.startsWith('#') ? routePath : new URL(targetUrl).hash
const encodedTargetUrl = JSON.stringify(targetUrl)
const encodedTargetHash = JSON.stringify(targetHash)
await client.command('Page.navigate', { url: targetUrl })
try {
await waitForExpression(
client,
`window.location.href === ${encodedTargetUrl} || window.location.hash === ${encodedTargetHash}`,
5000
)
} catch {
await evaluate(client, `window.location.href = ${encodedTargetUrl}; true`, 5000)
await waitForExpression(
client,
`window.location.href === ${encodedTargetUrl} || window.location.hash === ${encodedTargetHash}`,
10000
)
}
await waitForExpression(client, "document.querySelector('#app') && document.body.innerText.trim().length > 0", 15000)
}
async function assertBaseUrlReady() {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), baseUrlTimeoutMs)
try {
const response = await fetch(baseUrl, {
method: 'GET',
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const text = await response.text()
if (!text.includes('<!doctype html') && !text.includes('<div id="app">')) {
throw new Error('Modern UI app shell was not returned')
}
return true
} catch (error) {
throw new Error(`Modern UI base URL is not ready: ${baseUrl} (${error.message})`)
} finally {
clearTimeout(timeout)
}
}
const mockSessionResult = {
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: 'OFBiz Security'
}
},
messages: [],
warnings: [],
traceId: 'modern-ui-verification-session'
}
function apiJsonBody(data, traceId) {
return Buffer.from(JSON.stringify({
ok: true,
data,
messages: [],
warnings: [],
traceId
}), 'utf8').toString('base64')
}
const mockNavigationApplications = [
{ id: 'order', title: 'Order Manager', component: 'order', mountPoint: '/ordermgr', layout: 'backoffice', permissions: ['OFBTOOLS', 'ORDERMGR'], allowed: true },
{ id: 'catalog', title: 'Catalog Manager', component: 'product', mountPoint: '/catalog', layout: 'backoffice', permissions: ['OFBTOOLS', 'CATALOG'], allowed: true },
{ id: 'accounting', title: 'Accounting', component: 'accounting', mountPoint: '/accounting', layout: 'backoffice', permissions: ['OFBTOOLS', 'ACCOUNTING'], allowed: true },
{ id: 'facility', title: 'Facility', component: 'facility', mountPoint: '/facility', layout: 'backoffice', permissions: ['OFBTOOLS', 'FACILITY'], allowed: true },
{ id: 'manufacturing', title: 'Manufacturing', component: 'manufacturing', mountPoint: '/manufacturing', layout: 'backoffice', permissions: ['OFBTOOLS', 'MANUFACTURING'], allowed: true },
{ id: 'humanres', title: 'Human Resources', component: 'humanres', mountPoint: '/humanres', layout: 'backoffice', permissions: ['OFBTOOLS', 'HUMANRES'], allowed: true },
{ id: 'webtools', title: 'Web Tools', component: 'webtools', mountPoint: '/webtools', layout: 'system', permissions: ['OFBTOOLS', 'WEBTOOLS'], allowed: true }
]
async function loadPreviewInventory() {
try {
return JSON.parse(await readFile(publicInventoryFile, 'utf8'))
} catch {
return {
generatedAt: '2026-06-09T00:00:00.000Z',
counts: {},
coverage: {
missingRoutes: 0,
missingActions: 0,
missingPageDefinitions: []
},
routeManifest: [],
pageDefinitions: {},
actionDefinitions: {}
}
}
}
function pathnameFromPausedUrl(url) {
try {
return new URL(url).pathname
} catch {
return ''
}
}
function pageIdFromApiPath(pathname) {
const match = pathname.match(/\/api\/v1\/pages\/([^/]+)/)
return match ? decodeURIComponent(match[1]) : ''
}
function entityNameFromApiPath(pathname, prefix) {
const match = pathname.match(new RegExp(`/api/v1/${prefix}/([^/]+)`))
return match ? decodeURIComponent(match[1]) : ''
}
function emptyEntityRows(entityName) {
return {
entityName,
rows: [],
fields: [],
page: 0,
pageSize: 20,
query: '',
total: 0,
hasMore: false
}
}
function emptyLookupRows(lookupId) {
return {
lookupId,
rows: [],
fields: [],
page: 0,
pageSize: 20,
query: '',
total: 0,
hasMore: false
}
}
function emptyOptionsResult(entityName) {
return {
entityName,
keyFieldName: '',
options: [],
pageSize: 40,
hasMore: false
}
}
async function installAuthenticatedApiMocks(client, inventory) {
const previewInventory = await loadPreviewInventory()
const hits = {
session: 0,
navigation: 0,
inventory: 0,
pages: 0,
entities: 0,
lookups: 0,
options: 0,
actions: 0
}
await client.command('Fetch.enable', {
patterns: [
{ urlPattern: '*://*/api/v1/session*', requestStage: 'Request' },
{ urlPattern: '*://*/api/v1/navigation*', requestStage: 'Request' },
{ urlPattern: '*://*/api/v1/inventory*', requestStage: 'Request' },
{ urlPattern: '*://*/api/v1/pages/*', requestStage: 'Request' },
{ urlPattern: '*://*/api/v1/entities/*', requestStage: 'Request' },
{ urlPattern: '*://*/api/v1/lookups/*', requestStage: 'Request' },
{ urlPattern: '*://*/api/v1/options/*', requestStage: 'Request' },
{ urlPattern: '*://*/api/v1/actions/*', requestStage: 'Request' }
]
})
client.addEventListener('Fetch.requestPaused', async (event) => {
const requestId = event.params?.requestId
const url = event.params?.request?.url || ''
const pathname = pathnameFromPausedUrl(url)
if (!requestId) return
let body = ''
if (url.includes('/api/v1/session')) {
hits.session += 1
body = Buffer.from(JSON.stringify(mockSessionResult), 'utf8').toString('base64')
} else if (url.includes('/api/v1/navigation')) {
hits.navigation += 1
body = apiJsonBody({ applications: mockNavigationApplications }, 'modern-ui-verification-navigation')
} else if (url.includes('/api/v1/inventory')) {
hits.inventory += 1
body = apiJsonBody(previewInventory, 'modern-ui-verification-inventory')
} else if (pathname.includes('/api/v1/pages/')) {
const pageId = pageIdFromApiPath(pathname)
const page = inventory.pageDefinitions?.[pageId]
if (!page) {
await client.command('Fetch.continueRequest', { requestId }).catch(() => {})
return
}
hits.pages += 1
body = apiJsonBody(page, `modern-ui-verification-page-${pageId}`)
} else if (pathname.includes('/api/v1/entities/')) {
const entityName = entityNameFromApiPath(pathname, 'entities')
hits.entities += 1
body = apiJsonBody(emptyEntityRows(entityName), `modern-ui-verification-entity-${entityName}`)
} else if (pathname.includes('/api/v1/lookups/')) {
const lookupId = entityNameFromApiPath(pathname, 'lookups')
hits.lookups += 1
body = apiJsonBody(emptyLookupRows(lookupId), `modern-ui-verification-lookup-${lookupId}`)
} else if (pathname.includes('/api/v1/options/')) {
const entityName = entityNameFromApiPath(pathname, 'options')
hits.options += 1
body = apiJsonBody(emptyOptionsResult(entityName), `modern-ui-verification-options-${entityName}`)
} else if (pathname.includes('/api/v1/actions/')) {
const actionId = entityNameFromApiPath(pathname, 'actions')
hits.actions += 1
body = apiJsonBody({
executed: false,
actionId,
payload: {},
reason: 'Admin runtime rendering verification mock does not execute OFBiz actions'
}, `modern-ui-verification-action-${actionId}`)
} else {
await client.command('Fetch.continueRequest', { requestId }).catch(() => {})
return
}
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
}).catch(() => {})
})
return {
hits: () => ({ ...hits })
}
}
async function installActionRequestProbe(client) {
await client.command('Runtime.evaluate', {
expression: `(() => {
window.__modernActionRequests = [];
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/v1/actions/')) {
window.__modernActionRequests.push({
url,
method: init?.method || 'GET',
body: typeof init?.body === 'string' ? init.body : ''
});
}
return originalFetch(input, init);
};
return true;
})()`,
awaitPromise: true,
returnByValue: true
})
}
async function actionRequestSeen(client, actionId) {
const encodedActionId = JSON.stringify(actionId)
return Boolean(await evaluate(client, `(() => {
const actionId = ${encodedActionId};
return (window.__modernActionRequests || []).some((request) => request.url.includes('/api/v1/actions/' + encodeURIComponent(actionId)));
})()`))
}
async function previewRuntimeSnapshot(client) {
return await evaluate(client, `(() => ({
href: window.location.href,
hash: window.location.hash,
readyState: document.readyState,
hasApp: Boolean(document.querySelector('#app')),
hasShell: Boolean(document.querySelector('.erp-shell')),
hasLoginGate: Boolean(document.body?.innerText?.includes('登录后进入完整 ERP 后台')),
bodyText: document.body ? document.body.innerText.slice(0, 800) : ''
}))()`, 3000).catch((error) => ({ error: error.message }))
}
async function fileIncludes(file, patterns) {
const text = await readFile(file, 'utf8')
return patterns.every((pattern) => text.includes(pattern))
}
async function staticGates(inventory) {
const pageList = Object.values(inventory.pageDefinitions || {})
const routeManifest = inventory.routeManifest || []
const actionDefinitions = inventory.actionDefinitions || {}
const modernAppRoutes = routeManifest.filter((route) => String(route.modernPath || '').startsWith('/modern/app/#/pages/'))
const legacySwitchReady = await fileIncludes(controlFilterFile, [
'shouldRedirectLegacyGetToModern',
'ofbiz.modern.ui.enabled',
'loadModernUiLegacyRoutes',
'String queryString = req.getQueryString()',
'resp.sendRedirect(modernPath + "#/pages/" + route.getPageId() + hashQuery)',
'isHtmlPageRoute'
])
const legacySwitchConfigReady = await fileIncludes(requestHandlerPropertiesFile, [
'modern.ui.enabled=true',
'modern.ui.basePath=/modern/app/'
])
const legacySwitchTestReady = await fileIncludes(controlFilterTestFile, [
'modernUiCutoverRedirectsMappedLegacyGet',
'modernUiCutoverDoesNotRedirectPostActions',
'modernUiCutoverPreservesLegacyQueryString',
'modernUiCutoverKeepsLegacyWhenDisabled',
'modernUiCutoverDoesNotRedirectNonHtmlOutputRoutes'
])
return [
{
id: 'route-coverage',
label: 'All legacy view routes have PageDefinition',
expected: routeManifest.length,
actual: pageList.length,
passed: inventory.coverage?.missingRoutes === 0 && pageList.length > 0
},
{
id: 'action-coverage',
label: 'All controller actions have ActionDefinition',
expected: inventory.counts?.requestAction || 0,
actual: Object.keys(actionDefinitions).length,
passed: inventory.coverage?.missingActions === 0 && Object.keys(actionDefinitions).length > 0
},
{
id: 'modern-block-coverage',
label: 'Every generated page has modern Element Plus block',
expected: pageList.length,
actual: inventory.coverage?.modernBlockPages || 0,
passed: (inventory.coverage?.modernBlockPages || 0) === pageList.length && pageList.length > 0
},
{
id: 'table-data-source-contracts',
label: 'Every generated table has an entity or derived data-source contract',
expected: inventory.coverage?.tableBlocks || 0,
actual: inventory.coverage?.tableDataSourceBlocks || 0,
passed: (inventory.coverage?.tableBlocks || 0) > 0
&& inventory.coverage?.tableDataSourceBlocks === inventory.coverage?.tableBlocks
},
{
id: 'form-action-contracts',
label: 'Every generated form has an executable, navigation, dynamic, local, or readonly contract',
expected: inventory.coverage?.formBlocks || 0,
actual: inventory.coverage?.formContractBlocks || 0,
passed: (inventory.coverage?.formBlocks || 0) > 0
&& inventory.coverage?.formContractBlocks === inventory.coverage?.formBlocks
&& (inventory.coverage?.formMissingContractBlocks || 0) === 0
},
{
id: 'select-option-contracts',
label: 'Generated select/radio fields keep static options or dynamic option-source metadata',
expected: inventory.coverage?.selectFields || 0,
actual: inventory.coverage?.selectFieldsWithOptionContract || 0,
passed: (inventory.coverage?.selectFields || 0) > 0
&& inventory.coverage?.selectFieldsWithOptionContract === inventory.coverage?.selectFields
},
{
id: 'generated-only-zero',
label: 'No page is metadata-only',
expected: 0,
actual: inventory.coverage?.generatedOnlyPages || 0,
passed: (inventory.coverage?.generatedOnlyPages || 0) === 0
},
{
id: 'parity-manifest',
label: 'Parity manifest exists for every generated page',
expected: pageList.length,
actual: inventory.parityManifest?.pages?.length || 0,
passed: (inventory.parityManifest?.pages?.length || 0) === pageList.length && pageList.length > 0
},
{
id: 'e2e-scenario-coverage',
label: 'Every generated page has a business parity scenario',
expected: pageList.length,
actual: pageList.filter((page) => page.acceptance?.e2eScenario?.scenarioId).length,
passed: pageList.length > 0 && pageList.every((page) => page.acceptance?.e2eScenario?.scenarioId)
},
{
id: 'frontend-rewrite-status',
label: 'Every generated page is marked as admin runtime rendering ready',
expected: pageList.length,
actual: pageList.filter((page) => page.acceptance?.frontendRewriteStatus === 'rewritten-preview-passed').length,
passed: pageList.length > 0 && pageList.every((page) => page.acceptance?.frontendRewriteStatus === 'rewritten-preview-passed')
},
{
id: 'modern-app-route-paths',
label: 'Every legacy route points to the deployable /modern/app SPA entry',
expected: routeManifest.length,
actual: modernAppRoutes.length,
passed: routeManifest.length > 0 && modernAppRoutes.length === routeManifest.length
},
{
id: 'legacy-url-cutover-switch',
label: 'Legacy GET view URLs have an opt-in server-side cutover to the Modern UI SPA',
expected: 3,
actual: [legacySwitchReady, legacySwitchConfigReady, legacySwitchTestReady].filter(Boolean).length,
passed: legacySwitchReady && legacySwitchConfigReady && legacySwitchTestReady
}
]
}
async function verifyGeneratedAssets(inventory, inventoryPath, pagesDir, label) {
const pageList = Object.values(inventory.pageDefinitions || {})
const routeManifest = inventory.routeManifest || []
const expectedPageCount = pageList.length
const results = []
let snapshot = null
let files = []
let snapshotSize = 0
try {
const fileStat = await stat(inventoryPath)
snapshotSize = fileStat.size
snapshot = JSON.parse(await readFile(inventoryPath, 'utf8'))
} catch (error) {
results.push({
id: `${label}-inventory-json`,
label: `${label} generated inventory index exists`,
expected: 'readable JSON',
actual: error.message,
passed: false
})
return results
}
try {
files = (await readdir(pagesDir)).filter((file) => file.endsWith('.json'))
} catch (error) {
results.push({
id: `${label}-pages-dir`,
label: `${label} page definition directory exists`,
expected: expectedPageCount,
actual: error.message,
passed: false
})
return results
}
results.push({
id: `${label}-inventory-json`,
label: `${label} generated inventory index exists`,
expected: 'readable JSON',
actual: `${snapshotSize} bytes`,
passed: Boolean(snapshot?.routeManifest?.length)
})
results.push({
id: `${label}-split-page-count`,
label: `${label} split PageDefinition files cover every page`,
expected: expectedPageCount,
actual: files.length,
passed: files.length === expectedPageCount && expectedPageCount > 0
})
results.push({
id: `${label}-index-is-lightweight`,
label: `${label} index omits embedded page/action definitions`,
expected: 0,
actual: Object.keys(snapshot.pageDefinitions || {}).length + Object.keys(snapshot.actionDefinitions || {}).length,
passed: Object.keys(snapshot.pageDefinitions || {}).length === 0 && Object.keys(snapshot.actionDefinitions || {}).length === 0
})
results.push({
id: `${label}-route-page-urls`,
label: `${label} route manifest points to split PageDefinition URLs`,
expected: routeManifest.length,
actual: (snapshot.routeManifest || []).filter((route) => String(route.pageDefinitionUrl || '').startsWith('/modern/app/generated/pages/')).length,
passed: (snapshot.routeManifest || []).length === routeManifest.length
&& (snapshot.routeManifest || []).every((route) => String(route.pageDefinitionUrl || '').startsWith('/modern/app/generated/pages/'))
})
results.push({
id: `${label}-modern-app-route-paths`,
label: `${label} route manifest uses /modern/app SPA links`,
expected: routeManifest.length,
actual: (snapshot.routeManifest || []).filter((route) => String(route.modernPath || '').startsWith('/modern/app/#/pages/')).length,
passed: (snapshot.routeManifest || []).length === routeManifest.length
&& (snapshot.routeManifest || []).every((route) => String(route.modernPath || '').startsWith('/modern/app/#/pages/'))
})
for (const item of pageDefinitionCases) {
const route = (snapshot.routeManifest || []).find((manifestItem) => manifestItem.pageId === item.pageId)
const pageUrl = String(route?.pageDefinitionUrl || '')
const expectedPath = pageUrl.startsWith('/modern/app/generated/pages/')
? path.join(pagesDir, path.basename(pageUrl))
: ''
try {
if (!expectedPath) {
throw new Error(`No pageDefinitionUrl for ${item.pageId}`)
}
const definition = JSON.parse(await readFile(expectedPath, 'utf8'))
results.push({
id: `${label}-page-file-${item.id}`,
label: `${label} representative page file loads: ${item.pageId}`,
expected: item.pageId,
actual: definition.pageId,
passed: definition.pageId === item.pageId && Boolean(definition.acceptance?.e2eScenario?.scenarioId)
})
} catch (error) {
results.push({
id: `${label}-page-file-${item.id}`,
label: `${label} representative page file loads: ${item.pageId}`,
expected: item.pageId,
actual: error.message,
passed: false
})
}
}
return results
}
async function verifyDynamicCases(chrome, inventory) {
if (!chrome) {
return dynamicCases.map((item) => ({
...item,
url: normalizeUrl(item.path),
status: 'skipped',
missing: item.expected,
error: 'Chrome executable not found'
}))
}
let session = null
let client = null
try {
session = await launchCdpChrome(chrome)
client = new CdpConnection(session.wsUrl)
await client.open()
await client.command('Page.enable')
await client.command('Runtime.enable')
const apiMocks = await installAuthenticatedApiMocks(client, inventory)
const results = []
for (const item of dynamicCases) {
logProgress(`dynamic ${results.length + 1}/${dynamicCases.length}: ${item.id}`)
let text = ''
let error = ''
try {
await navigateCdp(client, item.path)
const expected = JSON.stringify(item.expected)
await waitForExpression(
client,
`${expected}.every((needle) => document.body.innerText.includes(needle))`,
10000
)
text = await evaluate(client, 'document.body.innerText', 5000)
} catch (caseError) {
error = caseError.message
text = await evaluate(client, 'document.body.innerText', 5000).catch(() => '')
}
const missing = item.expected.filter((needle) => !String(text).includes(needle))
const diagnostics = error || missing.length ? await previewRuntimeSnapshot(client) : undefined
results.push({
...item,
url: normalizeUrl(item.path),
status: !error && missing.length === 0 ? 'passed' : 'failed',
missing,
error,
apiHits: apiMocks.hits(),
...(diagnostics ? { diagnostics } : {})
})
}
return results
} catch (error) {
return dynamicCases.map((item) => ({
...item,
url: normalizeUrl(item.path),
status: 'failed',
missing: item.expected,
error: error.message
}))
} finally {
client?.close()
if (session) {
try {
await session.close()
} catch {
// Verification already records page-level failures.
}
}
}
}
async function verifyInteractionCases(chrome, inventory) {
if (!chrome) {
return interactionCases.map((item) => ({
id: item.id,
label: item.label,
path: item.path,
url: normalizeUrl(item.path),
status: 'skipped',
error: 'Chrome executable not found',
steps: item.steps.map((step) => ({ action: step.action, selector: step.selector, status: 'skipped' }))
}))
}
let session = null
let client = null
try {
session = await launchCdpChrome(chrome)
client = new CdpConnection(session.wsUrl)
await client.open()
await client.command('Page.enable')
await client.command('Runtime.enable')
const apiMocks = await installAuthenticatedApiMocks(client, inventory)
const results = []
for (const item of interactionCases) {
logProgress(`interaction ${results.length + 1}/${interactionCases.length}: ${item.id}`)
const steps = []
let status = 'passed'
let error = ''
try {
await navigateCdp(client, item.path)
await installActionRequestProbe(client)
if (item.pageId) {
await waitForExpression(client, `document.querySelector('[data-modern-page-id="${item.pageId}"]')`, 15000)
await ensureOverviewTab(client)
}
for (const step of item.steps) {
if (step.action === 'click') {
await clickSelector(client, step.selector)
} else if (step.action === 'fill-first-input') {
await fillSelector(client, step.selector, step.value || '')
} else if (step.action === 'assert-expression') {
// The wait below is the assertion for prefilled state or passive UI output.
} else {
throw new Error(`Unsupported interaction action: ${step.action}`)
}
await waitForExpression(client, step.waitFor, step.timeoutMs || 6000)
if (step.expectActionRequest) {
const seen = await actionRequestSeen(client, step.expectActionRequest)
if (!seen) {
throw new Error(`Expected Action API request was not sent: ${step.expectActionRequest}`)
}
}
steps.push({ action: step.action, selector: step.selector, status: 'passed' })
}
} catch (caseError) {
status = 'failed'
error = caseError.message
steps.push({ status: 'failed', error })
}
const currentUrl = await evaluate(client, 'window.location.href').catch(() => normalizeUrl(item.path))
const diagnostics = status === 'failed' ? await previewRuntimeSnapshot(client) : undefined
results.push({
id: item.id,
label: item.label,
path: item.path,
url: currentUrl,
status,
error,
apiHits: apiMocks.hits(),
...(diagnostics ? { diagnostics } : {}),
steps
})
}
return results
} catch (error) {
return interactionCases.map((item) => ({
id: item.id,
label: item.label,
path: item.path,
url: normalizeUrl(item.path),
status: 'failed',
error: error.message,
steps: item.steps.map((step) => ({ action: step.action, selector: step.selector, status: 'failed' }))
}))
} finally {
client?.close()
if (session) {
try {
await session.close()
} catch {
// Chrome cleanup failure should surface through case results, not crash the reporter.
}
}
}
}
function verifyPageDefinitionCases(inventory) {
return pageDefinitionCases.map((item) => {
const page = inventory.pageDefinitions?.[item.pageId]
const adapters = page?.acceptance?.adapterTypes || []
const requirements = page?.acceptance?.requirements || []
const scenario = page?.acceptance?.e2eScenario
const missingAdapters = item.expectedAdapters.filter((adapter) => !adapters.includes(adapter))
const missingRequirements = item.expectedRequirements.filter((requirement) => !requirements.includes(requirement))
const missingScenario = []
if (!scenario?.scenarioId) missingScenario.push('scenarioId')
if (item.expectedFlow && scenario?.flow !== item.expectedFlow) missingScenario.push(`flow:${item.expectedFlow}`)
if (!scenario?.steps?.length) missingScenario.push('steps')
if (!scenario?.apiContracts?.length) missingScenario.push('apiContracts')
return {
...item,
path: `#/pages/${item.pageId}`,
title: page?.title || '',
status: page && missingAdapters.length === 0 && missingRequirements.length === 0 && missingScenario.length === 0 ? 'passed' : 'failed',
adapterTypes: adapters,
requirements,
scenarioFlow: scenario?.flow || '',
scenarioStatus: scenario?.status || '',
missingAdapters,
missingRequirements,
missingScenario
}
})
}
function markdown(report) {
const staticLines = report.staticGates
.map((gate) => `| ${gate.passed ? 'PASS' : 'FAIL'} | ${gate.id} | ${gate.actual} | ${gate.expected} | ${gate.label} |`)
.join('\n')
const assetLines = report.assetGates
.map((gate) => `| ${gate.passed ? 'PASS' : 'FAIL'} | ${gate.id} | ${gate.actual} | ${gate.expected} | ${gate.label} |`)
.join('\n')
const dynamicLines = report.dynamicCases
.map((test) => `| ${test.status.toUpperCase()} | ${test.id} | ${test.path} | ${test.missing.join(', ') || '-'} |`)
.join('\n')
const pageDefinitionLines = report.pageDefinitionCases
.map((test) => `| ${test.status.toUpperCase()} | ${test.id} | ${test.pageId} | ${test.adapterTypes.join(', ') || '-'} | ${test.scenarioFlow || '-'} | ${test.missingAdapters.concat(test.missingRequirements, test.missingScenario).join(', ') || '-'} |`)
.join('\n')
const interactionLines = report.interactionCases
.map((test) => `| ${test.status.toUpperCase()} | ${test.id} | ${test.path} | ${test.steps.filter((step) => step.status === 'passed').length}/${test.steps.length} | ${test.error || '-'} |`)
.join('\n')
return `# Modern UI Admin Runtime Rendering Verification
Generated: ${report.generatedAt}
Base URL: ${report.baseUrl}
## Summary
\`\`\`json
${JSON.stringify(report.summary, null, 2)}
\`\`\`
## Static Gates
| Status | Gate | Actual | Expected | Label |
| --- | --- | ---: | ---: | --- |
${staticLines}
## Generated Frontend Assets
| Status | Gate | Actual | Expected | Label |
| --- | --- | ---: | ---: | --- |
${assetLines}
## Dynamic Runtime Rendering Cases
| Status | Case | Route | Missing Text |
| --- | --- | --- | --- |
${dynamicLines}
## Page Definition Detail Cases
| Status | Case | Page ID | Adapters | Scenario | Missing |
| --- | --- | --- | --- | --- | --- |
${pageDefinitionLines}
## Interaction Cases
| Status | Case | Route | Passed Steps | Error |
| --- | --- | --- | ---: | --- |
${interactionLines}
## Completion Note
This verifies admin/frontend runtime rendering coverage and representative route behavior. It does not prove final OFBiz business-function equivalence while pendingE2ePages is greater than 0.
`
}
async function main() {
const startedAt = Date.now()
const timeout = setTimeout(() => {
console.error(`[verify-admin-rendering] overall timeout after ${overallTimeoutMs}ms`)
process.exit(124)
}, overallTimeoutMs)
timeout.unref?.()
logProgress(`checking base URL ${baseUrl}`)
await assertBaseUrlReady()
logProgress('base URL is ready')
const inventory = JSON.parse(await readFile(inventoryFile, 'utf8'))
logProgress('running static gates')
const staticResults = await staticGates(inventory)
logProgress('checking generated public assets')
const publicAssetResults = await verifyGeneratedAssets(inventory, publicInventoryFile, publicPagesDir, 'public')
logProgress('checking generated built assets')
const builtAssetResults = await verifyGeneratedAssets(inventory, builtInventoryFile, builtPagesDir, 'built')
const chrome = findChrome()
logProgress(`using browser fallback: ${chrome || 'not found'}`)
logProgress('running dynamic browser routes')
const dynamicResults = await verifyDynamicCases(chrome, inventory)
logProgress('running interaction browser routes')
const interactionResults = await verifyInteractionCases(chrome, inventory)
logProgress('checking page definitions')
const pageDefinitionResults = verifyPageDefinitionCases(inventory)
const report = {
generatedAt: new Date().toISOString(),
baseUrl,
chrome: chrome || null,
summary: {
status: staticResults.every((gate) => gate.passed)
&& publicAssetResults.concat(builtAssetResults).every((gate) => gate.passed)
&& dynamicResults.every((test) => test.status === 'passed' || test.status === 'passed-with-timeout')
&& interactionResults.every((test) => test.status === 'passed')
&& pageDefinitionResults.every((test) => test.status === 'passed')
? 'passed'
: 'failed',
totalPages: Object.keys(inventory.pageDefinitions || {}).length,
pendingE2ePages: inventory.parityManifest?.summary?.pendingE2ePages || 0,
staticPassed: staticResults.filter((gate) => gate.passed).length,
staticFailed: staticResults.filter((gate) => !gate.passed).length,
assetPassed: publicAssetResults.concat(builtAssetResults).filter((gate) => gate.passed).length,
assetFailed: publicAssetResults.concat(builtAssetResults).filter((gate) => !gate.passed).length,
dynamicPassed: dynamicResults.filter((test) => test.status === 'passed' || test.status === 'passed-with-timeout').length,
dynamicFailed: dynamicResults.filter((test) => test.status === 'failed').length,
dynamicSkipped: dynamicResults.filter((test) => test.status === 'skipped').length,
interactionPassed: interactionResults.filter((test) => test.status === 'passed').length,
interactionFailed: interactionResults.filter((test) => test.status === 'failed').length,
interactionSkipped: interactionResults.filter((test) => test.status === 'skipped').length,
pageDefinitionPassed: pageDefinitionResults.filter((test) => test.status === 'passed').length,
pageDefinitionFailed: pageDefinitionResults.filter((test) => test.status === 'failed').length,
elapsedMs: Date.now() - startedAt
},
staticGates: staticResults,
assetGates: publicAssetResults.concat(builtAssetResults),
dynamicCases: dynamicResults,
interactionCases: interactionResults,
pageDefinitionCases: pageDefinitionResults
}
await writeFile(outJson, `${JSON.stringify(report, null, 2)}\n`)
await writeFile(outMd, markdown(report))
clearTimeout(timeout)
logProgress(`completed with status ${report.summary.status} in ${report.summary.elapsedMs}ms`)
console.log(JSON.stringify(report.summary, null, 2))
if (report.summary.status !== 'passed') {
process.exitCode = 1
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})