#!/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('')) { 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 })