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