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

1546 lines
63 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 { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import {
CdpConnection,
browserExecutionContext,
closeChrome,
createBrowserSetupTracker,
evaluate,
findChrome,
installConsoleErrorCapture,
installSessionMock,
isRetryableBrowserSetupError,
launchChrome,
runtimeDiagnostics,
summarizeBrowserSetupFailure,
waitForExpression
} from './lib/chrome-cdp.mjs'
import { fetchFailureDetail, localNetworkHint } from './lib/runtime-network.mjs'
import { actionableConsoleErrors } from './lib/runtime-console.mjs'
import {
adminRuntimePolicySnapshotExpression,
assertAdminRuntimeCaseCoverage,
assertAdminRuntimePolicy,
expectedRouteHash,
globalAdminForbiddenText,
runtimeRouteHashWaitExpression
} from './verify-admin-runtime-policy.mjs'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
const repoRoot = path.resolve(appRoot, '../../..')
const outDir = path.join(repoRoot, 'plugins/modern-ui/verification')
const outJson = path.join(outDir, 'admin-runtime-verification.json')
const outMd = path.join(outDir, 'admin-runtime-verification.md')
const caseOutJson = path.join(outDir, 'admin-runtime-cases.json')
const caseOutMd = path.join(outDir, 'admin-runtime-cases.md')
const baseUrl = normalizeBaseUrl(process.env.MODERN_UI_BASE_URL || 'http://127.0.0.1:8080/modern/app/')
const cdpCommandTimeoutMs = Number(process.env.MODERN_UI_CDP_TIMEOUT_MS || 15000)
const chromeStartupTimeoutMs = Number(process.env.MODERN_UI_CHROME_STARTUP_TIMEOUT_MS || 20000)
const baseUrlTimeoutMs = Number(process.env.MODERN_UI_BASE_URL_TIMEOUT_MS || 8000)
const overallTimeoutMs = Number(process.env.MODERN_UI_ADMIN_RUNTIME_TIMEOUT_MS || 600000)
const browserSetupAttempts = positiveIntegerEnv('MODERN_UI_BROWSER_SETUP_ATTEMPTS', 1)
const requestedCaseIds = splitList(process.env.MODERN_UI_ADMIN_RUNTIME_CASES)
const listCasesOnly = process.argv.includes('--list-cases')
const checkCaseDefinitionsOnly = process.argv.includes('--check-cases') || process.env.MODERN_UI_ADMIN_RUNTIME_CHECK_CASES === '1'
const networkSettleMs = nonNegativeIntegerEnv('MODERN_UI_ADMIN_RUNTIME_NETWORK_SETTLE_MS', 250)
const apiBaseUrl = normalizeApiBaseUrl(process.env.MODERN_UI_API_BASE_URL || baseUrl)
const adminUsername = process.env.MODERN_UI_ADMIN_USERNAME || 'admin'
const adminPassword = process.env.MODERN_UI_ADMIN_PASSWORD || 'ofbiz'
const baseUrlParts = new URL(`${baseUrl}/`)
const apiBaseUrlParts = new URL(`${apiBaseUrl}/`)
const modernWebappContextPath = inferWebappContextPath(baseUrlParts.pathname)
const modernSecuredLoginIdCookieName = `${webappCookieApplicationName(modernWebappContextPath)}.securedLoginId`
const mockedSession = {
ok: true,
data: {
authenticated: true,
user: {
userLoginId: 'admin',
partyId: 'DemoAdmin'
},
locale: 'zh-CN',
tenant: 'default',
theme: {
name: 'modern-element-plus',
density: 'compact',
navigation: 'module-sidebar'
},
permissions: {
resolved: true,
source: '经营数据'
}
},
messages: [],
warnings: [],
traceId: 'modern-admin-runtime-session'
}
const unauthenticatedSession = {
ok: true,
data: {
authenticated: false,
user: null,
locale: 'zh-CN',
tenant: null,
theme: {
name: 'modern-element-plus',
density: 'compact',
navigation: 'module-sidebar'
},
permissions: {
resolved: false,
source: '经营数据'
}
},
messages: [],
warnings: [],
traceId: 'modern-admin-runtime-login'
}
const optionalEbayEntityNames = new Set([
'EbayConfig',
'EbayShippingMethod',
'EbayProductListing',
'EbayProductStoreInventory',
'EbayProductStorePref',
'EBayLogMessagesInfo',
'EbayUserBestOffer'
])
const optionalEbayBusinessEmptyReason = 'Optional eBay/eBay Store entity is missing, disabled, or forbidden in this OFBiz runtime; treat as business empty data for the eBay operations workspace.'
const coreAdminRuntimeCaseContracts = [
['login-door', { session: 'guest', route: '#/' }],
['admin-workbench', { session: 'admin', route: '#/' }],
['order-admin', { session: 'admin', route: '#/orders' }],
['party-admin', { session: 'admin', route: '#/parties' }],
['product-admin', { session: 'admin', route: '#/catalog/products' }],
['accounting-admin', { session: 'admin', route: '#/accounting' }],
['inventory-admin', { session: 'admin', route: '#/facility' }],
['manufacturing-admin', { session: 'admin', route: '#/manufacturing' }],
['humanres-admin', { session: 'admin', route: '#/humanres' }],
['sales-admin', { session: 'admin', route: '#/sales' }],
['procurement-admin', { session: 'admin', route: '#/procurement' }],
['scrum-admin', { session: 'admin', route: '#/scrum' }],
['operations-admin', { session: 'admin', route: '#/operations' }],
['content-admin', { session: 'admin', route: '#/content' }],
['marketing-admin', { session: 'admin', route: '#/marketing' }],
['commerce-admin', { session: 'admin', route: '#/commerce' }],
['pos-admin', { session: 'admin', route: '#/pos' }],
['marketplace-admin', { session: 'admin', route: '#/marketplace' }],
['analytics-admin', { session: 'admin', route: '#/analytics' }],
['extension-admin', { session: 'admin', route: '#/extensions' }],
['global-business-center', { session: 'admin', route: '#/business?query=invoice' }],
['route-unavailable', { session: 'admin', route: '#/definitely-missing-route' }],
['system-maintenance', { session: 'admin', route: '#/system' }],
['security-admin', { session: 'admin', route: '#/system/security' }],
['system-operations', { session: 'admin', route: '#/system/operations' }]
]
const coreAdminRuntimeCaseIds = coreAdminRuntimeCaseContracts.map(([id]) => id)
const runtimeCases = [
{
id: 'login-door',
session: 'guest',
route: '#/',
expected: ['登录后进入完整 ERP 后台', '使用 OFBiz 账号继续', '登录 ERP 后台'],
forbidden: ['ERP 管理员工作台', '组件展厅', '迁移', '技术预览']
},
{
id: 'admin-workbench',
session: 'admin',
route: '#/',
expected: ['ERP 管理员工作台', '今日运营', '待办队列', '业务状态', '最近记录', '队列明细', '最近单据', '快捷动作', '可处理范围', '系统健康与权限', '经营数据', '快速检索'],
forbidden: ['Element Plus 定制 ERP 管理界面', '界面方案', '原应用清单', '组件展厅', '迁移', '技术预览', '页面清单', '覆盖台账', '未连接']
},
{
id: 'order-module',
session: 'admin',
route: '#/module/order',
expected: ['订单工作台', '业务处理中枢', '业务队列', '交接记录', '处理流', '执行动作', '处理事项', '复核交接', '异常交接'],
forbidden: ['组件展示页', '旧入口', '页面标识', '业务等价', '待验收']
},
{
id: 'order-admin',
session: 'admin',
route: '#/orders',
expected: ['订单运营台', '订单执行台', '待审核订单', '待履约订单', '付款关注', '退货风险', '今日处理流', '交接记录', '订单队列', '订单明细', '履约与发运', '退货授权', '订单业务数据', 'OrderHeader', 'OrderItem', 'OrderStatus', 'Shipment', 'ReturnHeader'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'party-admin',
session: 'admin',
route: '#/parties',
expected: ['客户与组织管理台', '客户主体', '个人档案', '组织档案', '角色关系', '联系方式', '沟通记录', '客户业务数据', 'Party', 'Person', 'PartyGroup', 'PartyRole', 'ContactMech', 'CommunicationEvent'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'accounting-admin',
session: 'admin',
route: '#/accounting',
expected: ['财务管理台', '财务结账台', '应收跟进', '应付安排', '收付款匹配', '核销队列', '现金流关注', '财务异常', '本期结账', '发票队列', '付款收款', '会计凭证', '凭证明细', '总账科目', '核销处理', '账龄风险', '财务业务数据', 'Invoice', 'Payment', 'AcctgTrans', 'AcctgTransEntry', 'GlAccount'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'inventory-admin',
session: 'admin',
route: '#/facility',
expected: ['库存管理台', '库存执行台', '收货上架', '可用量关注', '调拨跟进', '盘点差异', '库存异常', '库位交接', '库存项', '设施库位', '收货发运', '库存明细', '库存调拨', '盘点调整', '库存风险', '库存业务数据', 'InventoryItem', 'Facility', 'FacilityLocation', 'Shipment', 'InventoryItemDetail', 'InventoryTransfer', 'PhysicalInventory'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'manufacturing-admin',
session: 'admin',
route: '#/manufacturing',
expected: ['生产管理台', '生产调度台', '排产跟进', '物料缺口', '工序准备', '发料领用', '成本关注', '生产异常', '生产运行', '物料需求', 'BOM 工艺', '工序任务', '成本构成', '生产风险', '生产业务数据', 'WorkEffort', 'Requirement', 'ProductAssoc', 'WorkEffortGoodStandard', 'CostComponent', 'ItemIssuance'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'humanres-admin',
session: 'admin',
route: '#/humanres',
expected: ['人事管理台', '人事执行台', '入转离跟进', '岗位空缺', '招聘处理', '绩效复核', '技能资质', '人事异常', '任职交接', '员工档案', '雇佣关系', '岗位编制', '招聘申请', '人事风险', '人事业务数据', 'Person', 'Employment', 'EmplPosition', 'EmplPositionFulfillment', 'EmploymentApp', 'JobRequisition', 'PerfReview', 'PartySkill'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'sales-admin',
session: 'admin',
route: '#/sales',
expected: ['销售管理台', '销售执行台', '线索承接', '机会推进', '预测复核', '跟进交接', '客户角色', '销售异常', '销售线索', '机会管道', '销售预测', '跟进事件', '销售风险', '销售业务数据', 'SalesOpportunity', 'SalesOpportunityStage', 'SalesOpportunityRole', 'SalesForecast', 'SalesForecastDetail', 'PartyRole', 'CommunicationEvent'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'procurement-admin',
session: 'admin',
route: '#/procurement',
expected: ['采购管理台', '采购执行台', '需求审批', '询价比价', '供应商风险', '到货接收', '补货交接', '采购异常', '供应商协同', '采购需求', '供应商', '供应商品', '供应商报价', '采购风险', '采购业务数据', 'Requirement', 'SupplierProduct', 'Vendor', 'Quote', 'CustRequest', 'Shipment', 'InventoryItem'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'scrum-admin',
session: 'admin',
route: '#/scrum',
expected: ['敏捷交付管理台', '敏捷执行台', 'Backlog 承接', 'Sprint 推进', '任务交接', '工时复核', '资源协调', '交付异常', '产品 Backlog', 'Sprint 排程', '任务板', '团队资源', '工时确认', '交付风险', '敏捷业务数据', 'ProductBacklog', 'ProjectSprint', 'ProjectSprintBacklogAndTask', 'Timesheet', 'TimeEntry', 'WorkEffortPartyAssignment', 'CustRequest'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'operations-admin',
session: 'admin',
route: '#/operations',
expected: ['运营任务管理台', '运营执行台', '任务承接', '排程依赖', '人员分配', '工时复核', '请求跟进', '沟通交接', '运营异常', '任务排程', '工作分配', '工时表', '请求协同', '沟通记录', '运营风险', '运营业务数据', 'WorkEffort', 'WorkEffortAssoc', 'WorkEffortPartyAssignment', 'Timesheet', 'TimeEntry', 'CustRequest', 'CommunicationEvent'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'content-admin',
session: 'admin',
route: '#/content',
expected: ['内容管理台', '内容执行台', '处理队列', '复核交接', '风险关注', '内容资源', '媒体资源', '站点管理', 'CMS 树', '论坛消息', '博客文章', '发布风险', '内容业务数据', 'Content', 'DataResource', 'WebSite', 'WebPage', 'ElectronicText', 'CommunicationEvent'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'marketing-admin',
session: 'admin',
route: '#/marketing',
expected: ['营销管理台', '营销执行台', '处理队列', '复核交接', '风险关注', '营销活动', '联系名单', '追踪码', '细分群组', '活动统计', '客户角色', '营销风险', '营销业务数据', 'MarketingCampaign', 'ContactList', 'TrackingCode', 'SegmentGroup', 'CommunicationEvent', 'PartyRole'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'commerce-admin',
session: 'admin',
route: '#/commerce',
expected: ['电商管理台', '电商执行台', '处理队列', '复核交接', '风险关注', '购物车', '会员订单', '商品浏览', '退货请求', '客户资料', '店铺资料', '电商风险', '电商业务数据', 'ShoppingList', 'OrderHeader', 'Product', 'ReturnHeader', 'Party', 'ProductStore'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'pos-admin',
session: 'admin',
route: '#/pos',
expected: ['POS 管理台', 'POS 执行台', '处理队列', '复核交接', '风险关注', '门店购物车', '收银订单', '支付记录', '经理授权', '门店库存', '库存项', '收银风险', 'POS 业务数据', 'ShoppingList', 'OrderHeader', 'Payment', 'UserLogin', 'Facility', 'InventoryItem'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'marketplace-admin',
session: 'admin',
route: '#/marketplace',
expected: ['店铺运营管理台', '店铺执行台', '处理队列', '复核交接', '风险关注', '店铺配置', '物流方式', '库存同步', '活动刊登', '店铺商品', '商品资料', '店铺风险', '店铺业务数据', 'EbayConfig', 'EbayShippingMethod', 'EbayProductStoreInventory', 'EbayProductListing', 'ProductStore', 'Product'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'analytics-admin',
session: 'admin',
route: '#/analytics',
expected: ['报表分析管理台', '报表执行台', '处理队列', '复核交接', '风险关注', '数据维度', '商品维度', '事实数据', '发票事实', '报表资源', '报表发布', '输出队列', '报表风险', '报表业务数据', 'DateDimension', 'ProductDimension', 'SalesOrderItemFact', 'SalesInvoiceItemFact', 'DataResource', 'Enumeration'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'sales-module',
session: 'admin',
route: '#/module/sales',
expected: ['销售工作台', '线索与机会', '业务处理中枢', '业务队列', '交接记录', '销售机会', '销售线索', '销售预测', '线索转化', '复核交接'],
forbidden: ['组件展示页', '旧入口', '页面标识', '业务等价', '待验收', '营销管理']
},
{
id: 'procurement-module',
session: 'admin',
route: '#/module/procurement',
expected: ['采购工作台', '供应商与需求', '业务处理中枢', '业务队列', '交接记录', '采购需求', '需求审批', '供应商', '供应商品', '到货接收', '复核交接'],
forbidden: ['组件展示页', '旧入口', '页面标识', '业务等价', '待验收']
},
{
id: 'scrum-module',
session: 'admin',
route: '#/module/scrum',
expected: ['敏捷交付工作台', '产品与 SPRINT', '业务处理中枢', '业务队列', '交接记录', '产品 Backlog', 'Sprint', '任务板', '团队资源', '工时', '产品统计', '复核交接', 'ProductBacklog', 'ProjectSprint'],
forbidden: ['任务与排程', '组件展示页', '旧入口', '页面标识', '业务等价', '待验收']
},
{
id: 'marketplace-module',
session: 'admin',
route: '#/module/marketplace',
expected: ['店铺运营工作台', 'MARKETPLACE', '业务处理中枢', '业务队列', '交接记录', '店铺配置', '物流方式', '库存同步', '活动刊登', '复核交接', 'EbayConfig', 'EbayProductListing'],
forbidden: ['前台交易', '组件展示页', '旧入口', '页面标识', '业务等价', '待验收']
},
{
id: 'extension-admin',
session: 'admin',
route: '#/extensions',
expected: ['扩展应用管理台', '扩展应用', '扩展记录', '业务记录', '特征规则', '价目表导入', '报表资源', '支付网关', '短信通道', '扩展应用数据', '配置治理'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'product-admin',
session: 'admin',
route: '#/catalog/products',
expected: ['商品管理台', '商品资料', '目录分类', '价格规则', '促销活动', '库存状态', '商品业务数据', 'Product', 'ProductCategory', 'ProductPrice', 'ProductPromo', 'InventoryItem'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'global-business-center',
session: 'admin',
route: '#/business?query=invoice',
expected: ['统一业务中心', '业务受理台', '当前办理口径', 'invoice', '进入处理'],
forbidden: ['业务处理总台', '组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'route-unavailable',
session: 'admin',
route: '#/definitely-missing-route',
expected: ['页面不可用', '没有对应的现代管理页面', '返回管理员工作台'],
forbidden: ['今日运营', '待办队列', '组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'system-maintenance',
session: 'admin',
route: '#/system',
expected: ['系统维护', '安全与会话', '运行任务', '运行日志', '缓存维护', '导入导出', '用户与权限'],
forbidden: ['组件规范', '覆盖台账', '业务回归记录']
},
{
id: 'security-admin',
session: 'admin',
route: '#/system/security',
expected: ['用户与权限管理', '账号与登录', '安全组', '授权关系', '权限审计', '权限业务数据', 'UserLogin', 'SecurityGroup', 'UserLoginSecurityGroup'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'system-operations',
session: 'admin',
route: '#/system/operations',
expected: ['系统运行管理', '计划任务', '任务队列', '缓存维护', '导入导出', '运行资源', '运行审计', '运行业务数据'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收', '技术预览']
},
{
id: 'business-page',
session: 'admin',
route: '#/pages/order__findorders',
expected: ['订单查询', '订单运营', '订单队列', '订单列表', '批量处理'],
forbidden: ['不是页面预览', '业务等价', '待验收']
},
{
id: 'party-page',
session: 'admin',
route: '#/pages/party__findparty',
expected: ['客户查询', '客户与组织', '客户沟通', '客户业务数据', '关系/联系人', '请求转化'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'ap-invoices',
session: 'admin',
route: '#/pages/ap__FindApInvoices',
expected: ['应付发票查询', '财务运营', '财务运营台', '发票业务数据', '核销处理'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'ap-payments',
session: 'admin',
route: '#/pages/ap__FindApPayments',
expected: ['应付付款查询', '财务运营', '财务运营台', '付款业务数据', '收付款匹配'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'ar-invoices',
session: 'admin',
route: '#/pages/ar__FindArInvoices',
expected: ['应收发票查询', '财务运营', '财务运营台', '发票业务数据', '核销处理'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'ar-payments',
session: 'admin',
route: '#/pages/ar__FindPayments',
expected: ['应收付款查询', '财务运营', '财务运营台', '付款业务数据', '收付款匹配'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'portal-pages',
session: 'admin',
route: '#/pages/myportal__FindPortalPage',
expected: ['门户页面查询', '门户管理', '门户页面', 'PortalPage'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'portal-edit',
session: 'admin',
route: '#/pages/myportal__EditPortalPage',
expected: ['编辑门户页面', '门户管理', '栏目布局', 'PortalPageColumn'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'portal-create',
session: 'admin',
route: '#/pages/myportal__CreatePortalPage',
expected: ['新建门户页面', '门户管理', '门户页面', 'createPortalPage'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'birt-reports',
session: 'admin',
route: '#/pages/birt__ListFlexibleReport',
expected: ['报表查询', '报表发布', '报表库', 'FLEXIBLE_REPORT', 'DataResource'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'birt-report-library',
session: 'admin',
route: '#/pages/birt__ListFlexibleReport',
expected: ['报表查询', '报表发布', '报表库', 'FLEXIBLE_REPORT'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'birt-report-create',
session: 'admin',
route: '#/pages/birt__CreateFlexibleReport',
expected: ['新建报表', '报表发布', '主模板', 'REPORT_MASTER'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'birt-report-form',
session: 'admin',
route: '#/pages/birt__EditFlexibleReportSearchForm',
expected: ['编辑报表查询表单', '报表发布', '参数表单', 'ContentAttribute'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'birt-report-mail',
session: 'admin',
route: '#/pages/birt__Mail',
expected: ['报表邮件发送', '报表发布', '邮件发送', 'sendBirtMail'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'birt-report-output',
session: 'admin',
route: '#/pages/birt__ViewHandler',
expected: ['报表输出', '报表发布', '输出队列', 'DataResource'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'firstdata-gateway',
session: 'admin',
route: '#/pages/firstdata__main',
expected: ['首页', '网关管理', 'FirstData', 'PaymentGatewayFirstData'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'message-gateway',
session: 'admin',
route: '#/pages/msggateway__main',
expected: ['首页', '网关管理', '连接检查', 'Msg91GatewayConfig'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'scrumdemo-main',
session: 'admin',
route: '#/pages/scrumdemo__main',
expected: ['首页', '扩展应用', '表单动作', 'ExampleFeature'],
forbidden: ['组件展厅', '页面清单', '插件应用', '业务等价', '待验收']
},
{
id: 'example-entities',
session: 'admin',
route: '#/pages/example__FindExample',
expected: ['示例业务查询', '扩展应用', '扩展记录', 'Example'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'example-reports',
session: 'admin',
route: '#/pages/example__ExampleReportPdfBarcode',
expected: ['示例报表输出', '扩展应用', '报表输出', 'DataResource'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'pricat-import',
session: 'admin',
route: '#/pages/pricat__main',
expected: ['首页', '扩展应用', '价目表导入', 'ExcelImportHistory'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'sample-pricat',
session: 'admin',
route: '#/pages/pricatdemo__SamplePricat',
expected: ['价目表示例', '扩展应用', '导入日志', 'ExcelImportHistory'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'ebay-configurations',
session: 'admin',
route: '#/pages/ebay__FindEbayConfigurations',
expected: ['店铺配置查询', '店铺运营', '店铺配置', 'EbayConfig'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'ebay-shipping-methods',
session: 'admin',
route: '#/pages/ebay__EbayShippingMethods',
expected: ['物流方式维护', '店铺运营', '自动规则', 'EbayShippingMethod'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'ebay-store-inventory',
session: 'admin',
route: '#/pages/ebaystore__ebayStoreInventory',
expected: ['店铺库存同步', '店铺运营', '库存同步', 'EbayProductStoreInventory'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'ebay-active-listing',
session: 'admin',
route: '#/pages/ebaystore__ActiveListing',
expected: ['活动刊登管理', '店铺运营', '刊登管理', 'EbayProductListing'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'bi-main',
session: 'admin',
route: '#/pages/bi__main',
expected: ['首页', '报表分析', '数据仓库', 'DateDimension'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'bi-star-schema',
session: 'admin',
route: '#/pages/bi__ReportBuilderSelectStarSchema',
expected: ['选择分析模型', '报表分析', '报表构建', '星型模型'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'bi-schema-fields',
session: 'admin',
route: '#/pages/bi__ReportBuilderSelectStarSchemaFields',
expected: ['选择分析字段', '报表分析', '字段选择', 'ProductDimension'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'bi-report-output',
session: 'admin',
route: '#/pages/bi__ReportBuilderRenderStarSchemaReport',
expected: ['分析报表输出', '报表分析', '结果输出', 'SalesOrderItemFact'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'asset-maintenance',
session: 'admin',
route: '#/pages/assetmaint__FindFixedAssetMaints',
expected: ['资产维护查询', '资产维护', '资产维护', '资产维护业务数据', '维护记录'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'it-asset-management',
session: 'admin',
route: '#/pages/ismgr__ListComputerHardware',
expected: ['IT 资产查询', '资产维护', 'IT 资产', '资产维护业务数据', '硬件 / 软件 / 附件'],
forbidden: ['组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'sales-opportunity',
session: 'admin',
route: '#/pages/SalesForceAutomation__FindSalesOpportunity',
expected: ['销售机会查询', '销售自动化', '机会管道', 'SalesOpportunity'],
forbidden: ['组件展厅', '页面清单', '营销管理', '业务等价', '待验收']
},
{
id: 'sales-leads',
session: 'admin',
route: '#/pages/SalesForceAutomation__FindLeads',
expected: ['销售线索查询', '销售自动化', '销售线索'],
forbidden: ['组件展厅', '页面清单', '营销管理', '业务等价', '待验收']
},
{
id: 'sales-forecast',
session: 'admin',
route: '#/pages/SalesForceAutomation__FindSalesForecast',
expected: ['销售预测查询', '销售自动化', '销售预测', 'SalesForecast'],
forbidden: ['组件展厅', '页面清单', '营销管理', '业务等价', '待验收']
},
{
id: 'sales-convert-lead',
session: 'admin',
route: '#/pages/SalesForceAutomation__ConvertLead',
expected: ['线索转化', '销售自动化', '线索转化'],
forbidden: ['组件展厅', '页面清单', '营销管理', '业务等价', '待验收']
},
{
id: 'procurement-requirements',
session: 'admin',
route: '#/pages/order__FindRequirements',
expected: ['采购需求查询', '采购管理', '采购需求', 'Requirement'],
forbiddenScope: '.erp-shell__main',
forbidden: ['订单运营', '财务运营', '商品管理', '库存管理', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'procurement-approval',
session: 'admin',
route: '#/pages/order__ApproveRequirements',
expected: ['采购需求审批', '采购管理', '需求审批', 'Requirement'],
forbiddenScope: '.erp-shell__main',
forbidden: ['订单运营', '财务运营', '商品管理', '库存管理', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'procurement-vendors',
session: 'admin',
route: '#/pages/ap__FindVendors',
expected: ['供应商查询', '采购管理', '供应商', 'Vendor'],
forbiddenScope: '.erp-shell__main',
forbidden: ['订单运营', '财务运营', '商品管理', '库存管理', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'procurement-supplier-product',
session: 'admin',
route: '#/pages/catalog__EditSupplierProduct',
expected: ['供应商品维护', '采购管理', '供应商品', 'SupplierProduct'],
forbiddenScope: '.erp-shell__main',
forbidden: ['订单运营', '财务运营', '商品管理', '库存管理', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'procurement-receiving',
session: 'admin',
route: '#/pages/facility__ReceiveInventoryAgainstPurchaseOrder',
expected: ['采购到货接收', '采购管理', '到货接收', 'Shipment', 'InventoryItem'],
forbiddenScope: '.erp-shell__main',
forbidden: ['订单运营', '财务运营', '商品管理', '库存管理', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'scrum-sprints',
session: 'admin',
route: '#/pages/scrum__Sprints',
expected: ['Sprint 排程', '敏捷交付', 'Sprint', 'ProjectSprint'],
forbiddenScope: '.erp-shell__main',
forbidden: ['项目任务', '扩展应用', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'scrum-backlog',
session: 'admin',
route: '#/pages/scrum__AddProdBacklog',
expected: ['产品 Backlog', '敏捷交付', '产品 Backlog', 'ProductBacklog'],
forbiddenScope: '.erp-shell__main',
forbidden: ['项目任务', '扩展应用', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'scrum-task-board',
session: 'admin',
route: '#/pages/scrum__SprintTask',
expected: ['任务板', '敏捷交付', '任务板', 'ProjectSprintBacklogAndTask'],
forbiddenScope: '.erp-shell__main',
forbidden: ['项目任务', '扩展应用', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'scrum-resources',
session: 'admin',
route: '#/pages/scrum__FindResource',
expected: ['团队资源', '敏捷交付', '团队资源', 'PartyRole'],
forbiddenScope: '.erp-shell__main',
forbidden: ['项目任务', '扩展应用', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'scrum-timesheet',
session: 'admin',
route: '#/pages/scrum__FindTimeSheet',
expected: ['工时表', '敏捷交付', '工时/计费', 'Timesheet'],
forbiddenScope: '.erp-shell__main',
forbidden: ['项目任务', '扩展应用', '组件展厅', '页面清单', '业务等价', '待验收']
},
{
id: 'scrum-product-statistics',
session: 'admin',
route: '#/pages/scrum__ProductStatistics',
expected: ['产品统计', '敏捷交付', '产品', 'ProjectSprintBacklogTaskAndTimeEntryTimeSheet'],
forbiddenScope: '.erp-shell__main',
forbidden: ['项目任务', '扩展应用', '组件展厅', '页面清单', '业务等价', '待验收']
}
]
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, '_')
}
function splitList(value) {
return String(value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function positiveIntegerEnv(name, fallback) {
const parsed = Number(process.env[name] || fallback)
return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : fallback
}
function nonNegativeIntegerEnv(name, fallback) {
const parsed = Number(process.env[name] || fallback)
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback
}
function selectedRuntimeCases() {
if (!requestedCaseIds.length) return runtimeCases
const runtimeCaseById = new Map(runtimeCases.map((testCase) => [testCase.id, testCase]))
const expandedCaseIds = expandRequestedCaseIds(requestedCaseIds)
const selected = expandedCaseIds.map((id) => runtimeCaseById.get(id)).filter(Boolean)
const missing = expandedCaseIds.filter((id) => !runtimeCases.some((testCase) => testCase.id === id))
if (missing.length) {
throw new Error(`Unknown MODERN_UI_ADMIN_RUNTIME_CASES id(s): ${missing.join(', ')}`)
}
return selected
}
function expandRequestedCaseIds(ids) {
const expanded = ids.flatMap((id) => {
if (['core', 'core-admin', 'required', 'required-core'].includes(id)) return coreAdminRuntimeCaseIds
if (id === 'all') return runtimeCases.map((testCase) => testCase.id)
return [id]
})
return Array.from(new Set(expanded))
}
function assertRuntimeCaseDefinitions(cases) {
const failures = []
const warnings = []
const ids = new Map()
const routeSessions = new Map()
const coreContracts = new Map(coreAdminRuntimeCaseContracts)
for (const testCase of cases) {
if (!testCase?.id) failures.push('runtime case is missing an id')
if (!testCase?.route) failures.push(`${testCase?.id || '<unknown>'} is missing a route`)
if (!['admin', 'guest'].includes(testCase?.session)) {
failures.push(`${testCase?.id || '<unknown>'} has unsupported session ${testCase?.session || '-'}`)
}
if (!Array.isArray(testCase?.expected) || !testCase.expected.length) {
failures.push(`${testCase?.id || '<unknown>'} must declare visible expected text`)
}
const visibleExpected = Array.isArray(testCase?.expected) ? visibleExpectedText(testCase) : []
if (!visibleExpected.length) {
failures.push(`${testCase?.id || '<unknown>'} must include at least one visible expected text token, not only technical entity names`)
}
const route = String(testCase?.route || '')
if (route && !expectedRouteHash(route).startsWith('#/')) {
failures.push(`${testCase?.id || '<unknown>'} route must resolve to a modern hash route: ${route}`)
}
const duplicate = ids.get(testCase.id)
if (duplicate) {
failures.push(`duplicate runtime case id ${testCase.id}`)
}
ids.set(testCase.id, testCase)
const routeSessionKey = `${testCase.session || '-'} ${expectedRouteHash(testCase.route || '')}`
const firstRouteSession = routeSessions.get(routeSessionKey)
if (firstRouteSession && !sameRouteDuplicateAllowed(firstRouteSession, testCase)) {
warnings.push(`${testCase.id} duplicates ${firstRouteSession.id} on ${routeSessionKey}`)
}
routeSessions.set(routeSessionKey, testCase)
}
for (const [id, contract] of coreContracts) {
const testCase = ids.get(id)
if (!testCase) {
failures.push(`core administrator runtime case is missing: ${id}`)
continue
}
if (testCase.session !== contract.session) {
failures.push(`${id} must use ${contract.session} session, got ${testCase.session || '-'}`)
}
if (expectedRouteHash(testCase.route) !== expectedRouteHash(contract.route)) {
failures.push(`${id} must visit ${contract.route}, got ${testCase.route}`)
}
if (coreCaseUsesNonProductRoute(testCase)) {
failures.push(`${id} must visit the production administrator page route, not a module workspace or generated legacy page: ${testCase.route}`)
}
for (const text of runtimeForbiddenText(testCase)) {
if (testCase.expected.some((expected) => String(expected).includes(text))) {
failures.push(`${id} expected text includes forbidden preview/migration copy: ${text}`)
}
}
}
if (failures.length) {
throw new Error(`Admin runtime case definition check failed:\n- ${failures.join('\n- ')}`)
}
return {
id: 'admin-runtime-case-definitions',
status: 'passed',
totalCases: cases.length,
coreCases: coreAdminRuntimeCaseIds,
coreCaseCount: coreAdminRuntimeCaseIds.length,
warnings,
selectedByDefault: !requestedCaseIds.length,
selectableWith: 'MODERN_UI_ADMIN_RUNTIME_CASES=core-admin or MODERN_UI_ADMIN_RUNTIME_CASES=case-a,case-b',
listCommand: 'npm --prefix plugins/modern-ui/app run verify:admin-runtime -- --list-cases',
checkCommand: 'npm --prefix plugins/modern-ui/app run verify:admin-runtime -- --check-cases'
}
}
function sameRouteDuplicateAllowed(first, second) {
return Boolean(first?.allowDuplicateRoute || second?.allowDuplicateRoute)
}
function coreCaseUsesNonProductRoute(testCase) {
const hash = expectedRouteHash(testCase.route)
if (hash.startsWith('#/module/')) return true
if (hash.startsWith('#/pages/')) return true
return hash.includes('preview') || hash.includes('migration')
}
function runtimeForbiddenText(testCase) {
return Array.from(new Set([
...(testCase.forbidden || []),
...(testCase.session === 'admin' ? globalAdminForbiddenText : [])
]))
}
function createNetworkDiagnostics(client) {
const requests = new Map()
const failures = []
const criticalTypes = new Set(['Document', 'Script', 'Stylesheet'])
const diagnosticTypes = new Set([...criticalTypes, 'Fetch', 'XHR'])
function remember(requestId, payload) {
if (!requestId) return
requests.set(requestId, {
...(requests.get(requestId) || {}),
...payload
})
}
function addFailure(failure) {
const type = String(failure.type || '')
if (!diagnosticTypes.has(type)) return
const businessEmptyData = isBusinessEmptyDataFailure(failure)
failures.push({
...failure,
critical: !businessEmptyData && criticalTypes.has(type),
authFailure: !businessEmptyData && isAuthFailure(failure),
businessEmptyData,
businessEmptyReason: businessEmptyData ? optionalEbayBusinessEmptyReason : undefined
})
}
function isAuthFailure(failure) {
const status = Number(failure.status || 0)
return (status === 401 || status === 403) && String(failure.url || '').includes('/api/')
}
function isBusinessEmptyDataFailure(failure) {
const status = Number(failure.status || 0)
if (status !== 403 && status !== 404) return false
return optionalEbayEntityNames.has(entityNameFromEntityApiUrl(failure.url))
}
client.addEventListener('Network.requestWillBeSent', (event) => {
remember(event.params?.requestId, {
url: event.params?.request?.url || '',
type: event.params?.type || ''
})
})
client.addEventListener('Network.responseReceived', (event) => {
const status = Number(event.params?.response?.status || 0)
const requestId = event.params?.requestId
const request = requests.get(requestId) || {}
const type = event.params?.type || request.type || ''
const url = event.params?.response?.url || request.url || ''
remember(requestId, { url, type, status })
if (status >= 400) {
addFailure({
type,
url,
status,
errorText: `HTTP ${status}`
})
}
})
client.addEventListener('Network.loadingFailed', (event) => {
const request = requests.get(event.params?.requestId) || {}
addFailure({
type: event.params?.type || request.type || '',
url: request.url || '',
status: 0,
errorText: event.params?.errorText || 'loading failed'
})
})
return {
failures: () => failures.filter((failure) => !failure.businessEmptyData),
businessEmptyData: () => failures.filter((failure) => failure.businessEmptyData),
authFailures: () => failures.filter((failure) => failure.authFailure),
criticalFailures: () => failures.filter((failure) => failure.critical),
summary: () => failures.slice(0, 12)
}
}
function entityNameFromEntityApiUrl(url) {
try {
const { pathname } = new URL(String(url || ''))
const marker = '/api/v1/entities/'
const index = pathname.indexOf(marker)
if (index === -1) return ''
return decodeURIComponent(pathname.slice(index + marker.length).split('/')[0] || '')
} catch {
return ''
}
}
function formatNetworkFailures(failures) {
return failures
.map((failure) => `${failure.type || '-'} ${failure.status || failure.errorText || '-'} ${failure.url || '-'}`)
.join(' | ')
}
function logProgress(message) {
console.error(`[verify-admin-runtime] ${message}`)
}
async function assertBaseUrlReady() {
const anonymous = await fetchAppShell()
if (anonymous.error) {
throw new Error(`Modern UI base URL is not reachable: ${baseUrl}/. Start OFBiz or set MODERN_UI_BASE_URL to a reachable /modern/app/ endpoint. This check uses local Chrome/CDP and does not depend on Codex in-app Browser (iab). Cause: ${fetchFailureDetail(anonymous.error)}.${localNetworkHint(anonymous.error, 'npm --prefix plugins/modern-ui/app run verify:admin-runtime')}`)
}
if (isAppShellResponse(anonymous)) return
if (anonymous.response && [401, 403].includes(anonymous.response.status)) {
logProgress(`base URL anonymous check returned HTTP ${anonymous.response.status}; retrying with Modern API login cookies`)
const cookies = await loginApiSession()
const cookieHeader = cookieHeaderForUrl(cookies, `${baseUrl}/`)
const authenticated = await fetchAppShell(cookieHeader)
if (isAppShellResponse(authenticated)) return
throw new Error(`Modern UI base URL returned ${appShellFailureDetail(authenticated)} after authenticated retry; anonymous check returned ${appShellFailureDetail(anonymous)}. The retry used ${modernSecuredLoginIdCookieName} plus securedLoginToken derived from /api/v1/login. This check uses local Chrome/CDP and does not depend on Codex in-app Browser (iab).`)
}
if (anonymous.response && !anonymous.response.ok) {
throw new Error(`Modern UI base URL returned HTTP ${anonymous.response.status}: ${baseUrl}/. Body: ${snippet(anonymous.text)}. This check uses local Chrome/CDP and does not depend on Codex in-app Browser (iab).`)
}
throw new Error(`Modern UI app shell was not returned from ${baseUrl}/. Expected the Vite/OFBiz /modern/app/ HTML shell before opening local Chrome/CDP. Response: ${appShellFailureDetail(anonymous)}.`)
}
function isAppShellResponse(result) {
if (!result?.response?.ok) return false
const text = String(result.text || '')
return text.toLowerCase().includes('<!doctype html') || text.includes('<div id="app"')
}
function appShellFailureDetail(result) {
if (result?.error) return `fetch error ${fetchFailureDetail(result.error)}`
if (result?.response) {
return `HTTP ${result.response.status} content-type=${result.response.headers.get('content-type') || '-'} body=${snippet(result.text)}`
}
return 'no response'
}
function snippet(value, maxLength = 240) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text
}
async function fetchAppShell(cookieHeader = '') {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), baseUrlTimeoutMs)
try {
const response = await fetch(`${baseUrl}/`, {
method: 'GET',
headers: cookieHeader ? { Cookie: cookieHeader } : {},
signal: controller.signal
})
const text = await response.text().catch(() => '')
return { response, text }
} catch (error) {
return { error }
} finally {
clearTimeout(timeout)
}
}
async function loginApiSession() {
const loginUrl = `${apiBaseUrl}/v1/login`
const response = await fetch(loginUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: adminUsername,
password: adminPassword
})
})
const text = await response.text()
if (!response.ok) {
throw new Error(`Unable to login to Modern API for admin runtime verification: HTTP ${response.status} ${text}`)
}
const payload = parseJson(text)
const userLoginId = payload?.data?.user?.userLoginId || adminUsername
const setCookie = response.headers.getSetCookie ? response.headers.getSetCookie() : splitSetCookieHeader(response.headers.get('set-cookie'))
const cookies = setCookie
.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 splitSetCookieHeader(header) {
if (!header) return []
return header.split(/,(?=\s*[^;,=\s]+=[^;,]*)/).map((item) => item.trim()).filter(Boolean)
}
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: new URL(apiBaseUrl).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) return normalized
if (!hasCookieForUrl(normalized, securedLoginToken.name, `${baseUrl}/`)) {
normalized.push(normalizeCookieForRuntime({
...securedLoginToken,
domain: baseUrlParts.hostname,
path: '/'
}))
}
if (!normalized.some((cookie) => cookie.name === modernSecuredLoginIdCookieName && pathMatches(modernWebappContextPath, cookie.path || '/'))) {
normalized.push(normalizeCookieForRuntime({
name: modernSecuredLoginIdCookieName,
value: userLoginId,
domain: baseUrlParts.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 (baseUrlParts.protocol === 'http:' || apiBaseUrlParts.protocol === 'http:')
&& [baseUrlParts.hostname, apiBaseUrlParts.hostname].every(isLoopbackHost)
}
function isLoopbackHost(hostname) {
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(String(hostname || '').toLowerCase())
}
function cookieHeaderForUrl(cookies, url) {
return cookies
.filter((cookie) => cookieAppliesToUrl(cookie, url))
.sort((left, right) => String(right.path || '/').length - String(left.path || '/').length)
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ')
}
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}/`)
}
function pageTextExpression() {
return `document.body ? document.body.innerText : ''`
}
function scopedTextExpression(selector) {
return selector
? `(() => { const node = document.querySelector(${JSON.stringify(selector)}); return node ? node.innerText : ''; })()`
: pageTextExpression()
}
function visibleExpectedText(testCase) {
return testCase.expected.filter((text) => !isTechnicalContractToken(text))
}
function isTechnicalContractToken(text) {
const value = String(text || '')
if (!value) return false
return /^[A-Za-z][A-Za-z0-9_]*$/.test(value) || /^[A-Z0-9_]+$/.test(value)
}
async function runCase(testCase, chrome) {
const { session, client, network, consoleCapture, browserSetup } = await openPreparedClient(testCase, chrome)
try {
await consoleCapture.reset()
await client.command('Page.navigate', { url: `${baseUrl}/${testCase.route}` })
const visibleExpected = visibleExpectedText(testCase)
try {
await waitForExpression(client, "document.querySelector('#app') && document.body.innerText.trim().length > 0")
await waitForExpression(client, runtimeRouteHashWaitExpression(testCase.route))
await waitForExpression(client, visibleExpected.map((text) => `document.body.innerText.includes(${JSON.stringify(text)})`).join(' && '))
} catch (error) {
throw new Error(`${error.message}; diagnostics=${await runtimeDiagnostics(client, testCase.id)}; network=${JSON.stringify(network.summary(), null, 2)}`)
}
const text = await evaluate(client, pageTextExpression())
const forbiddenText = testCase.forbiddenScope
? await evaluate(client, scopedTextExpression(testCase.forbiddenScope))
: text
const missing = visibleExpected.filter((item) => !String(text).includes(item))
const forbidden = runtimeForbiddenText(testCase).filter((item) => String(forbiddenText).includes(item))
const policySnapshot = await evaluate(client, adminRuntimePolicySnapshotExpression(testCase.route))
const policyResult = assertAdminRuntimePolicy(policySnapshot, testCase)
if (networkSettleMs > 0) {
await delay(networkSettleMs)
}
const rawConsoleErrors = await consoleCapture.snapshot()
const actionableErrors = actionableConsoleErrors(rawConsoleErrors)
const networkFailures = network.failures()
const authFailures = network.authFailures()
const criticalNetworkFailures = network.criticalFailures()
if (missing.length || forbidden.length) {
throw new Error(`${testCase.id} failed; missing=${missing.join(',') || '-'} forbidden=${forbidden.join(',') || '-'}`)
}
if (policyResult.failures.length) {
throw new Error(`${testCase.id} failed administrator runtime policy: ${policyResult.failures.join(' | ')}; snapshot=${JSON.stringify(policySnapshot, null, 2)}`)
}
if (authFailures.length) {
throw new Error(`${testCase.id} emitted authenticated API/network failure(s): ${formatNetworkFailures(authFailures)}`)
}
if (criticalNetworkFailures.length) {
throw new Error(`${testCase.id} failed to load critical browser resource(s): ${formatNetworkFailures(criticalNetworkFailures)}`)
}
if (networkFailures.length) {
throw new Error(`${testCase.id} emitted runtime network failure(s): ${formatNetworkFailures(networkFailures)}`)
}
if (actionableErrors.length) {
throw new Error(`${testCase.id} emitted ${actionableErrors.length} runtime console error(s): ${actionableErrors.join(' | ')}`)
}
return {
id: testCase.id,
route: testCase.route,
session: testCase.session,
status: 'passed',
textLength: String(text).length,
policy: policyResult,
consoleErrors: rawConsoleErrors,
ignoredConsoleErrors: rawConsoleErrors.length - actionableErrors.length,
networkFailures,
businessEmptyData: network.businessEmptyData(),
authFailures,
browserSetup
}
} finally {
client.close()
await closeChrome(session)
}
}
async function openPreparedClient(testCase, chrome) {
let lastError = null
const setupTracker = createBrowserSetupTracker({
scope: `admin runtime case ${testCase.id}`,
maxAttempts: browserSetupAttempts,
chrome,
candidates: browserExecutionContext(chrome, browserSetupAttempts).localChromeCdp.candidates
})
for (let attempt = 1; attempt <= browserSetupAttempts; attempt += 1) {
let session = null
let client = null
try {
session = await launchChrome(chrome, {
chromeStartupTimeoutMs,
profilePrefix: 'ofbiz-modern-admin-runtime.'
})
client = new CdpConnection(session.wsUrl, cdpCommandTimeoutMs)
await client.open()
const network = createNetworkDiagnostics(client)
await client.command('Page.enable')
await client.command('Network.enable')
await client.command('Runtime.enable')
const consoleCapture = await installConsoleErrorCapture(client)
if (testCase.session === 'admin') {
for (const cookie of await loginApiSession()) {
await setRuntimeCookie(client, cookie)
}
}
await installSessionMock(client, testCase.session === 'admin' ? mockedSession : unauthenticatedSession)
return {
session,
client,
network,
consoleCapture,
browserSetup: setupTracker.snapshot({
status: 'passed',
attemptsUsed: attempt,
caseId: testCase.id
})
}
} catch (error) {
lastError = error
setupTracker.record({ attempt, error })
client?.close()
if (session) {
await closeChrome(session).catch(() => {})
}
if (!isRetryableBrowserSetupError(error) || attempt >= browserSetupAttempts) {
break
}
logProgress(`Chrome/CDP setup attempt ${attempt}/${browserSetupAttempts} for ${testCase.id} failed with ${setupTracker.attempts.at(-1)?.category}; retrying within MODERN_UI_BROWSER_SETUP_ATTEMPTS=${browserSetupAttempts}: ${error.message}`)
}
}
const failure = new Error(summarizeBrowserSetupFailure({
scope: `admin runtime case ${testCase.id}`,
attempts: setupTracker.attempts,
maxAttempts: browserSetupAttempts,
chrome,
candidates: setupTracker.snapshot().candidates,
lastError
}))
failure.browserSetup = setupTracker.snapshot({
status: 'failed',
caseId: testCase.id
})
throw failure
}
async function setRuntimeCookie(client, cookie) {
const result = await client.command('Network.setCookie', cookie)
if (result?.success === false) {
throw new Error(`Chrome rejected runtime cookie ${cookie.name} for domain=${cookie.domain || '-'} path=${cookie.path || '-'}`)
}
if (process.env.MODERN_UI_ADMIN_RUNTIME_DEBUG === '1') {
logProgress(`set cookie ${cookie.name} domain=${cookie.domain || '-'} path=${cookie.path || '-'} secure=${cookie.secure ? 'Y' : 'N'}`)
}
}
function runtimeCaseSummary(testCase) {
return {
id: testCase.id,
route: testCase.route,
session: testCase.session,
expectedVisibleText: visibleExpectedText(testCase),
forbidden: runtimeForbiddenText(testCase)
}
}
function runtimeCaseCatalog(cases) {
return cases.map(runtimeCaseSummary)
}
function createRuntimeReport({
status,
chrome = null,
cases,
coverage,
definitionCheck,
results = [],
failedCase = null,
error = null
}) {
return {
status,
generatedAt: new Date().toISOString(),
baseUrl: `${baseUrl}/`,
chrome,
browserSetup: chrome ? browserExecutionContext(chrome, browserSetupAttempts) : null,
selectedBy: requestedCaseIds.length ? 'MODERN_UI_ADMIN_RUNTIME_CASES' : 'default-all-runtime-cases',
checked: results.length,
totalSelected: cases.length,
coverage,
definitionCheck,
failedCase,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack
} : null,
cases: runtimeCaseCatalog(cases),
results
}
}
function markdownTable(rows) {
return [
'| Case | Route | Session | Status |',
'| --- | --- | --- | --- |',
...rows.map((row) => `| ${escapeMarkdownTable(row.id)} | ${escapeMarkdownTable(row.route)} | ${escapeMarkdownTable(row.session)} | ${escapeMarkdownTable(row.status || 'selected')} |`)
].join('\n')
}
function escapeMarkdownTable(value) {
return String(value ?? '').replaceAll('|', '\\|')
}
function runtimeReportMarkdown(report) {
const resultById = new Map((report.results || []).map((result) => [result.id, result]))
const caseRows = (report.cases || []).map((testCase) => ({
...testCase,
status: resultById.get(testCase.id)?.status || (report.failedCase?.id === testCase.id ? 'failed' : 'pending')
}))
return [
'# Modern UI Admin Runtime Verification',
'',
`Generated: ${report.generatedAt}`,
'',
`Status: ${report.status}`,
`Base URL: ${report.baseUrl}`,
`Checked: ${report.checked}/${report.totalSelected}`,
`Selected by: ${report.selectedBy}`,
'',
'## Coverage',
'',
`- Required core cases: ${report.coverage?.required?.length || 0}`,
`- Missing core cases: ${report.coverage?.missing?.length ? report.coverage.missing.join(', ') : '-'}`,
`- Definition warnings: ${report.definitionCheck?.warnings?.length ? report.definitionCheck.warnings.join('; ') : '-'}`,
'',
'## Cases',
'',
markdownTable(caseRows),
'',
report.error ? '## Error' : '',
report.error ? '' : '',
report.error ? `- ${report.error.message}` : '',
''
].filter((line, index, lines) => line || lines[index - 1] !== '').join('\n')
}
async function writeRuntimeReport(report, files = { json: outJson, markdown: outMd }) {
await mkdir(outDir, { recursive: true })
await writeFile(files.json, `${JSON.stringify(report, null, 2)}\n`)
await writeFile(files.markdown, runtimeReportMarkdown(report))
return files
}
async function main() {
const overallTimeout = setTimeout(() => {
console.error(`[verify-admin-runtime] overall timeout after ${overallTimeoutMs}ms`)
process.exit(124)
}, overallTimeoutMs)
overallTimeout.unref?.()
const cases = selectedRuntimeCases()
const definitionCheck = assertRuntimeCaseDefinitions(runtimeCases)
const coverage = assertAdminRuntimeCaseCoverage(cases, { enforceDefaultCoverage: !requestedCaseIds.length })
if (listCasesOnly || checkCaseDefinitionsOnly) {
clearTimeout(overallTimeout)
const report = createRuntimeReport({
status: 'passed',
cases,
coverage,
definitionCheck
})
const reports = await writeRuntimeReport(report, {
json: caseOutJson,
markdown: caseOutMd
})
console.log(JSON.stringify({
status: 'passed',
checked: 0,
totalSelected: cases.length,
coverage,
definitionCheck,
cases: runtimeCaseCatalog(cases),
reports
}, null, 2))
return
}
logProgress(`checking base URL ${baseUrl}/`)
await assertBaseUrlReady()
const chrome = findChrome()
if (!chrome) {
throw new Error(summarizeBrowserSetupFailure({
scope: 'admin runtime verification',
maxAttempts: browserSetupAttempts,
chrome,
candidates: browserExecutionContext(chrome, browserSetupAttempts).localChromeCdp.candidates,
lastError: new Error('Chrome executable not found. Set CHROME_BIN or install Google Chrome/Microsoft Edge.')
}))
}
logProgress(`using local Chrome/CDP: ${chrome}`)
const results = []
try {
for (const [index, testCase] of cases.entries()) {
logProgress(`runtime ${index + 1}/${cases.length}: ${testCase.id}`)
results.push(await runCase(testCase, chrome))
}
} catch (error) {
const failedCase = cases[results.length]
? runtimeCaseSummary(cases[results.length])
: null
await writeRuntimeReport(createRuntimeReport({
status: 'failed',
chrome,
cases,
coverage,
definitionCheck,
results,
failedCase,
error
}))
throw error
}
clearTimeout(overallTimeout)
const report = createRuntimeReport({
status: 'passed',
chrome,
cases,
coverage,
definitionCheck,
results
})
await writeRuntimeReport(report)
console.log(JSON.stringify({
...report,
reports: {
json: outJson,
markdown: outMd
}
}, null, 2))
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})