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