恢复点(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>
1850 lines
71 KiB
JavaScript
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
|
|
})
|