SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)

恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,521 @@
import { accessSync, constants, existsSync } from 'node:fs'
import { mkdtemp, rm, readFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { spawn } from 'node:child_process'
import { setTimeout as delay } from 'node:timers/promises'
export const chromeCandidates = [
process.env.CHROME_BIN,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'google-chrome',
'chromium',
'chrome'
].filter(Boolean)
export function commandExists(command) {
if (!command || command.includes('/')) return command && existsSync(command)
return (process.env.PATH || '').split(path.delimiter).some((directory) => {
if (!directory) return false
try {
accessSync(path.join(directory, command), constants.X_OK)
return true
} catch {
return false
}
})
}
export function findChrome() {
return chromeCandidates.find(commandExists) || ''
}
export function localChromeCdpStatus(chrome = findChrome()) {
return {
available: Boolean(chrome),
executable: chrome || null,
candidates: chromeCandidates.map((candidate) => ({
command: candidate,
available: commandExists(candidate)
}))
}
}
export function codexInAppBrowserStatus() {
return {
available: false,
checked: false,
reason: 'Codex in-app Browser is not used by this Node verification script. Check agent.browsers.list() from an agent runtime before routing work to iab.'
}
}
export function browserExecutionContext(chrome = findChrome(), maxAttempts = 1) {
return {
mode: 'local Chrome/CDP',
localChromeCdp: localChromeCdpStatus(chrome),
codexInAppBrowser: codexInAppBrowserStatus(),
boundedAttempts: Math.max(1, Number(maxAttempts) || 1)
}
}
export function classifyBrowserSetupError(error) {
const message = String(error?.message || '')
const rules = [
['chrome-not-found', ['Chrome executable not found', 'Set CHROME_BIN']],
['chrome-launch-failed', ['Chrome failed to start', 'Chrome exited before DevTools was ready']],
['cdp-port-unavailable', ['Chrome did not expose DevToolsActivePort']],
['cdp-websocket-unavailable', ['Timed out connecting to Chrome DevTools', 'Chrome DevTools WebSocket failed', 'No Chrome page target available', 'Chrome DevTools socket is not open']],
['cdp-command-timeout', ['Timed out running Chrome DevTools command']],
['runtime-auth-setup-failed', ['Chrome rejected runtime cookie', 'Unable to login to Modern API', 'Modern API login did not return a session cookie']],
['base-url-unreachable', ['Modern UI base URL is not reachable', 'Modern UI base URL is not ready', 'Start OFBiz', 'MODERN_UI_BASE_URL']],
['app-shell-unavailable', ['Modern UI app shell was not returned']]
]
for (const [category, needles] of rules) {
if (needles.some((needle) => message.includes(needle))) return category
}
return 'unknown-browser-setup-error'
}
export function isRetryableBrowserSetupError(error) {
return new Set([
'chrome-launch-failed',
'cdp-port-unavailable',
'cdp-websocket-unavailable',
'cdp-command-timeout'
]).has(classifyBrowserSetupError(error))
}
export function createBrowserSetupTracker({ scope, maxAttempts, chrome = '', candidates = [] } = {}) {
const attempts = []
const boundedAttempts = Math.max(1, Number(maxAttempts) || 1)
return {
scope: scope || 'browser setup',
maxAttempts: boundedAttempts,
chrome: chrome || null,
candidates,
attempts,
record({ attempt, chrome: attemptChrome = chrome, error, retryable } = {}) {
const category = classifyBrowserSetupError(error)
attempts.push({
attempt,
maxAttempts: boundedAttempts,
chrome: attemptChrome || null,
category,
retryable: retryable ?? isRetryableBrowserSetupError(error),
message: String(error?.message || error || 'unknown error')
})
},
snapshot(extra = {}) {
const last = attempts[attempts.length - 1] || null
return {
scope: scope || 'browser setup',
mode: 'local Chrome/CDP',
maxAttempts: boundedAttempts,
attemptsUsed: attempts.length,
chrome: chrome || null,
candidates,
attempts: attempts.slice(),
lastCategory: last?.category || null,
localChromeCdp: localChromeCdpStatus(chrome),
codexInAppBrowser: codexInAppBrowserStatus(),
boundedAttempts: boundedAttempts,
...extra
}
}
}
}
export function summarizeBrowserSetupFailure({ scope, attempts = [], maxAttempts = 1, chrome = '', candidates = [], lastError = null } = {}) {
const boundedAttempts = Math.max(1, Number(maxAttempts) || 1)
const last = attempts[attempts.length - 1] || null
const category = last?.category || classifyBrowserSetupError(lastError)
const lines = [
`Unable to initialize local Chrome/CDP${scope ? ` for ${scope}` : ''} after ${attempts.length || 0}/${boundedAttempts} bounded attempt(s).`,
`Failure category: ${category}.`,
'Codex in-app Browser (iab) status: not used by this Node script; do not retry iab unless agent.browsers.list() shows it is available.',
`Local Chrome/CDP status: ${chrome ? `candidate ${chrome}` : 'no executable selected'}; ${candidates.length ? `${candidates.filter((item) => item.available).length}/${candidates.length} candidate(s) available` : 'candidate list unavailable'}.`,
`Last error: ${last?.message || lastError?.message || 'unknown error'}.`
]
if (attempts.length) {
lines.push(...attempts.map((attempt) => `attempt ${attempt.attempt}/${attempt.maxAttempts} [${attempt.category}] retryable=${attempt.retryable}: ${attempt.message}`))
}
return lines.join('\n')
}
export function waitForProcessExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
return new Promise((resolve) => {
const timeout = setTimeout(() => {
child.off('exit', onExit)
resolve(false)
}, timeoutMs)
function onExit() {
clearTimeout(timeout)
resolve(true)
}
child.once('exit', onExit)
})
}
async function waitForDevToolsPort(profile, chromeStartupTimeoutMs, getEarlyExit = () => null) {
const activePortFile = path.join(profile, 'DevToolsActivePort')
const deadline = Date.now() + chromeStartupTimeoutMs
let lastError = null
while (Date.now() < deadline) {
const earlyExit = getEarlyExit()
if (earlyExit) {
if (earlyExit.error) {
throw new Error(`Chrome failed to start: ${earlyExit.error.message}`)
}
throw new Error(`Chrome exited before DevTools was ready: code=${earlyExit.code ?? 'null'} signal=${earlyExit.signal ?? 'null'}`)
}
try {
const text = await readFile(activePortFile, 'utf8')
const [port] = text.trim().split('\n')
if (port) return port
} catch (error) {
lastError = error
}
await delay(100)
}
throw new Error(lastError?.message || `Chrome did not expose DevToolsActivePort within ${chromeStartupTimeoutMs}ms`)
}
export class CdpConnection {
constructor(wsUrl, cdpCommandTimeoutMs = 10000) {
this.wsUrl = wsUrl
this.cdpCommandTimeoutMs = cdpCommandTimeoutMs
this.nextId = 1
this.pending = new Map()
this.eventHandlers = new Map()
}
async open() {
this.socket = new WebSocket(this.wsUrl)
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out connecting to Chrome DevTools')), this.cdpCommandTimeoutMs)
this.socket.addEventListener('open', () => {
clearTimeout(timeout)
resolve()
}, { once: true })
this.socket.addEventListener('error', () => {
clearTimeout(timeout)
reject(new Error('Chrome DevTools WebSocket failed'))
}, { once: true })
})
this.socket.addEventListener('message', (event) => {
const message = JSON.parse(String(event.data))
if (message.id && this.pending.has(message.id)) {
const { resolve, reject, timeout } = this.pending.get(message.id)
clearTimeout(timeout)
this.pending.delete(message.id)
if (message.error) {
reject(new Error(message.error.message || JSON.stringify(message.error)))
} else {
resolve(message.result)
}
} else if (message.method) {
for (const handler of this.eventHandlers.get(message.method) || []) {
Promise.resolve(handler(message)).catch(() => {})
}
}
})
}
addEventListener(method, handler) {
const handlers = this.eventHandlers.get(method) || []
handlers.push(handler)
this.eventHandlers.set(method, handlers)
}
command(method, params = {}, timeoutMs = this.cdpCommandTimeoutMs) {
const id = this.nextId++
const payload = JSON.stringify({ id, method, params })
return new Promise((resolve, reject) => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
reject(new Error(`Chrome DevTools socket is not open for ${method}`))
return
}
const timeout = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`Timed out running Chrome DevTools command: ${method}`))
}, timeoutMs)
this.pending.set(id, { resolve, reject, timeout })
this.socket.send(payload)
})
}
close() {
for (const [id, pending] of this.pending.entries()) {
clearTimeout(pending.timeout)
pending.reject(new Error('Chrome DevTools connection closed'))
this.pending.delete(id)
}
try {
this.socket?.close()
} catch {
// Nothing to clean up.
}
}
}
export async function launchChrome(chrome, options = {}) {
const chromeStartupTimeoutMs = Number(options.chromeStartupTimeoutMs || 15000)
const profilePrefix = options.profilePrefix || 'ofbiz-modern-chrome.'
const windowSize = options.windowSize || '1440,1000'
const profile = await mkdtemp(path.join(os.tmpdir(), profilePrefix))
const args = [
'--headless=new',
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-default-browser-check',
'--disable-background-networking',
'--disable-component-update',
'--disable-sync',
'--disable-extensions',
'--remote-debugging-port=0',
`--window-size=${windowSize}`,
`--user-data-dir=${profile}`,
'about:blank'
]
const child = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
let earlyExit = null
let launchError = null
child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
})
child.once('error', (error) => {
launchError = error
})
child.once('exit', (code, signal) => {
earlyExit = { code, signal }
})
try {
const port = await waitForDevToolsPort(profile, chromeStartupTimeoutMs, () => launchError ? { error: launchError } : earlyExit)
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json())
const pageTarget = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl)
if (!pageTarget) throw new Error('No Chrome page target available')
return {
child,
profile,
wsUrl: pageTarget.webSocketDebuggerUrl,
stderr: () => stderr
}
} catch (error) {
try {
child.kill('SIGTERM')
} catch {
// The process may never have spawned.
}
await waitForProcessExit(child, 1000)
await rm(profile, { recursive: true, force: true })
const stderrTail = stderr.trim().slice(-4000)
throw new Error(stderrTail ? `${error.message}; Chrome stderr: ${stderrTail}` : error.message)
}
}
export async function closeChrome(session) {
if (session.child.exitCode === null && session.child.signalCode === null) {
session.child.kill('SIGTERM')
}
const terminated = await waitForProcessExit(session.child, 1500)
if (!terminated && session.child.exitCode === null && session.child.signalCode === null) {
session.child.kill('SIGKILL')
await waitForProcessExit(session.child, 1500)
}
await rm(session.profile, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
}
export async function evaluate(client, expression, timeoutMs = 5000) {
const result = await client.command('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
timeout: timeoutMs
})
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime.evaluate failed')
return result.result?.value
}
const consoleErrorCaptureSource = `
(() => {
const key = '__modernRuntimeConsoleErrors'
const serialize = (value) => {
try {
if (typeof value === 'string') return value
if (value && typeof value === 'object' && value.stack) return String(value.stack)
if (value && typeof value === 'object' && value.message) return String(value.message)
return JSON.stringify(value)
} catch {
return String(value)
}
}
const bucket = Array.isArray(window[key]) ? window[key] : []
try {
Object.defineProperty(window, key, { value: bucket, configurable: true, writable: true })
} catch {
window[key] = bucket
}
if (window.__modernRuntimeConsoleCaptureInstalled) return
try {
Object.defineProperty(window, '__modernRuntimeConsoleCaptureInstalled', { value: true, configurable: true })
} catch {
window.__modernRuntimeConsoleCaptureInstalled = true
}
const originalError = console.error ? console.error.bind(console) : () => {}
console.error = (...args) => {
bucket.push(args.map(serialize).filter(Boolean).join(' '))
return originalError(...args)
}
window.addEventListener('error', (event) => {
bucket.push(event.message || (event.error && serialize(event.error)) || 'window error')
})
window.addEventListener('unhandledrejection', (event) => {
bucket.push(event.reason ? serialize(event.reason) : 'unhandled rejection')
})
})()
`
function remoteObjectText(remoteObject) {
if (!remoteObject) return ''
if (typeof remoteObject.description === 'string') return remoteObject.description
if (remoteObject.value !== undefined) return String(remoteObject.value)
if (remoteObject.unserializableValue !== undefined) return String(remoteObject.unserializableValue)
return remoteObject.type || ''
}
function uniqueConsoleErrors(errors) {
const seen = new Set()
const unique = []
for (const error of errors) {
const text = String(error || '').trim()
if (!text || seen.has(text)) continue
seen.add(text)
unique.push(text)
}
return unique
}
export async function installConsoleErrorCapture(client) {
const eventErrors = []
client.addEventListener('Runtime.consoleAPICalled', (event) => {
if (event.params?.type !== 'error') return
const text = (event.params?.args || []).map(remoteObjectText).filter(Boolean).join(' ')
eventErrors.push(text || 'console.error')
})
client.addEventListener('Runtime.exceptionThrown', (event) => {
const details = event.params?.exceptionDetails || {}
const text = remoteObjectText(details.exception) || details.text || 'runtime exception'
eventErrors.push(text)
})
await client.command('Page.addScriptToEvaluateOnNewDocument', {
source: consoleErrorCaptureSource
})
return {
eventErrors: () => uniqueConsoleErrors(eventErrors),
async reset() {
eventErrors.length = 0
await evaluate(client, 'window.__modernRuntimeConsoleErrors = []').catch(() => undefined)
},
async snapshot() {
const pageErrors = await evaluate(client, 'window.__modernRuntimeConsoleErrors || []').catch(() => [])
return uniqueConsoleErrors([
...eventErrors,
...(Array.isArray(pageErrors) ? pageErrors : [])
])
}
}
}
export async function waitForExpression(client, expression, timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const value = await evaluate(client, `Boolean(${expression})`, 2000).catch(() => false)
if (value) return
await delay(150)
}
throw new Error(`Timed out waiting for expression: ${expression}`)
}
const fetchMockState = new WeakMap()
export function installSessionMock(client, sessionPayload) {
return installApiMock(client, {
'*://*/api/v1/session*': sessionPayload
})
}
export async function installApiMock(client, handlers) {
const entries = Object.entries(handlers || {})
if (!entries.length) return
const state = fetchMockState.get(client) || {
handlers: new Map(),
listening: false
}
for (const [urlPattern, handler] of entries) {
state.handlers.set(urlPattern, handler)
}
fetchMockState.set(client, state)
await client.command('Fetch.enable', {
patterns: Array.from(state.handlers.keys()).map((urlPattern) => ({ urlPattern, requestStage: 'Request' }))
})
if (state.listening) return
state.listening = true
client.addEventListener('Fetch.requestPaused', async (event) => {
const requestId = event.params?.requestId
const url = event.params?.request?.url || ''
if (!requestId) return
const activeState = fetchMockState.get(client) || state
const match = Array.from(activeState.handlers.entries()).find(([urlPattern]) => urlMatchesPattern(url, urlPattern))
if (!match) {
await client.command('Fetch.continueRequest', { requestId }).catch(() => {})
return
}
const payload = typeof match[1] === 'function' ? match[1](url, event.params?.request || {}) : match[1]
await client.command('Fetch.fulfillRequest', {
requestId,
responseCode: 200,
responseHeaders: [
{ name: 'Content-Type', value: 'application/json; charset=utf-8' },
{ name: 'Cache-Control', value: 'no-store' }
],
body: Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
}).catch(() => {})
})
}
function urlMatchesPattern(url, pattern) {
const escaped = String(pattern)
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
return new RegExp(`^${escaped}$`).test(url)
}
export async function runtimeDiagnostics(client, id) {
const value = await evaluate(client, `(() => {
const app = document.querySelector('#app')
return {
id: ${JSON.stringify(id)},
href: location.href,
readyState: document.readyState,
hasApp: Boolean(app),
appChildCount: app ? app.childElementCount : -1,
appHtml: app ? app.innerHTML.slice(0, 800) : '',
bodyText: document.body ? document.body.innerText.slice(0, 1200) : '',
consoleErrors: window.__modernRuntimeConsoleErrors || []
}
})()`, 2000).catch((error) => ({
id,
evaluateError: error.message
}))
return JSON.stringify(value, null, 2)
}
@@ -0,0 +1,12 @@
const benignConsoleErrorPatterns = [
/^ResizeObserver loop completed with undelivered notifications\.$/,
/^ResizeObserver loop limit exceeded\.?$/
]
export function actionableConsoleErrors(errors) {
if (!Array.isArray(errors)) return []
return errors
.map((error) => String(error || '').trim())
.filter(Boolean)
.filter((error) => !benignConsoleErrorPatterns.some((pattern) => pattern.test(error)))
}
@@ -0,0 +1,50 @@
/*
* 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.
*/
export function fetchFailureDetail(error) {
const parts = [String(error?.message || 'fetch failed')]
const cause = error?.cause
if (cause) {
const fields = [
cause.code ? `code=${cause.code}` : '',
cause.syscall ? `syscall=${cause.syscall}` : '',
cause.address ? `address=${cause.address}` : '',
cause.port ? `port=${cause.port}` : ''
].filter(Boolean)
if (cause.message && !parts.includes(cause.message)) {
parts.push(cause.message)
}
if (fields.length) {
parts.push(fields.join(' '))
}
}
return parts.join('; ')
}
export function isLocalhostPermissionError(error) {
const detail = fetchFailureDetail(error)
return detail.includes('EPERM') || detail.includes('EACCES')
}
export function localNetworkHint(error, command) {
if (isLocalhostPermissionError(error)) {
return ` If this is a sandbox localhost permission blocker, rerun this command with localhost/127.0.0.1 access: ${command}. Do not switch to Codex in-app Browser (iab).`
}
return ` If this environment blocks localhost, run ${command} from the repo root or set MODERN_UI_BASE_URL to a reachable /modern/app/ endpoint; do not switch to Codex in-app Browser (iab).`
}
@@ -0,0 +1,104 @@
#!/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 { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const scriptsRoot = path.resolve(path.dirname(scriptPath), '..')
const files = {
browserRuntime: path.join(scriptsRoot, 'verify-browser-runtime.mjs'),
adminRuntime: path.join(scriptsRoot, 'verify-admin-runtime.mjs'),
screenshots: path.join(scriptsRoot, 'capture-admin-screenshots.mjs'),
chromeCdp: path.join(scriptsRoot, 'lib/chrome-cdp.mjs'),
network: path.join(scriptsRoot, 'lib/runtime-network.mjs')
}
const sources = Object.fromEntries(await Promise.all(
Object.entries(files).map(async ([id, file]) => [id, await readFile(file, 'utf8')])
))
const checks = [
{
id: 'browser-runtime-defaults-to-one-cdp-setup-attempt',
passed: sources.browserRuntime.includes("positiveIntegerEnv('MODERN_UI_BROWSER_SETUP_ATTEMPTS', 1)")
},
{
id: 'admin-runtime-defaults-to-one-cdp-setup-attempt',
passed: sources.adminRuntime.includes("positiveIntegerEnv('MODERN_UI_BROWSER_SETUP_ATTEMPTS', 1)")
&& !sources.adminRuntime.includes('attempt <= 3')
},
{
id: 'screenshots-default-to-one-base-url-and-browser-attempt',
passed: sources.screenshots.includes("positiveIntegerEnv('MODERN_UI_BASE_URL_ATTEMPTS', 1)")
&& sources.screenshots.includes("positiveIntegerEnv('MODERN_UI_BROWSER_ATTEMPTS', 1)")
},
{
id: 'runtime-scripts-route-unavailable-iab-to-chrome-cdp',
passed: [sources.browserRuntime, sources.adminRuntime, sources.screenshots].every((source) => source.includes('local Chrome/CDP'))
&& sources.network.includes('Do not switch to Codex in-app Browser (iab).')
&& sources.chromeCdp.includes('codexInAppBrowser')
&& sources.chromeCdp.includes('localChromeCdp')
&& sources.chromeCdp.includes('boundedAttempts')
&& sources.chromeCdp.includes('agent.browsers.list()')
&& [sources.browserRuntime, sources.adminRuntime, sources.screenshots].every((source) => source.includes('summarizeBrowserSetupFailure'))
},
{
id: 'browser-setup-failures-are-classified-and-bounded',
passed: sources.chromeCdp.includes('createBrowserSetupTracker')
&& sources.chromeCdp.includes('summarizeBrowserSetupFailure')
&& sources.chromeCdp.includes('classifyBrowserSetupError')
&& [sources.browserRuntime, sources.adminRuntime, sources.screenshots].every((source) => source.includes('createBrowserSetupTracker'))
&& [sources.browserRuntime, sources.adminRuntime, sources.screenshots].every((source) => source.includes('summarizeBrowserSetupFailure'))
&& [sources.browserRuntime, sources.adminRuntime, sources.screenshots].every((source) => source.includes('browserSetup'))
},
{
id: 'targeted-single-page-selection-remains-supported',
passed: sources.adminRuntime.includes('MODERN_UI_ADMIN_RUNTIME_CASES')
&& sources.adminRuntime.includes('selectedRuntimeCases()')
&& sources.screenshots.includes('MODERN_UI_SCREENSHOT_IDS')
&& sources.screenshots.includes('selectedScreenshots()')
},
{
id: 'screenshots-report-console-errors',
passed: sources.screenshots.includes('actionableConsoleErrors')
&& sources.screenshots.includes('consoleErrors:')
&& sources.screenshots.includes('ignoredConsoleErrors:')
},
{
id: 'localhost-blocker-messages-include-rerun-command',
passed: [sources.browserRuntime, sources.adminRuntime, sources.screenshots].every((source) => source.includes('npm --prefix plugins/modern-ui/app'))
&& [sources.browserRuntime, sources.adminRuntime, sources.screenshots].every((source) => source.includes('MODERN_UI_BASE_URL'))
&& sources.network.includes('EPERM')
}
]
const failed = checks.filter((check) => !check.passed)
console.log(JSON.stringify({
status: failed.length ? 'failed' : 'passed',
checkedFiles: files,
checks
}, null, 2))
if (failed.length) {
process.exitCode = 1
}
@@ -0,0 +1,58 @@
// Headless boot check for the OA replica: load key routes, capture runtime
// exceptions + console errors, confirm #app renders content. No screenshots.
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
const CHROME = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const BASE = process.env.OA_BASE || 'http://localhost:8080/modern/app/'
const ROUTES = [
'#/oa', '#/oa/contacts',
'#/oa/collab/todo', '#/oa/collab/create', '#/oa/collab/done', '#/oa/collab/center',
'#/oa/meeting/mine', '#/oa/meeting/room', '#/oa/goal/project', '#/oa/goal/schedule',
'#/oa/knowledge/doccenter', '#/oa/knowledge/portal', '#/oa/doccollab/list',
'#/oa/culture/notice', '#/oa/culture/survey', '#/oa/hr/org', '#/oa/hr/staff',
'#/oa/report/manage', '#/oa/appdev/workbench'
]
const profile = await mkdtemp(path.join(os.tmpdir(), 'oacheck.'))
const chrome = spawn(CHROME, ['--headless=new', '--disable-gpu', '--no-first-run', '--remote-debugging-port=0', '--window-size=1440,900', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
let port = ''
const dl = Date.now() + 15000
while (Date.now() < dl && !port) { try { port = (await readFile(path.join(profile, 'DevToolsActivePort'), 'utf8')).trim().split('\n')[0] } catch { await delay(150) } }
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json())
const ws = new WebSocket(targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl).webSocketDebuggerUrl)
await new Promise((res) => ws.addEventListener('open', res, { once: true }))
let id = 0
const pending = new Map()
let errors = []
ws.addEventListener('message', (e) => {
const m = JSON.parse(e.data)
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) }
else if (m.method === 'Runtime.exceptionThrown') { errors.push('EXC: ' + (m.params?.exceptionDetails?.exception?.description || m.params?.exceptionDetails?.text || 'exception').split('\n')[0]) }
else if (m.method === 'Runtime.consoleAPICalled' && m.params?.type === 'error') { errors.push('ERR: ' + (m.params.args || []).map((a) => a.value || a.description || '').join(' ').slice(0, 200)) }
})
const cmd = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })) })
const evalJs = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })).result?.value
await cmd('Page.enable'); await cmd('Runtime.enable')
const results = []
for (const route of ROUTES) {
errors = []
await cmd('Page.navigate', { url: BASE + route })
await delay(1700)
const info = await evalJs(`(() => { const app = document.querySelector('#app'); const t = app ? app.innerText : ''; return { len: t.trim().length, head: t.trim().slice(0,40).replace(/\\s+/g,' '), shell: !!document.querySelector('.oa-shell, .oa-app-shell, [class*=oa-shell], header, nav') }; })()`).catch((e) => ({ len: 0, head: 'EVAL-FAIL ' + e.message }))
results.push({ route, len: info?.len ?? 0, head: info?.head, errs: errors.slice(0, 3) })
}
let ok = 0
for (const r of results) {
const good = r.len > 80 && r.errs.length === 0
if (good) ok++
console.log(`${good ? 'OK ' : 'XX '} ${r.route.padEnd(28)} len=${String(r.len).padStart(5)} ${r.errs.length ? '['+r.errs.join(' | ')+']' : r.head}`)
}
console.log(`\nBOOTCHECK: ${ok}/${results.length} routes rendered cleanly`)
ws.close(); chrome.kill('SIGTERM'); await delay(300); await rm(profile, { recursive: true, force: true })
process.exit(0)
@@ -0,0 +1,63 @@
// Capture the OA form/flow engine in action: 新建事项 dialog → 预览 (form) → 流程 (flow) → 表单设计器.
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
const CHROME = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const BASE = process.env.OA_BASE || 'http://localhost:8080/modern/app/'
const OUT = '/Users/qiu/Desktop/ERP/ofbiz-framework/plugins/modern-ui/verification/oa-shots'
await mkdir(OUT, { recursive: true })
const profile = await mkdtemp(path.join(os.tmpdir(), 'oaeng.'))
const chrome = spawn(CHROME, ['--headless=new', '--disable-gpu', '--no-first-run', '--remote-debugging-port=0', '--window-size=1440,900', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
let port = ''
const dl = Date.now() + 15000
while (Date.now() < dl && !port) { try { port = (await readFile(path.join(profile, 'DevToolsActivePort'), 'utf8')).trim().split('\n')[0] } catch { await delay(150) } }
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json())
const ws = new WebSocket(targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl).webSocketDebuggerUrl)
await new Promise((res) => ws.addEventListener('open', res, { once: true }))
let id = 0
const pending = new Map()
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
const cmd = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })) })
const evalJs = async (expr) => { const r = await cmd('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); return r.result?.value }
const shot = async (name) => { const s = await cmd('Page.captureScreenshot', { format: 'png' }); if (s?.data) { await writeFile(path.join(OUT, name + '.png'), Buffer.from(s.data, 'base64')); console.log('saved', name) } }
// click the first element whose visible text contains `txt` (prefers buttons/clickable)
const clickByText = async (txt) => evalJs(`(() => {
const t = ${JSON.stringify(txt)};
const els = Array.from(document.querySelectorAll('button, a, .el-button, [role=button], .el-tabs__item, li, .tpl-card, .template-card, span, div'));
const hit = els.reverse().find(e => (e.innerText||e.textContent||'').trim().includes(t) && e.offsetParent !== null);
if (hit) { (hit.closest('button,a,.el-button,.el-tabs__item') || hit).click(); return true; }
// also try hidden-but-present elements (hover-revealed buttons)
const any = els.find(e => (e.innerText||e.textContent||'').trim() === t);
if (any) { any.click(); return true; }
return false;
})()`)
await cmd('Page.enable'); await cmd('Runtime.enable')
// 1) 新建事项 launcher page + open dialog
await cmd('Page.navigate', { url: BASE + '#/oa/collab/create' }); await delay(2000)
await shot('eng-1-create-page')
await clickByText('新建事项'); await delay(1500)
await shot('eng-2-template-picker')
// 2) preview a template (form)
await clickByText('预览'); await delay(1800)
await shot('eng-3-form-preview')
// 3) flow tab
await clickByText('流程'); await delay(1500)
await shot('eng-4-flow-diagram')
// close any dialogs
await evalJs(`document.querySelectorAll('.el-dialog__headerbtn, .el-overlay').forEach(()=>{}); document.body.click && document.body.click()`)
await cmd('Runtime.evaluate', { expression: "document.querySelectorAll('.el-dialog__headerbtn').forEach(b=>b.click())" }); await delay(800)
// 4) form designer (应用管理中心)
await cmd('Page.navigate', { url: BASE + '#/oa/appdev/appmgr' }); await delay(2200)
await shot('eng-5-appmgr')
await clickByText('新建表单'); await delay(1500)
await shot('eng-6-form-designer')
ws.close(); chrome.kill('SIGTERM'); await delay(300); await rm(profile, { recursive: true, force: true })
console.log('done')
process.exit(0)
@@ -0,0 +1,52 @@
// Capture screenshots of the OA replica for manual review (saved as PNGs).
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
const CHROME = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const BASE = process.env.OA_BASE || 'http://localhost:8080/modern/app/'
const OUTDIR = '/Users/qiu/Desktop/ERP/ofbiz-framework/plugins/modern-ui/verification/oa-shots'
const SHOTS = [
['portal', '#/oa'],
['collab-todo', '#/oa/collab/todo'],
['collab-create', '#/oa/collab/create'],
['collab-center', '#/oa/collab/center'],
['meeting-mine', '#/oa/meeting/mine'],
['meeting-room', '#/oa/meeting/room'],
['goal-project', '#/oa/goal/project'],
['knowledge-doccenter', '#/oa/knowledge/doccenter'],
['hr-staff', '#/oa/hr/staff'],
['hr-org', '#/oa/hr/org'],
['culture-notice', '#/oa/culture/notice'],
['report-analysis', '#/oa/report/analysis'],
['contacts', '#/oa/contacts']
]
await mkdir(OUTDIR, { recursive: true })
const profile = await mkdtemp(path.join(os.tmpdir(), 'oashots.'))
const chrome = spawn(CHROME, ['--headless=new', '--disable-gpu', '--no-first-run', '--remote-debugging-port=0', '--window-size=1440,900', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
let port = ''
const dl = Date.now() + 15000
while (Date.now() < dl && !port) { try { port = (await readFile(path.join(profile, 'DevToolsActivePort'), 'utf8')).trim().split('\n')[0] } catch { await delay(150) } }
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json())
const ws = new WebSocket(targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl).webSocketDebuggerUrl)
await new Promise((res) => ws.addEventListener('open', res, { once: true }))
let id = 0
const pending = new Map()
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
const cmd = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })) })
await cmd('Page.enable')
await cmd('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false })
for (const [name, route] of SHOTS) {
await cmd('Page.navigate', { url: BASE + route })
await delay(1900)
const shot = await cmd('Page.captureScreenshot', { format: 'png' })
if (shot?.data) { await writeFile(path.join(OUTDIR, `${name}.png`), Buffer.from(shot.data, 'base64')); console.log('saved', name) }
else console.log('FAILED', name)
}
ws.close(); chrome.kill('SIGTERM'); await delay(300); await rm(profile, { recursive: true, force: true })
console.log('OUTDIR', OUTDIR)
process.exit(0)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,361 @@
#!/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.
*/
export const globalAdminForbiddenText = [
'Element Plus 定制 ERP 管理界面',
'Generated Element Plus preview',
'Migration Foundation',
'Page Definition',
'Storybook',
'不是页面预览',
'业务回归记录',
'业务等价',
'原应用清单',
'技术预览',
'旧 URL',
'旧入口',
'界面方案',
'组件展示页',
'组件展厅',
'组件规范',
'覆盖台账',
'迁移',
'页面标识',
'页面清单',
'待验收',
'历史地址'
]
const requiredDefaultRuntimeCaseIds = [
'login-door',
'admin-workbench',
'order-admin',
'party-admin',
'product-admin',
'accounting-admin',
'inventory-admin',
'manufacturing-admin',
'humanres-admin',
'sales-admin',
'procurement-admin',
'scrum-admin',
'operations-admin',
'content-admin',
'marketing-admin',
'commerce-admin',
'pos-admin',
'marketplace-admin',
'analytics-admin',
'extension-admin',
'global-business-center',
'route-unavailable',
'system-maintenance',
'security-admin',
'system-operations'
]
// The professional shell drives navigation from a grouped left sidebar tree
// (module = el-sub-menu, its quick pages = leaf items). These maps express the
// expected active module label (the sub-menu title) or the expected active
// top-level item for routes that are not nested under a module.
const moduleNavLabelByPath = new Map([
['#/orders', '订单'],
['#/catalog/products', '商品'],
['#/parties', '客户'],
['#/sales', '销售'],
['#/procurement', '采购'],
['#/accounting', '财务'],
['#/facility', '库存'],
['#/manufacturing', '生产'],
['#/humanres', '人事'],
['#/scrum', '敏捷'],
['#/operations', '运营'],
['#/content', '内容'],
['#/marketing', '营销'],
['#/commerce', '电商'],
['#/marketplace', '店铺'],
['#/pos', 'POS'],
['#/analytics', '报表'],
['#/extensions', '配置']
])
const moduleNavLabelById = new Map([
['order', '订单'],
['catalog', '商品'],
['party', '客户'],
['sales', '销售'],
['procurement', '采购'],
['accounting', '财务'],
['facility', '库存'],
['manufacturing', '生产'],
['humanres', '人事'],
['scrum', '敏捷'],
['operations', '运营'],
['content', '内容'],
['marketing', '营销'],
['commerce', '电商'],
['marketplace', '店铺'],
['pos', 'POS'],
['analytics', '报表'],
['system-admin', '运维'],
['extensions', '配置']
])
const topLevelNavByPath = new Map([
['#/', '运营工作台'],
['#/system', '系统管理'],
['#/system/security', '账号与角色'],
['#/system/operations', '运行监控']
])
const procurementPageIds = new Set([
'order__FindRequirements',
'order__ApproveRequirements',
'order__ApprovedProductRequirementsByVendor',
'ap__FindVendors',
'catalog__EditSupplierProduct',
'facility__ReceiveInventoryAgainstPurchaseOrder'
])
// Page id prefix -> owning module nav label, mirroring moduleCatalog prefixes.
const pagePrefixNavLabel = [
['SalesForceAutomation__', '销售'],
['accounting__', '财务'],
['ap__', '财务'],
['ar__', '财务'],
['order__', '订单'],
['catalog__', '商品'],
['party__', '客户'],
['facility__', '库存'],
['manufacturing__', '生产'],
['humanres__', '人事'],
['scrum__', '敏捷'],
['workeffort__', '运营'],
['projectmgr__', '运营'],
['content__', '内容'],
['marketing__', '营销'],
['ecommerce__', '电商'],
['ebay__', '店铺'],
['ebaystore__', '店铺'],
['webpos__', 'POS'],
['bi__', '报表'],
['birt__', '报表'],
['webtools__', '运维'],
['ofbizsetup__', '运维'],
['assetmaint__', '运维'],
['myportal__', '运维'],
['example__', '配置'],
['exampleext__', '配置'],
['pricat__', '配置'],
['pricatdemo__', '配置'],
['firstdata__', '配置'],
['msggateway__', '配置'],
['ismgr__', '配置'],
['scrumdemo__', '配置']
]
export function expectedRouteHash(route) {
const value = String(route || '#/')
const hashIndex = value.indexOf('#')
if (hashIndex >= 0) return value.slice(hashIndex)
return value.startsWith('/') ? `#${value}` : `#/${value.replace(/^\/+/, '')}`
}
function pathOnly(route) {
return expectedRouteHash(route).replace(/\?.*$/, '')
}
function pageIdForRoute(route) {
const path = pathOnly(route)
if (!path.startsWith('#/pages/')) return ''
return decodeURIComponent(path.slice('#/pages/'.length))
}
/**
* The expected active navigation target for a route:
* - { topLevel } when a top-level sidebar item should be active (workbench, system)
* - { moduleLabel } when a module sub-menu should be the active/open group
* - null when the route has no dedicated sidebar entry (business center, unknown)
*/
export function expectedSideNavForRoute(route) {
const path = pathOnly(route)
if (topLevelNavByPath.has(path)) return { topLevel: topLevelNavByPath.get(path) }
if (moduleNavLabelByPath.has(path)) return { moduleLabel: moduleNavLabelByPath.get(path) }
if (path.startsWith('#/module/')) {
const moduleId = decodeURIComponent(path.slice('#/module/'.length))
const label = moduleNavLabelById.get(moduleId)
return label ? { moduleLabel: label } : null
}
const pageId = pageIdForRoute(route)
if (pageId) {
if (procurementPageIds.has(pageId)) return { moduleLabel: '采购' }
const match = pagePrefixNavLabel.find(([prefix]) => pageId.startsWith(prefix))
if (match) return { moduleLabel: match[1] }
}
return null
}
// Back-compat export: returns the single expected nav label for a route, or ''.
export function expectedTopMenuTextForRoute(route) {
const expected = expectedSideNavForRoute(route)
if (!expected) return ''
return expected.topLevel || expected.moduleLabel || ''
}
function normalizeText(value) {
return String(value || '').replace(/\s+/g, '').trim()
}
function hasNormalizedText(items, expected) {
const normalizedExpected = normalizeText(expected)
return items.some((item) => normalizeText(item.text).includes(normalizedExpected))
}
export function runtimeRouteHashWaitExpression(route) {
return `location.hash === ${JSON.stringify(expectedRouteHash(route))}`
}
export function adminRuntimePolicySnapshotExpression(route) {
return `(() => {
const forbiddenTerms = ${JSON.stringify(globalAdminForbiddenText)}
const linkSnapshot = (selector) => Array.from(document.querySelectorAll(selector)).map((node) => ({
text: String(node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim(),
href: node.getAttribute('href') || '',
className: String(node.className || '')
}))
const text = document.body ? document.body.innerText : ''
const commandInput = document.querySelector('.erp-command input')
const activeSubMenu = document.querySelector('.erp-nav .el-sub-menu.is-active > .el-sub-menu__title')
|| document.querySelector('.erp-nav .el-sub-menu.is-opened > .el-sub-menu__title')
return {
expectedHash: ${JSON.stringify(expectedRouteHash(route))},
hash: location.hash,
href: location.href,
textLength: text.trim().length,
shellText: text.slice(0, 2000),
forbiddenVisible: forbiddenTerms.filter((term) => text.includes(term)),
hasApp: Boolean(document.querySelector('#app')),
hasAdminShell: Boolean(document.querySelector('.erp-shell')),
hasAside: Boolean(document.querySelector('.erp-shell__aside')),
hasTopbar: Boolean(document.querySelector('.erp-shell__topbar')),
hasMain: Boolean(document.querySelector('.erp-shell__main')),
hasCommand: Boolean(document.querySelector('.erp-command')),
commandPlaceholder: commandInput ? commandInput.getAttribute('placeholder') || '' : '',
hasQuickActions: Boolean(document.querySelector('.erp-quick-actions')),
hasSession: Boolean(document.querySelector('.erp-session')),
sessionText: document.querySelector('.erp-session')?.innerText || '',
hasSidebarNav: Boolean(document.querySelector('.erp-nav')),
sideActive: linkSnapshot('.erp-nav .el-menu-item.is-active'),
sideActiveModule: activeSubMenu ? String(activeSubMenu.innerText || '').replace(/\\s+/g, ' ').trim() : '',
loginDoorVisible: text.includes('登录 ERP 后台') || text.includes('使用 OFBiz 账号继续')
}
})()`
}
export function assertAdminRuntimeCaseCoverage(cases, options = {}) {
const selectedIds = new Set((cases || []).map((testCase) => testCase.id))
const missing = requiredDefaultRuntimeCaseIds.filter((id) => !selectedIds.has(id))
if (options.enforceDefaultCoverage && missing.length) {
throw new Error(`Default admin runtime coverage is missing required case(s): ${missing.join(', ')}`)
}
return {
id: 'admin-runtime-default-coverage',
status: missing.length ? 'skipped' : 'passed',
required: requiredDefaultRuntimeCaseIds,
missing
}
}
export function assertAdminRuntimePolicy(snapshot, testCase = {}) {
const route = testCase.route || snapshot?.expectedHash || '#/'
const session = testCase.session || 'admin'
const failures = []
const expectedHash = expectedRouteHash(route)
if (!snapshot || typeof snapshot !== 'object') {
failures.push('runtime policy snapshot is missing')
} else {
if (!snapshot.hasApp) failures.push('Vue app root is missing')
if (snapshot.hash !== expectedHash) {
failures.push(`route hash mismatch: expected ${expectedHash}, got ${snapshot.hash || '-'}`)
}
if (!snapshot.textLength) failures.push('document body has no visible text')
}
if (snapshot && session === 'admin') {
const requiredFlags = [
['hasAdminShell', 'administrator shell'],
['hasAside', 'administrator sidebar'],
['hasSidebarNav', 'administrator sidebar navigation tree'],
['hasTopbar', 'administrator topbar'],
['hasMain', 'administrator main content'],
['hasCommand', 'administrator command search'],
['hasQuickActions', 'administrator quick actions'],
['hasSession', 'administrator session area']
]
for (const [flag, label] of requiredFlags) {
if (!snapshot[flag]) failures.push(`${label} is missing`)
}
for (const text of ['OFBiz ERP', '统一运营管理平台', '运营工作台', '核心业务', '渠道与扩展', '系统治理', '新建', '待办', '在线']) {
if (!String(snapshot.shellText || '').includes(text)) failures.push(`administrator shell text is missing: ${text}`)
}
if (!String(snapshot.commandPlaceholder || '').includes('搜索订单、商品、客户、发票')) {
failures.push('administrator command search placeholder is missing business-object wording')
}
if (snapshot.loginDoorVisible) failures.push('administrator route rendered the login door')
if (snapshot.forbiddenVisible?.length) {
failures.push(`forbidden preview/migration copy is visible: ${snapshot.forbiddenVisible.join(', ')}`)
}
const expectedNav = expectedSideNavForRoute(route)
const sideActive = Array.isArray(snapshot.sideActive) ? snapshot.sideActive : []
if (expectedNav?.topLevel) {
if (sideActive.length !== 1) {
failures.push(`sidebar should have exactly one active item for ${expectedHash}; active=${sideActive.map((item) => item.text).join(', ') || '-'}`)
}
if (!hasNormalizedText(sideActive, expectedNav.topLevel)) {
failures.push(`sidebar active item should be ${expectedNav.topLevel}; active=${sideActive.map((item) => item.text).join(', ') || '-'}`)
}
} else if (expectedNav?.moduleLabel) {
if (sideActive.length !== 1) {
failures.push(`sidebar should have exactly one active leaf for ${expectedHash}; active=${sideActive.map((item) => item.text).join(', ') || '-'}`)
}
if (!normalizeText(snapshot.sideActiveModule).includes(normalizeText(expectedNav.moduleLabel))) {
failures.push(`sidebar active module should be ${expectedNav.moduleLabel}; activeModule=${snapshot.sideActiveModule || '-'}`)
}
}
}
if (snapshot && session !== 'admin' && snapshot.hasAdminShell) {
failures.push('guest route rendered the administrator shell')
}
return {
id: 'administrator-runtime-policy',
status: failures.length ? 'failed' : 'passed',
route: expectedHash,
session,
expectedNav: session === 'admin' ? expectedSideNavForRoute(route) : null,
sideActive: Array.isArray(snapshot?.sideActive) ? snapshot.sideActive : [],
sideActiveModule: snapshot?.sideActiveModule || '',
forbiddenVisible: Array.isArray(snapshot?.forbiddenVisible) ? snapshot.forbiddenVisible : [],
failures
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
#!/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 assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
const appVue = await readFile(path.join(appRoot, 'src/App.vue'), 'utf8')
const shellVue = await readFile(path.join(appRoot, 'src/components/erp/ErpAppShell.vue'), 'utf8')
const dashboardVue = await readFile(path.join(appRoot, 'src/views/DashboardView.vue'), 'utf8')
const loginVue = await readFile(path.join(appRoot, 'src/views/LoginView.vue'), 'utf8')
const moduleCatalog = await readFile(path.join(appRoot, 'src/data/moduleCatalog.ts'), 'utf8')
const pageVue = await readFile(path.join(appRoot, 'src/views/BusinessPageView.vue'), 'utf8')
const rendererVue = await readFile(path.join(appRoot, 'src/components/erp/ErpPageRenderer.vue'), 'utf8')
const dataTableVue = await readFile(path.join(appRoot, 'src/components/erp/ErpDataTable.vue'), 'utf8')
const design = await readFile(path.join(appRoot, '../../..', 'DESIGN.md'), 'utf8')
for (const text of ['工作台', '订单', '商品', '客户', '财务', '库存', '运营', '系统']) {
assert.match(appVue + shellVue + moduleCatalog, new RegExp(text), `primary admin navigation should include ${text}`)
}
for (const text of ['LoginView', 'showLoginGate', 'isAuthenticated']) {
assert.match(appVue, new RegExp(text), `app should route through login/session gate: ${text}`)
}
assert.doesNotMatch(appVue + shellVue, /adminPreview|preserveAdminPreview|isAdminPreviewMode/, 'product shell must not expose a preview parameter that bypasses login')
for (const text of ['登录后进入完整 ERP 后台', 'apiLogin', 'targetHash']) {
assert.match(loginVue, new RegExp(text), `login door should delegate to OFBiz login: ${text}`)
}
assert.match(appVue, /:target-hash="loginTargetHash"/, 'login gate should return users to the originally requested administrator route after OFBiz login')
for (const text of ['ERP 管理员工作台', '今日运营', '待办队列', '最近记录', '常用动作']) {
assert.match(dashboardVue, new RegExp(text), `administrator dashboard should render ${text}`)
}
assert.doesNotMatch(
dashboardVue,
/OFBiz 全量 UI 重写基座|Migration Foundation|交付预览|组件体系/,
'default dashboard must not read like a migration preview site'
)
for (const text of ['搜索订单', '运营工作台', '核心业务', '渠道与扩展', '统一运营管理平台', '系统治理', '系统管理']) {
assert.match(shellVue, new RegExp(text), `admin shell should expose ${text}`)
}
assert.match(design, /Business operation before implementation evidence/, 'DESIGN.md should document the administrator-site direction')
for (const text of ['处理台', '资料明细', '处理动作', '操作记录']) {
assert.match(pageVue, new RegExp(text), `generated business page tabs should include ${text}`)
}
assert.doesNotMatch(
pageVue,
/eyebrow="Page Definition"|Generated Element Plus preview|<el-tab-pane label="展示" name="preview"/,
'business page should not present itself as a technical preview surface'
)
assert.match(rendererVue, /处理回执|处理编号|下一步处理/, 'generated page renderer should expose business action receipts instead of preview gate language')
assert.doesNotMatch(dataTableVue, /Preview rows|结构预览行|结构预览/, 'data table user copy should not read as preview data')
assert.doesNotMatch(
rendererVue,
/Generated from OFBiz|block\.description \|\| block\.source|Widget 转换器/,
'business renderer should not pass generated source descriptions through to the business view'
)
assert.doesNotMatch(
await readFile(path.join(appRoot, 'src/components/erp/ErpAdapterBlock.vue'), 'utf8'),
/component:\/\/|\.ftl:已进入|legacy parity|等待领域动作验收|模板适配器|<div class="erp-adapter-block__eyebrow">\{\{ kind \}\}<\/div>/,
'adapter blocks should use business-facing copy instead of technical source paths'
)
assert.doesNotMatch(
pageVue,
/service、event|page\.value\.pageId\]\)|label="工作面"|label="数据区"|label="执行区"|label="审批流"|后端服务协同|Service Dispatcher|统一 Lookup|动作编码/,
'business page chrome should not expose implementation identifiers in the primary view'
)
assert.doesNotMatch(
appVue + shellVue + dashboardVue,
/label: 'Workbench'|label: 'Orders'|Live routes|service \/ event \/ readonly \/ local|>high<|routes<\/el-tag>/,
'admin shell and workbench should use business-facing Chinese labels'
)
console.log(JSON.stringify({
status: 'passed',
checked: ['App.vue', 'ErpAppShell.vue', 'DashboardView.vue', 'BusinessPageView.vue', 'ErpPageRenderer.vue', 'ErpDataTable.vue', 'DESIGN.md']
}, null, 2))
@@ -0,0 +1,901 @@
#!/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 assert from 'node:assert/strict'
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
async function source(file) {
return readFile(path.join(appRoot, file), 'utf8')
}
async function sourceFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true })
const files = await Promise.all(entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) return sourceFiles(fullPath)
return entry.name.endsWith('.vue') ? [fullPath] : []
}))
return files.flat()
}
const appVue = await source('src/App.vue')
const routerTs = await source('src/router/index.ts')
const shellVue = await source('src/components/erp/ErpAppShell.vue')
const dashboardVue = await source('src/views/DashboardView.vue')
const loginVue = await source('src/views/LoginView.vue')
const moduleVue = await source('src/views/ModuleWorkspaceView.vue')
const businessPageVue = await source('src/views/BusinessPageView.vue')
const notFoundVue = await source('src/views/NotFoundView.vue')
const productAdminVue = await source('src/views/ProductAdminView.vue')
const orderAdminVue = await source('src/views/OrderAdminView.vue')
const partyAdminVue = await source('src/views/PartyAdminView.vue')
const accountingAdminVue = await source('src/views/AccountingAdminView.vue')
const inventoryAdminVue = await source('src/views/InventoryAdminView.vue')
const manufacturingAdminVue = await source('src/views/ManufacturingAdminView.vue')
const humanResAdminVue = await source('src/views/HumanResAdminView.vue')
const salesAdminVue = await source('src/views/SalesAdminView.vue')
const procurementAdminVue = await source('src/views/ProcurementAdminView.vue')
const scrumAdminVue = await source('src/views/ScrumAdminView.vue')
const operationsAdminVue = await source('src/views/OperationsAdminView.vue')
const contentAdminVue = await source('src/views/ContentAdminView.vue')
const marketingAdminVue = await source('src/views/MarketingAdminView.vue')
const commerceAdminVue = await source('src/views/CommerceAdminView.vue')
const posAdminVue = await source('src/views/PosAdminView.vue')
const marketplaceAdminVue = await source('src/views/MarketplaceAdminView.vue')
const analyticsAdminVue = await source('src/views/AnalyticsAdminView.vue')
const systemVue = await source('src/views/SystemToolsView.vue')
const securityAdminVue = await source('src/views/SecurityAdminView.vue')
const systemOperationsVue = await source('src/views/SystemOperationsView.vue')
const businessCenterVue = await source('src/views/BusinessCenterView.vue')
const moduleCatalog = await source('src/data/moduleCatalog.ts')
const modernCss = await source('src/styles/modern.css')
const apiTs = await source('src/services/api.ts')
const apiTypes = await source('src/types/api.ts')
const domainAdminVue = await source('src/components/erp/ErpDomainAdminView.vue')
const adminSurfaceVueFiles = [
...(await sourceFiles(path.join(appRoot, 'src/views'))),
...(await sourceFiles(path.join(appRoot, 'src/components/erp')))
]
const directElementTabs = []
for (const file of adminSurfaceVueFiles) {
const relativeFile = path.relative(appRoot, file)
if (relativeFile === 'src/components/erp/ErpTabbedDataPanel.vue') continue
const content = await readFile(file, 'utf8')
if (/<(?:el-tabs|el-tab-pane|ElTabs|ElTabPane)\b/.test(content)) {
directElementTabs.push(relativeFile)
}
}
assert.deepEqual(
directElementTabs.sort(),
[],
'business views and ERP workspaces must use ErpTabbedDataPanel instead of direct Element Plus tabs'
)
async function collectDirectElementUsage(pattern, allowedWrapperFiles = []) {
const allowedFiles = new Set(allowedWrapperFiles)
const usage = {}
for (const file of adminSurfaceVueFiles) {
const relativeFile = path.relative(appRoot, file)
if (allowedFiles.has(relativeFile)) continue
const content = await readFile(file, 'utf8')
const count = content.match(pattern)?.length || 0
if (count > 0) usage[relativeFile] = count
}
return Object.fromEntries(Object.entries(usage).sort(([left], [right]) => left.localeCompare(right)))
}
function assertNoExpandedDirectElementUsage(actualUsage, allowedBaseline, message) {
const unexpectedFiles = Object.keys(actualUsage).filter((file) => !(file in allowedBaseline))
const expandedFiles = Object.entries(actualUsage)
.filter(([file, count]) => count > allowedBaseline[file])
.map(([file, count]) => `${file} (${count} > ${allowedBaseline[file]})`)
assert.deepEqual(unexpectedFiles, [], `${message}: unexpected direct Element Plus files`)
assert.deepEqual(expandedFiles, [], `${message}: direct Element Plus baseline expanded`)
}
const directElementTables = await collectDirectElementUsage(
/<(?:el-table|el-table-column|ElTable|ElTableColumn)\b/g,
['src/components/erp/ErpDataTable.vue']
)
const directElementTableBaseline = {
'src/components/erp/ErpFinanceOperationsWorkspace.vue': 23,
'src/components/erp/ErpOrderWorkspace.vue': 28,
'src/views/BusinessCenterView.vue': 8,
'src/views/BusinessPageView.vue': 34
}
assertNoExpandedDirectElementUsage(
directElementTables,
directElementTableBaseline,
'business views and ERP workspaces should move direct Element Plus tables into ErpDataTable'
)
const directElementForms = await collectDirectElementUsage(
/<(?:el-form|el-form-item|ElForm|ElFormItem)\b/g,
['src/components/erp/ErpEntityForm.vue', 'src/components/erp/ErpSearchForm.vue']
)
assertNoExpandedDirectElementUsage(
directElementForms,
{},
'business views and ERP workspaces must use ERP form wrappers instead of direct Element Plus forms'
)
function assertEntitySource(content, entityName, message) {
const directEntityLoad = new RegExp(`getEntityRows\\('${entityName}'`)
const wrapperEntitySource = new RegExp(`entityName: '${entityName}'`)
assert.ok(directEntityLoad.test(content) || wrapperEntitySource.test(content), message)
}
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Routing moved out of App.vue's hand-rolled hash dispatch into the central
// vue-router table (src/router/index.ts). Route paths there have no leading
// '#'. This asserts that the same `#/X` deep link still resolves to the same
// view component, now via the router record instead of the old activeView chain.
function assertRouteMapsToView(route, component, message) {
const routePath = escapeRegExp(route.replace(/^#/, ''))
const pattern = new RegExp(`path:\\s*'${routePath}'[\\s\\S]*?import\\('\\.\\./views/${component}\\.vue'\\)`)
assert.match(routerTs, pattern, message)
}
const visibleUiLeakageTerms = [
['component gallery copy', /组件展厅|组件规范|排版规则|全量清单/],
['preview copy', /技术预览|>\s*预览\s*<|title="预览"|预览模式|页面预览/],
['migration copy', /迁移|自动迁移|旧入口|旧 URL|旧页面|历史地址|旧屏幕来源|旧 widget/i],
['parity copy', /业务等价|功能等价|旧新报表比对|逐页比对|真实输出比对/],
['acceptance copy', /验收|待验收|验收清单|业务回归|待业务回归/],
['inventory checklist copy', /页面清单|覆盖率|覆盖台账|待补齐|待输出比对/],
['engineering metadata copy', /页面定义|页面结构|动作契约|控制规则|模板适配|适配器已覆盖|前端已重写/]
]
function assertNoVisibleUiLeakage(surfaces) {
for (const [fileName, content] of surfaces) {
for (const [label, pattern] of visibleUiLeakageTerms) {
assert.doesNotMatch(
content,
pattern,
`${fileName} must not expose ${label} in visible administrator product UI`
)
}
}
}
const postLoginCoreModuleAnchors = [
['#/orders', 'order-admin', 'OrderAdminView', 'OrderAdminView.vue', orderAdminVue, ['订单运营台', '订单业务数据'], ['OrderHeader', 'OrderItem', 'Shipment']],
['#/catalog/products', 'product-admin', 'ProductAdminView', 'ProductAdminView.vue', productAdminVue, ['商品管理台', '商品业务数据'], ['Product', 'ProductCategory', 'ProductPrice']],
['#/parties', 'party-admin', 'PartyAdminView', 'PartyAdminView.vue', partyAdminVue, ['客户与组织管理台', '客户业务数据'], ['Party', 'Person', 'PartyRole']],
['#/sales', 'sales-admin', 'SalesAdminView', 'SalesAdminView.vue', salesAdminVue, ['销售管理台', '销售业务数据'], ['SalesOpportunity', 'SalesForecast', 'CommunicationEvent']],
['#/procurement', 'procurement-admin', 'ProcurementAdminView', 'ProcurementAdminView.vue', procurementAdminVue, ['采购管理台', '采购业务数据'], ['Requirement', 'SupplierProduct', 'Vendor']],
['#/accounting', 'accounting-admin', 'AccountingAdminView', 'AccountingAdminView.vue', accountingAdminVue, ['财务管理台', '财务业务数据'], ['Invoice', 'Payment', 'AcctgTrans']],
['#/facility', 'inventory-admin', 'InventoryAdminView', 'InventoryAdminView.vue', inventoryAdminVue, ['库存管理台', '库存业务数据'], ['InventoryItem', 'Facility', 'Shipment']],
['#/manufacturing', 'manufacturing-admin', 'ManufacturingAdminView', 'ManufacturingAdminView.vue', manufacturingAdminVue, ['生产管理台', '生产业务数据'], ['WorkEffort', 'Requirement', 'CostComponent']],
['#/humanres', 'humanres-admin', 'HumanResAdminView', 'HumanResAdminView.vue', humanResAdminVue, ['人事管理台', '人事业务数据'], ['Person', 'Employment', 'EmplPosition']]
]
function assertPostLoginProductAnchors() {
assert.match(appVue, /<ErpAppShell[\s\S]*v-else/, 'authenticated product UI must render inside the ERP application shell after the login gate')
assert.match(appVue, /if \(sessionData\.authenticated\) \{\s*await loadAuthenticatedData\(\)/, 'authenticated session refresh must load administrator navigation and inventory only after login')
assert.match(appVue, /loadAuthenticatedData[\s\S]*getNavigation\(\)[\s\S]*getInventory\(\)/, 'post-login product load must hydrate backend navigation and generated business page definitions')
assertRouteMapsToView('#/business', 'BusinessCenterView', 'post-login product must include a business center route')
// BusinessPageView no longer takes :page-id/:inventory props; it reads the
// pageId from the route and the inventory from inject(). The same wiring is
// preserved here: the router maps the generated business-page path to
// BusinessPageView, and BusinessPageView sources pageId from the route and
// inventory from the injected app-level state.
assert.match(routerTs, /path:\s*'\/pages\/:pageId\(\.\*\)'[\s\S]*?import\('\.\.\/views\/BusinessPageView\.vue'\)/, 'post-login product must render generated OFBiz business pages through BusinessPageView')
assert.match(businessPageVue, /route\.params\.pageId/, 'BusinessPageView must read its pageId from the route')
assert.match(businessPageVue, /inject\(InventoryKey/, 'BusinessPageView must read its inventory from injected app state')
assert.match(shellVue + dashboardVue + businessCenterVue, /#\/business/, 'post-login product chrome must expose the global business center anchor')
for (const [route, activeView, component, sourceName, sourceText, labels, entities] of postLoginCoreModuleAnchors) {
assert.match(routerTs, new RegExp(component), `${sourceName} must be mounted in the authenticated administrator app`)
assertRouteMapsToView(route, component, `${route} must resolve to ${activeView} after login`)
assert.match(
shellVue + dashboardVue + moduleCatalog,
new RegExp(escapeRegExp(route)),
`${route} must be reachable from post-login shell, dashboard, or module catalog anchors`
)
for (const label of labels) {
assert.match(sourceText, new RegExp(escapeRegExp(label)), `${sourceName} must expose product UI anchor: ${label}`)
}
for (const entityName of entities) {
assertEntitySource(sourceText, entityName, `${sourceName} must load real ${entityName} rows for the product anchor`)
}
}
}
assertPostLoginProductAnchors()
assertNoVisibleUiLeakage([
['LoginView.vue', loginVue],
['App.vue', appVue],
['ErpAppShell.vue', shellVue],
['DashboardView.vue', dashboardVue],
['ModuleWorkspaceView.vue', moduleVue],
['BusinessCenterView.vue', businessCenterVue],
['BusinessPageView.vue', businessPageVue],
['SystemToolsView.vue', systemVue],
['SecurityAdminView.vue', securityAdminVue],
['SystemOperationsView.vue', systemOperationsVue],
['ProductAdminView.vue', productAdminVue],
['OrderAdminView.vue', orderAdminVue],
['PartyAdminView.vue', partyAdminVue],
['AccountingAdminView.vue', accountingAdminVue],
['InventoryAdminView.vue', inventoryAdminVue],
['ManufacturingAdminView.vue', manufacturingAdminVue],
['HumanResAdminView.vue', humanResAdminVue],
['SalesAdminView.vue', salesAdminVue],
['ProcurementAdminView.vue', procurementAdminVue],
['ScrumAdminView.vue', scrumAdminVue],
['OperationsAdminView.vue', operationsAdminVue],
['ContentAdminView.vue', contentAdminVue],
['MarketingAdminView.vue', marketingAdminVue],
['CommerceAdminView.vue', commerceAdminVue],
['PosAdminView.vue', posAdminVue],
['MarketplaceAdminView.vue', marketplaceAdminVue],
['AnalyticsAdminView.vue', analyticsAdminVue],
['ErpDomainAdminView.vue', domainAdminVue]
])
assert.match(routerTs, /DashboardView/, 'the authenticated default route should land on the ERP administrator workbench')
assert.match(routerTs, /path:\s*'\/'[\s\S]*?import\('\.\.\/views\/DashboardView\.vue'\)/, 'only the authenticated root route should land on the ERP administrator workbench')
assert.match(routerTs, /path:\s*'\/:pathMatch\(\.\*\)\*'[\s\S]*?import\('\.\.\/views\/NotFoundView\.vue'\)/, 'unknown authenticated routes should render a clear unavailable state')
assert.match(notFoundVue, /页面不可用/, 'the unavailable route state must clearly tell the operator the page is unavailable')
assert.doesNotMatch(routerTs, /path:\s*'\/:pathMatch\(\.\*\)\*'[\s\S]*?DashboardView/, 'unknown authenticated routes must not silently fall back to the administrator workbench')
assert.doesNotMatch(routerTs, /path:\s*'\/components'|path:\s*'\/patterns'|PatternsView/, 'component and pattern pages must not be top-level authenticated routes')
assert.doesNotMatch(
routerTs,
/LegacyRedirectView|DeliveryView|ParityView|ComponentsView|PatternsView|InventoryView/,
'production administrator site should not mount legacy bridge, delivery, parity, component, pattern, or inventory engineering workbenches'
)
assert.doesNotMatch(routerTs, /path:\s*'\/legacy'|path:\s*'\/delivery'|path:\s*'\/parity'|path:\s*'\/components'|path:\s*'\/patterns'|path:\s*'\/inventory'/, 'support workbenches and legacy bridge pages should not ship as top-level administrator routes')
assert.doesNotMatch(
loginVue,
/inventory\.counts|个业务入口|业务化后台界面/,
'login door should read like an ERP administrator login, not a route or inventory catalog'
)
assert.doesNotMatch(
appVue,
/getSession\(\),\s*\n\s*getNavigation\(\),\s*\n\s*getInventory\(\)/,
'page inventory should load after OFBiz session authentication, not before the login gate'
)
for (const text of ['ERP 管理员工作台', '管理员首页', '经营数据', '今日运营', '待办队列', '最近记录', '快捷动作', '系统健康与权限', '快速检索', '订单运营', '系统维护', '当班状态', '可处理范围']) {
assert.match(dashboardVue, new RegExp(text), `administrator dashboard must prioritize business operation: ${text}`)
}
for (const text of ['kpiRows', 'priorityWorkRows', 'workbenchStatusRows', 'continueWorkRows', 'recentDocumentRows', 'businessActionGroups', 'businessSearchRows', 'pendingWorkRows']) {
assert.match(dashboardVue, new RegExp(text), `administrator dashboard should be organized around daily ERP work: ${text}`)
}
for (const text of ['getAdminSummary', 'businessSummary', 'realEntityMetrics', '经营数据', '最近单据']) {
assert.match(dashboardVue, new RegExp(text), `administrator dashboard must use real OFBiz business data signals: ${text}`)
}
assert.match(apiTs, /export async function getAdminSummary/, 'API client should expose a business summary loader for the administrator dashboard')
for (const entityName of ['OrderHeader', 'Product', 'Party', 'Invoice', 'InventoryItem']) {
assert.match(apiTs, new RegExp(`entityName: '${entityName}'`), `business summary should include ${entityName}`)
}
assert.match(apiTs, /adminSummarySources[\s\S]*getEntityRows\(source\.entityName/, 'business summary should query configured core OFBiz entities')
assert.match(apiTypes, /export type AdminSummary/, 'API types should document the administrator summary contract')
assert.doesNotMatch(
dashboardVue,
/props\.inventory|UiInventory|ParityPage|parityManifest|routeManifest|pageSource|pageList|highRiskPages|businessParityStatus|parityStatus|risk ===|page\.risk|value:\s*moduleStats\.value\.[\w?]+\.pages|const moduleStats|item\.highRiskCount\s*\|\|\s*item\.pageCount|运营入口台|核心工作区|核心处理入口|全局业务命令/,
'dashboard must not use route inventory, parity, or generated risk data as operator metrics'
)
assert.doesNotMatch(
dashboardVue,
/常用操作|数据来源|空数据源|OFBiz Entity|实体可读/,
'dashboard should read like an operator workbench instead of a data-source diagnostic page'
)
assert.doesNotMatch(
dashboardVue,
/后端授权|后端连接|待登录授权|已授权业务|条处理路径|entryCount:\s*module\.quickPages\.length|actions:\s*module\.quickPages\.length/,
'dashboard should not expose backend-authorization wording or quick-page counts as business operation language'
)
for (const text of ['primaryModules', 'secondaryModules', '核心业务', '渠道与扩展', '运营工作台', '系统治理']) {
assert.match(shellVue, new RegExp(text), `application shell must separate business navigation from governance: ${text}`)
}
for (const text of ['#/business', '业务处理中心', '统一业务中心']) {
assert.match(shellVue + appVue + dashboardVue + businessCenterVue + systemVue, new RegExp(text), `administrator product should expose a business center route instead of a page catalog route: ${text}`)
}
assertRouteMapsToView('#/business', 'BusinessCenterView', 'business center should be mounted as the administrator business route')
assert.doesNotMatch(
shellVue,
/const adminSupportTools = \[[^\]]*#\/pages[^\]]*\]/,
'global administrator support navigation should not send operators to the generated page route; use #/business for the business center'
)
assert.doesNotMatch(
shellVue,
/label: '组件规范'|label: '排版规则'|label: '全量清单'/,
'component and inventory pages must not be permanent shell navigation items'
)
assert.match(shellVue, /quickQueues[\s\S]*进入模块工作台/, 'application shell should expose operator work queues')
assert.doesNotMatch(
shellVue,
/`\$\{module\.navLabel\}入口`|>\s*入口\s*</,
'application shell should not describe core operator surfaces as generic entries'
)
assert.doesNotMatch(
shellVue,
/authorizedApplications\s*=\s*computed|后端授权/,
'application shell should turn backend navigation into authorized business domains, not an app-directory panel'
)
for (const text of ['统一运营管理平台', '运营工作台', '核心业务', 'navGroups', 'quickQueues', 'breadcrumbs']) {
assert.match(shellVue, new RegExp(text), `application shell should expose administrator work context: ${text}`)
}
assert.doesNotMatch(
shellVue,
/count:\s*module\.quickPages\.length/,
'topbar work queues should not use quick action counts as queue counts'
)
assert.match(
shellVue,
/const navGroups = computed\(\(\) => \[[\s\S]*modules: primaryBusinessModules\.value[\s\S]*modules: secondaryBusinessModules\.value/,
'authorized business navigation must be built from the moduleCatalog primary/secondary groups'
)
assert.match(
shellVue,
/moduleItemIndex\(module, toPath\(moduleLandingPath\(module\)\)\)/,
'authorized business-domain links must use dedicated administrator routes (moduleLandingPath), not backend modernPath or generated page-shell anchors'
)
assert.doesNotMatch(
shellVue,
/const path = matchedItems\[0\] \? normalizedModernPath\(matchedItems\[0\]\) : moduleLandingPath\(module\)/,
'authorized business-domain links must not prefer backend navigation paths over real administrator routes'
)
assert.doesNotMatch(
shellVue,
/#\/pages\/\$\{item\.id\}__main|function normalizedModernPath/,
'application shell must not generate backend page-shell anchors for menu navigation'
)
for (const [pattern, message] of [
[
/v-for="module in group\.modules"[\s\S]*:index="moduleItemIndex\(module, toPath\(moduleLandingPath\(module\)\)\)"/,
'sidebar module menu landing items should use module landing routes'
],
[
/v-for="page in module\.quickPages"[\s\S]*:index="moduleItemIndex\(module, `\/pages\/\$\{page\.pageId\}`\)"/,
'sidebar module quick-page items should link to their generated business pages'
],
[
/path: toPath\(moduleLandingPath\(module\)\)/,
'topbar work-queue links should use module landing routes'
],
[
/v-for="group in navGroups"[\s\S]*:title="group\.title"/,
'authorized business-domain navigation should render each moduleCatalog group through its dedicated nav field'
],
[
/trail\.push\(\{ label: module\.navLabel, path: route\.path === landing \? undefined : landing \}\)/,
'module breadcrumb links should return to the dedicated administrator route'
]
]) {
assert.match(shellVue, pattern, message)
}
assert.match(
shellVue,
/const secondaryBusinessModules = computed\(\(\) => secondaryModules\.map\(\(id\) => moduleConfigMap\[id\]\)\.filter\(Boolean\)\)/,
'secondary business navigation should be driven by the moduleCatalog secondary group, including the standalone system entry under governance'
)
for (const text of ['getModuleSummary', 'moduleSummary', 'moduleEntityMetrics', 'pendingQueues', 'processingLanes', '业务处理中枢', '业务队列', '处理流', '交接记录', '执行动作', '处理事项', '运行状态', '异常交接']) {
assert.match(moduleVue, new RegExp(text), `module workspace must behave like an ERP operating console: ${text}`)
}
assert.doesNotMatch(
moduleVue,
/props\.inventory|parityManifest|ParityPage|modulePages|filteredPages|workflowRows|scenarioColumns|businessParityStatus|parityStatus|frontendRewriteStatus|uiRewriteStatus|scenarioStatus|pending-business-e2e|ready-for-business-e2e|risk ===|page\.risk|highRiskPages|highRisk|高风险|待生成/,
'module workspaces must not expose migration, parity, or generated-risk semantics'
)
assert.doesNotMatch(
moduleVue,
/搜索当前模块页面、流程、旧入口|模块页面|页面标识|旧入口|直接进入真实 OFBiz 业务页面|组件展示页|打开常用入口|常用操作|最近记录|真实 OFBiz 模块数据|模块数据源|模块数据|数据源|没有匹配的常用操作/,
'module workspaces must not read like route inventories or migration catalogs'
)
assert.match(
moduleCatalog,
/queueLabels|scenarioColumnLabels|primaryModules|secondaryModules/,
'module catalog should provide business navigation groupings and queue labels'
)
assert.match(
moduleCatalog,
/id: 'extensions'[\s\S]*landingPath: '#\/extensions'/,
'configuration module should land on the dedicated administrator page, not the generic module workspace'
)
assert.match(routerTs, /ProductAdminView/, 'administrator site should include a dedicated product administration view')
assertRouteMapsToView('#/catalog/products', 'ProductAdminView', 'product administration should have a dedicated catalog route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/catalog\/products/, 'product administration should be reachable from the shell, dashboard, or catalog module')
for (const text of ['商品管理台', '商品资料', '目录分类', '价格规则', '促销活动', '库存状态', '商品业务数据']) {
assert.match(productAdminVue, new RegExp(text), `product administration page should expose administrator operation: ${text}`)
}
for (const entityName of ['Product', 'ProductCategory', 'ProductPrice', 'ProductPromo', 'InventoryItem']) {
assertEntitySource(productAdminVue, entityName, `product administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
productAdminVue,
/Demo[A-Za-z0-9_]*|SKU-\d+|PROD-\d+|RetailStore|BigSupplier|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'product administration page should not expose fake product rows or engineering language'
)
assert.match(routerTs, /OrderAdminView/, 'administrator site should include a dedicated order operations view')
assertRouteMapsToView('#/orders', 'OrderAdminView', 'order operations should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/orders/, 'order operations should be reachable from the shell, dashboard, or order module')
for (const text of ['订单运营台', '订单执行台', '待审核订单', '待履约订单', '付款关注', '退货风险', '今日处理流', '交接记录', '订单队列', '订单明细', '履约与发运', '退货授权', '订单业务数据']) {
assert.match(orderAdminVue, new RegExp(text), `order operations page should expose administrator operation: ${text}`)
}
for (const text of ['orderExecutionRows', 'orderFlowRows', 'orderHandoffRows', 'orderRiskRows', 'order-admin-execution', 'order-admin-flow-row', 'order-admin-handoff-row', 'order-admin-risk-row']) {
assert.match(orderAdminVue + modernCss, new RegExp(text), `order operations page should expose executable order work surfaces: ${text}`)
}
for (const text of ['ErpTabbedDataPanel', 'orderDataTabs', 'data-modern="order-admin-tabbed-data"', '订单业务数据']) {
assert.match(orderAdminVue, new RegExp(text), `order operations page should use the ERP tabbed data panel for real order partitions: ${text}`)
}
for (const entityName of ['OrderHeader', 'OrderItem', 'OrderStatus', 'Shipment', 'ReturnHeader']) {
assert.match(orderAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `order operations page should load real ${entityName} rows`)
}
assert.doesNotMatch(
orderAdminVue,
/Demo[A-Za-z0-9_]*|ORD-\d+|SHP-\d+|RET-\d+|SampleCustomer|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'order operations page should not expose fake order rows or engineering language'
)
assert.match(routerTs, /PartyAdminView/, 'administrator site should include a dedicated party administration view')
assertRouteMapsToView('#/parties', 'PartyAdminView', 'party administration should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/parties/, 'party administration should be reachable from the shell, dashboard, or party module')
for (const text of ['客户与组织管理台', '客户主体', '个人档案', '组织档案', '角色关系', '联系方式', '沟通记录', '客户业务数据']) {
assert.match(partyAdminVue, new RegExp(text), `party administration page should expose administrator operation: ${text}`)
}
for (const entityName of ['Party', 'Person', 'PartyGroup', 'PartyRole', 'ContactMech', 'CommunicationEvent']) {
assertEntitySource(partyAdminVue, entityName, `party administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
partyAdminVue,
/Demo[A-Za-z0-9_]*|PARTY-\d+|CUST-\d+|ORG-\d+|Person-\d+|SampleCustomer|example\.com|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'party administration page should not expose fake party rows or engineering language'
)
assert.match(routerTs, /AccountingAdminView/, 'administrator site should include a dedicated accounting administration view')
assertRouteMapsToView('#/accounting', 'AccountingAdminView', 'accounting administration should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/accounting/, 'accounting administration should be reachable from the shell, dashboard, or accounting module')
for (const text of ['财务管理台', '财务结账台', '应收跟进', '应付安排', '收付款匹配', '核销队列', '现金流关注', '财务异常', '本期结账', '发票队列', '付款收款', '会计凭证', '凭证明细', '总账科目', '核销处理', '账龄风险', '财务业务数据']) {
assert.match(accountingAdminVue, new RegExp(text), `accounting administration page should expose administrator operation: ${text}`)
}
for (const text of ['financeCloseRows', 'receivableRows', 'payableRows', 'reconciliationRows', 'cashAttentionRows', 'financeExceptionRows', 'accounting-admin-close-grid', 'accounting-admin-work-row', 'accounting-admin-exception-row']) {
assert.match(accountingAdminVue + modernCss, new RegExp(text), `accounting administration page should expose finance operator work surfaces: ${text}`)
}
for (const entityName of ['Invoice', 'Payment', 'AcctgTrans', 'AcctgTransEntry', 'GlAccount']) {
assert.match(accountingAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `accounting administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
accountingAdminVue,
/Demo[A-Za-z0-9_]*|INV-\d+|PAY-\d+|GL-\d+|ACCT-\d+|SampleCustomer|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'accounting administration page should not expose fake finance rows or engineering language'
)
assert.match(routerTs, /InventoryAdminView/, 'administrator site should include a dedicated inventory administration view')
assertRouteMapsToView('#/facility', 'InventoryAdminView', 'inventory administration should have a dedicated facility route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/facility/, 'inventory administration should be reachable from the shell, dashboard, or facility module')
for (const text of ['库存管理台', '库存执行台', '收货上架', '可用量关注', '调拨跟进', '盘点差异', '库存异常', '库位交接', '库存项', '设施库位', '收货发运', '库存明细', '库存调拨', '盘点调整', '库存风险', '库存业务数据']) {
assert.match(inventoryAdminVue, new RegExp(text), `inventory administration page should expose administrator operation: ${text}`)
}
for (const text of ['inventoryExecutionRows', 'receivingWorkRows', 'availabilityRows', 'transferWorkRows', 'countVarianceRows', 'inventoryExceptionRows', 'inventory-admin-execution-grid', 'inventory-admin-work-row', 'inventory-admin-risk-row']) {
assert.match(inventoryAdminVue + modernCss, new RegExp(text), `inventory administration page should expose warehouse operator work surfaces: ${text}`)
}
for (const entityName of ['InventoryItem', 'Facility', 'FacilityLocation', 'Shipment', 'InventoryItemDetail', 'InventoryTransfer', 'PhysicalInventory']) {
assert.match(inventoryAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `inventory administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
inventoryAdminVue,
/Demo[A-Za-z0-9_]*|INV-\d+|FAC-\d+|SHP-\d+|LOC-\d+|TRF-\d+|SampleWarehouse|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'inventory administration page should not expose fake inventory rows or engineering language'
)
assert.match(routerTs, /ManufacturingAdminView/, 'administrator site should include a dedicated manufacturing administration view')
assertRouteMapsToView('#/manufacturing', 'ManufacturingAdminView', 'manufacturing administration should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/manufacturing/, 'manufacturing administration should be reachable from the shell, dashboard, or manufacturing module')
for (const text of ['生产管理台', '生产调度台', '排产跟进', '物料缺口', '工序准备', '发料领用', '成本关注', '生产异常', '生产运行', '物料需求', 'BOM 工艺', '工序任务', '成本构成', '生产风险', '生产业务数据']) {
assert.match(manufacturingAdminVue, new RegExp(text), `manufacturing administration page should expose administrator operation: ${text}`)
}
for (const text of ['manufacturingScheduleRows', 'materialShortageRows', 'routingPreparationRows', 'issuanceWorkRows', 'manufacturingExceptionRows', 'manufacturing-admin-execution-grid', 'manufacturing-admin-work-row', 'manufacturing-admin-risk-row']) {
assert.match(manufacturingAdminVue + modernCss, new RegExp(text), `manufacturing administration page should expose production operator work surfaces: ${text}`)
}
for (const entityName of ['WorkEffort', 'Requirement', 'ProductAssoc', 'WorkEffortGoodStandard', 'CostComponent', 'ItemIssuance']) {
assert.match(manufacturingAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `manufacturing administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
manufacturingAdminVue,
/Demo[A-Za-z0-9_]*|MFG-\d+|RUN-\d+|BOM-\d+|MAT-\d+|WORK-\d+|SampleFactory|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'manufacturing administration page should not expose fake manufacturing rows or engineering language'
)
assert.match(routerTs, /HumanResAdminView/, 'administrator site should include a dedicated human resources administration view')
assertRouteMapsToView('#/humanres', 'HumanResAdminView', 'human resources administration should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/humanres/, 'human resources administration should be reachable from the shell, dashboard, or humanres module')
for (const text of ['人事管理台', '人事执行台', '入转离跟进', '岗位空缺', '招聘处理', '绩效复核', '技能资质', '人事异常', '任职交接', '员工档案', '雇佣关系', '岗位编制', '招聘申请', '人事风险', '人事业务数据']) {
assert.match(humanResAdminVue, new RegExp(text), `human resources administration page should expose administrator operation: ${text}`)
}
for (const text of ['humanResExecutionRows', 'employmentLifecycleRows', 'positionVacancyRows', 'recruitingWorkRows', 'performanceReviewRows', 'skillCredentialRows', 'humanResExceptionRows', 'humanres-admin-execution-grid', 'humanres-admin-work-row', 'humanres-admin-risk-row']) {
assert.match(humanResAdminVue + modernCss, new RegExp(text), `human resources administration page should expose HR operator work surfaces: ${text}`)
}
for (const entityName of ['Person', 'Employment', 'EmplPosition', 'EmplPositionFulfillment', 'EmploymentApp', 'JobRequisition', 'PerfReview', 'PartySkill']) {
assert.match(humanResAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `human resources administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
humanResAdminVue,
/Demo[A-Za-z0-9_]*|EMP-\d+|HR-\d+|POS-\d+|APP-\d+|SampleEmployee|SamplePosition|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'human resources administration page should not expose fake human resources rows or engineering language'
)
assert.match(routerTs, /SalesAdminView/, 'administrator site should include a dedicated sales administration view')
assertRouteMapsToView('#/sales', 'SalesAdminView', 'sales administration should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/sales/, 'sales administration should be reachable from the shell, dashboard, or sales module')
for (const text of ['销售管理台', '销售执行台', '线索承接', '机会推进', '预测复核', '跟进交接', '客户角色', '销售异常', '销售线索', '机会管道', '销售预测', '跟进事件', '销售风险', '销售业务数据']) {
assert.match(salesAdminVue, new RegExp(text), `sales administration page should expose administrator operation: ${text}`)
}
for (const text of ['salesExecutionRows', 'leadHandoffRows', 'opportunityPipelineRows', 'forecastReviewRows', 'communicationFollowupRows', 'salesExceptionRows', 'sales-admin-execution-grid', 'sales-admin-work-row', 'sales-admin-risk-row']) {
assert.match(salesAdminVue + modernCss, new RegExp(text), `sales administration page should expose sales operator work surfaces: ${text}`)
}
for (const entityName of ['SalesOpportunity', 'SalesOpportunityStage', 'SalesOpportunityRole', 'SalesForecast', 'SalesForecastDetail', 'PartyRole', 'CommunicationEvent']) {
assert.match(salesAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `sales administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
salesAdminVue,
/Demo[A-Za-z0-9_]*|LEAD-\d+|SFA-\d+|OPP-\d+|FORECAST-\d+|SampleLead|SampleOpportunity|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'sales administration page should not expose fake sales rows or engineering language'
)
assert.match(routerTs, /ProcurementAdminView/, 'administrator site should include a dedicated procurement administration view')
assertRouteMapsToView('#/procurement', 'ProcurementAdminView', 'procurement administration should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/procurement/, 'procurement administration should be reachable from the shell, dashboard, or procurement module')
for (const text of ['采购管理台', '采购执行台', '需求审批', '询价比价', '供应商风险', '到货接收', '补货交接', '采购异常', '供应商协同', '采购需求', '供应商', '供应商品', '供应商报价', '采购风险', '采购业务数据']) {
assert.match(procurementAdminVue, new RegExp(text), `procurement administration page should expose administrator operation: ${text}`)
}
for (const text of ['procurementExecutionRows', 'approvalWorkRows', 'quoteComparisonRows', 'supplierRiskRows', 'receivingWorkRows', 'procurementHandoffRows', 'procurement-admin-execution-grid', 'procurement-admin-work-row', 'procurement-admin-risk-row']) {
assert.match(procurementAdminVue + modernCss, new RegExp(text), `procurement administration page should expose procurement operator work surfaces: ${text}`)
}
for (const entityName of ['Requirement', 'SupplierProduct', 'Vendor', 'Quote', 'CustRequest', 'Shipment', 'InventoryItem']) {
assert.match(procurementAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `procurement administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
procurementAdminVue,
/Demo[A-Za-z0-9_]*|REQ-\d+|PO-\d+|SUP-\d+|VEN-\d+|SampleVendor|SampleSupplier|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'procurement administration page should not expose fake procurement rows or engineering language'
)
assert.match(routerTs, /ScrumAdminView/, 'administrator site should include a dedicated Scrum administration view')
assertRouteMapsToView('#/scrum', 'ScrumAdminView', 'Scrum administration should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/scrum/, 'Scrum administration should be reachable from the shell, dashboard, or Scrum module')
for (const text of ['敏捷交付管理台', '敏捷执行台', 'Backlog 承接', 'Sprint 推进', '任务交接', '工时复核', '资源协调', '交付异常', '产品 Backlog', 'Sprint 排程', '任务板', '团队资源', '工时确认', '交付风险', '敏捷业务数据']) {
assert.match(scrumAdminVue, new RegExp(text), `Scrum administration page should expose administrator operation: ${text}`)
}
for (const text of ['scrumExecutionRows', 'backlogHandoffRows', 'sprintDeliveryRows', 'sprintTaskHandoffRows', 'timeReviewRows', 'resourceCoordinationRows', 'scrumExceptionRows', 'scrum-admin-execution', 'scrum-admin-work-row', 'scrum-admin-risk-row']) {
assert.match(scrumAdminVue + modernCss, new RegExp(text), `Scrum administration page should expose executable delivery work surfaces: ${text}`)
}
for (const entityName of ['ProductBacklog', 'ProjectSprint', 'ProjectSprintBacklogAndTask', 'Timesheet', 'TimeEntry', 'WorkEffortPartyAssignment', 'CustRequest']) {
assert.match(scrumAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `Scrum administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
scrumAdminVue,
/Demo[A-Za-z0-9_]*|SCRUM-\d+|SPRINT-\d+|TASK-\d+|BACKLOG-\d+|SampleSprint|SampleBacklog|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'Scrum administration page should not expose fake agile delivery rows or engineering language'
)
assert.match(routerTs, /OperationsAdminView/, 'administrator site should include a dedicated operations administration view')
assertRouteMapsToView('#/operations', 'OperationsAdminView', 'operations administration should have a dedicated route')
assert.match(shellVue + dashboardVue + moduleCatalog, /#\/operations/, 'operations administration should be reachable from the shell, dashboard, or operations module')
for (const text of ['运营任务管理台', '运营执行台', '任务承接', '排程依赖', '人员分配', '工时复核', '请求跟进', '沟通交接', '运营异常', '任务排程', '工作分配', '工时表', '请求协同', '沟通记录', '运营风险', '运营业务数据']) {
assert.match(operationsAdminVue, new RegExp(text), `operations administration page should expose administrator operation: ${text}`)
}
for (const text of ['operationsExecutionRows', 'taskHandoffRows', 'dependencyRows', 'assignmentDispatchRows', 'operationsTimeReviewRows', 'requestFollowupRows', 'communicationHandoffRows', 'operationsExceptionRows', 'operations-admin-execution', 'operations-admin-work-row', 'operations-admin-risk-row']) {
assert.match(operationsAdminVue + modernCss, new RegExp(text), `operations administration page should expose executable operations work surfaces: ${text}`)
}
for (const entityName of ['WorkEffort', 'WorkEffortAssoc', 'WorkEffortPartyAssignment', 'Timesheet', 'TimeEntry', 'CustRequest', 'CommunicationEvent']) {
assert.match(operationsAdminVue, new RegExp(`getEntityRows\\('${entityName}'`), `operations administration page should load real ${entityName} rows`)
}
assert.doesNotMatch(
operationsAdminVue,
/Demo[A-Za-z0-9_]*|OPS-\d+|WORK-\d+|TASK-\d+|SampleTask|SampleOperation|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
'operations administration page should not expose fake operations rows or engineering language'
)
const convertedTabbedAdminExpectations = [
{
name: 'inventory administration page',
content: inventoryAdminVue,
tokens: ['ErpTabbedDataPanel', 'inventoryDataTabs', 'data-modern="inventory-admin-tabbed-data"', '库存业务数据']
},
{
name: 'operations administration page',
content: operationsAdminVue,
tokens: ['ErpTabbedDataPanel', 'operationsDataTabs', 'data-modern="operations-admin-tabbed-data"', '运营业务数据']
},
{
name: 'sales administration page',
content: salesAdminVue,
tokens: ['ErpTabbedDataPanel', 'salesDataTabs', 'data-modern="sales-admin-tabbed-data"', '销售业务数据']
},
{
name: 'procurement administration page',
content: procurementAdminVue,
tokens: ['ErpTabbedDataPanel', 'procurementDataTabs', 'data-modern="procurement-admin-tabbed-data"', '采购业务数据']
},
{
name: 'human resources administration page',
content: humanResAdminVue,
tokens: ['ErpTabbedDataPanel', 'humanResDataTabs', 'data-modern="humanres-admin-tabbed-data"', '人事业务数据']
},
{
name: 'Scrum administration page',
content: scrumAdminVue,
tokens: ['ErpTabbedDataPanel', 'scrumDataTabs', 'data-modern="scrum-admin-tabbed-data"', '敏捷业务数据']
}
]
for (const adminPage of convertedTabbedAdminExpectations) {
for (const text of adminPage.tokens) {
assert.match(adminPage.content, new RegExp(text), `${adminPage.name} should use ErpTabbedDataPanel for real business data partitions: ${text}`)
}
}
const secondaryAdminExpectations = [
{
viewName: 'ContentAdminView',
content: contentAdminVue,
route: '#/content',
activeView: 'content-admin',
labels: ['内容管理台', '内容资源', '站点管理', 'CMS 树', '论坛消息', '博客文章', '发布风险', '内容业务数据'],
executionLabels: ['内容执行台', '处理队列', '复核交接', '风险关注'],
entities: ['Content', 'DataResource', 'WebSite', 'WebPage', 'ElectronicText', 'CommunicationEvent']
},
{
viewName: 'MarketingAdminView',
content: marketingAdminVue,
route: '#/marketing',
activeView: 'marketing-admin',
labels: ['营销管理台', '营销活动', '联系名单', '追踪码', '细分群组', '活动统计', '营销风险', '营销业务数据'],
executionLabels: ['营销执行台', '处理队列', '复核交接', '风险关注'],
entities: ['MarketingCampaign', 'ContactList', 'TrackingCode', 'SegmentGroup', 'CommunicationEvent', 'PartyRole']
},
{
viewName: 'CommerceAdminView',
content: commerceAdminVue,
route: '#/commerce',
activeView: 'commerce-admin',
labels: ['电商管理台', '购物车', '会员订单', '商品浏览', '退货请求', '客户资料', '电商风险', '电商业务数据'],
executionLabels: ['电商执行台', '处理队列', '复核交接', '风险关注'],
entities: ['ShoppingList', 'OrderHeader', 'Product', 'ReturnHeader', 'Party', 'ProductStore']
},
{
viewName: 'PosAdminView',
content: posAdminVue,
route: '#/pos',
activeView: 'pos-admin',
labels: ['POS 管理台', '门店购物车', '收银订单', '支付记录', '经理授权', '门店库存', '收银风险', 'POS 业务数据'],
executionLabels: ['POS 执行台', '处理队列', '复核交接', '风险关注'],
entities: ['ShoppingList', 'OrderHeader', 'Payment', 'UserLogin', 'Facility', 'InventoryItem']
},
{
viewName: 'MarketplaceAdminView',
content: marketplaceAdminVue,
route: '#/marketplace',
activeView: 'marketplace-admin',
labels: ['店铺运营管理台', '店铺配置', '物流方式', '库存同步', '活动刊登', '店铺商品', '店铺风险', '店铺业务数据'],
executionLabels: ['店铺执行台', '处理队列', '复核交接', '风险关注'],
entities: ['EbayConfig', 'EbayShippingMethod', 'EbayProductStoreInventory', 'EbayProductListing', 'ProductStore', 'Product']
},
{
viewName: 'AnalyticsAdminView',
content: analyticsAdminVue,
route: '#/analytics',
activeView: 'analytics-admin',
labels: ['报表分析管理台', '数据维度', '事实数据', '报表资源', '报表发布', '输出队列', '报表风险', '报表业务数据'],
executionLabels: ['报表执行台', '处理队列', '复核交接', '风险关注'],
entities: ['DateDimension', 'ProductDimension', 'SalesOrderItemFact', 'SalesInvoiceItemFact', 'DataResource', 'Enumeration']
}
]
for (const adminPage of secondaryAdminExpectations) {
assert.match(routerTs, new RegExp(`${adminPage.viewName}`), `${adminPage.viewName} should be mounted as a dedicated administrator page`)
assertRouteMapsToView(adminPage.route, adminPage.viewName, `${adminPage.route} should resolve to ${adminPage.activeView}`)
assert.match(shellVue + dashboardVue + moduleCatalog, new RegExp(adminPage.route.replace('/', '\\/')), `${adminPage.viewName} should be reachable from administrator navigation`)
assert.match(adminPage.content, /ErpDomainAdminView/, `${adminPage.viewName} should use the shared domain admin renderer that loads entity sources`)
for (const text of adminPage.labels) {
assert.match(adminPage.content, new RegExp(text), `${adminPage.viewName} should expose ERP operation label: ${text}`)
}
for (const text of adminPage.executionLabels) {
assert.match(adminPage.content, new RegExp(text), `${adminPage.viewName} should expose execution-desk label: ${text}`)
}
for (const entityName of adminPage.entities) {
assert.match(adminPage.content, new RegExp(`entityName: '${entityName}'`), `${adminPage.viewName} should declare real ${entityName} rows for the domain renderer`)
}
assert.doesNotMatch(
adminPage.content,
/Demo[A-Za-z0-9_]*|Sample[A-Za-z0-9_]*|MOCK-\d+|组件展厅|页面清单|业务等价|待验收|迁移|技术预览/,
`${adminPage.viewName} should not expose fake rows or engineering language`
)
}
for (const text of ['executionDeskRows', 'reviewHandoffRows', 'riskFocusRows', 'domainWorkQueueRows', 'recentBusinessRows', 'handoffRows', 'businessActionRows', '业务执行台', '处理队列', '复核交接', '风险关注', '业务队列', '业务记录', '快捷处理', '异常交接', '处理流', '进入处理']) {
assert.match(domainAdminVue, new RegExp(text), `shared domain administrator renderer must render a complete ERP operation console: ${text}`)
}
for (const selector of ['domain-admin-execution-desk', 'domain-admin-execution-row', 'domain-admin-review-list', 'domain-admin-risk-strip']) {
assert.match(domainAdminVue + modernCss, new RegExp(selector), `shared domain administrator renderer should provide quiet execution desk layout: ${selector}`)
}
const domainAdminCssStart = modernCss.indexOf('.domain-admin-hero')
const domainAdminCssEnd = modernCss.indexOf('.modern-table-search', domainAdminCssStart)
const domainAdminCss = domainAdminCssStart === -1 ? '' : modernCss.slice(domainAdminCssStart, domainAdminCssEnd === -1 ? undefined : domainAdminCssEnd)
assert.doesNotMatch(
domainAdminCss,
/border-left/,
'shared domain administrator layout should use quiet 1px borders or top rules, not colored left bars'
)
assert.doesNotMatch(
domainAdminVue,
/组件展厅|页面清单|业务等价|待验收|迁移|技术预览|预览/,
'shared domain administrator renderer must not expose preview, migration, parity, or component-gallery language'
)
assert.match(
systemVue,
/安全与会话|运行任务|运行日志|缓存维护|导入导出|用户与权限|权限与业务域/,
'system maintenance should expose administrator operations instead of design or validation workbenches'
)
assert.doesNotMatch(systemVue, /应用权限|system-app-list|navigation\.slice/, 'system maintenance must organize permissions by ERP business domain, not by the old application list')
assert.match(routerTs, /SecurityAdminView/, 'administrator site should include a dedicated security administration view')
assertRouteMapsToView('#/system/security', 'SecurityAdminView', 'security administration should have a dedicated system route')
assert.match(systemVue, /#\/system\/security/, 'system maintenance should link to the dedicated security administration page')
for (const text of ['用户与权限管理', '账号与登录', '安全组', '授权关系', '权限业务数据']) {
assert.match(securityAdminVue, new RegExp(text), `security administration page should expose administrator operation: ${text}`)
}
for (const entityName of ['UserLogin', 'SecurityGroup', 'UserLoginSecurityGroup']) {
assertEntitySource(securityAdminVue, entityName, `security administration page should load real ${entityName} rows`)
}
assert.match(routerTs, /SystemOperationsView/, 'administrator site should include a dedicated system operations view')
assertRouteMapsToView('#/system/operations', 'SystemOperationsView', 'system operations should have a dedicated system route')
assert.match(systemVue + shellVue, /#\/system\/operations/, 'system maintenance and shell should link to the dedicated system operations page')
for (const text of ['系统运行管理', '计划任务', '缓存维护', '导入导出', '运行资源', '运行业务数据']) {
assert.match(systemOperationsVue, new RegExp(text), `system operations page should expose administrator operation: ${text}`)
}
for (const entityName of ['JobSandbox', 'SystemProperty', 'DataResource', 'ExcelImportHistory']) {
assert.match(systemOperationsVue, new RegExp(`getEntityRows\\('${entityName}'`), `system operations page should load real ${entityName} rows`)
}
assert.doesNotMatch(
systemVue + systemOperationsVue,
/props\.inventory|UiInventory|routeManifest|counts\?\.routes|counts\?\.actions|counts\?\.services|业务回归记录|界面组件规范|排版规则|覆盖台账|现代页面|动作契约|#\/system\/delivery|#\/system\/parity|#\/system\/components|#\/system\/patterns|#\/system\/inventory|待补齐事项|pendingE2ePages|customParityPages|missingRoutes|missingActions/,
'system maintenance should not foreground validation, component documentation, inventory counts, or engineering workbenches'
)
for (const text of ['运行日志', '缓存维护', '定时任务', '导入导出']) {
assert.match(systemVue, new RegExp(text), `system maintenance should expose administrator operation: ${text}`)
}
assert.doesNotMatch(
systemVue + moduleCatalog,
/快速进入安全、服务|WebTools|OFBiz 扩展|OFBiz 管理|接口|后端|后台|实体对象|业务对象|业务实体|待处理对象|可处理对象/,
'system maintenance and module catalog copy must use administrator-facing task, record, and connection language'
)
for (const text of ['统一业务中心', '业务受理台', '待办事项', '当前班次', '当前办理口径', '今日承接', '优先处理', '异常队列', '续办记录', '待办队列', '快捷处理', '处理建议', '进入处理']) {
assert.match(businessCenterVue, new RegExp(text), `global business center must use operator-facing language: ${text}`)
}
assert.doesNotMatch(
businessCenterVue + moduleVue,
/待登录授权|待授权|需授权|实体可读|可读实体|数据连接|数据状态|业务深链|处理路径/,
'business center and module workspaces must not use catalog, authorization-diagnostic, or route-path wording as primary product language'
)
for (const text of ['business-center-workbench', 'business-command-grid', 'business-shift-board', 'business-priority-board', 'business-exception-board', 'business-continue-board', 'business-center-queue-card', 'business-command-lane', 'business-priority-row', '业务受理台', '办理口径', '待办总量']) {
assert.match(businessCenterVue, new RegExp(text), `global business center must render a real administrator workbench: ${text}`)
}
for (const text of ['operatorEntryGroups', 'business-center-entry-grid', 'business-center-entry-card', '处理动作', '订单接收', '客户建档', '财务复核', '库存接收', '系统运维']) {
assert.match(businessCenterVue + modernCss, new RegExp(text), `global business center must provide fixed administrator processing entries: ${text}`)
}
for (const text of ['getGlobalBusinessSearch', 'businessSearch', 'businessRecords', '待办事项', '当前班次']) {
assert.match(businessCenterVue, new RegExp(text), `global business center must search real business records: ${text}`)
}
assert.match(apiTs, /export async function getGlobalBusinessSearch/, 'API client should expose a real business search loader')
assert.match(apiTypes, /export type GlobalBusinessSearch/, 'API types should document the global business search contract')
for (const entityName of ['OrderHeader', 'Product', 'Party', 'Invoice', 'Payment', 'InventoryItem', 'Shipment']) {
assert.match(apiTs, new RegExp(`entityName: '${entityName}'`), `global business search should include ${entityName}`)
}
assert.doesNotMatch(
businessCenterVue,
/props\.inventory\.routeManifest|routeManifest|label="页面标识"|label="适配"|label="验收清单"|label="业务等价"|label="控制规则"|label="旧入口"|旧 URL|旧入口|历史地址|待补齐事项|missingRoutes|missingActions|generated-renderable|generated-adapter-renderable|generated-needs-custom-vue|risk ===|row\.risk/,
'global business center should not use route inventory or migration columns as operator data'
)
for (const text of ['ErpTabbedDataPanel', 'businessDataTabs', 'data-modern="business-page-tabbed-data"', '处理台', '资料明细', '处理动作', '操作记录', '当前单据', '业务资料', '后续处理', '流转记录', 'businessDocumentRows', 'businessActionRows', 'businessFlowRows', 'businessRecordRows']) {
assert.match(businessPageVue, new RegExp(text), `business pages must behave like ERP document pages: ${text}`)
}
assert.doesNotMatch(
businessPageVue,
/label="工作面"|label="数据区"|label="执行区"|label="审批流"|后端服务协同|Service Dispatcher|统一 Lookup|业务工作面|页面结构|动作编码|当前页面|页面动作|动作清单|页面定义|旧入口|旧 URL|适配/,
'business pages should not read like an engineering console'
)
for (const [fileName, content] of [
['ErpAppShell.vue', shellVue],
['DashboardView.vue', dashboardVue],
['BusinessPageView.vue', businessPageVue],
['BusinessCenterView.vue', businessCenterVue],
['SystemToolsView.vue', systemVue],
['SecurityAdminView.vue', securityAdminVue],
['ModuleWorkspaceView.vue', moduleVue]
]) {
assert.doesNotMatch(
content,
/组件展厅|验收|迁移|业务等价|覆盖率|覆盖台账|待补齐|旧入口|旧 URL|旧页面|技术预览|页面清单|自动迁移/,
`${fileName} must not expose preview, migration, parity, or component-gallery language in the administrator product`
)
}
for (const selector of [
'modern-side-nav-group',
'module-board-grid',
'module-queue-card',
'module-record-list',
'admin-backend-row'
]) {
assert.match(modernCss, new RegExp(selector), `modern CSS should style the administrator site pattern: ${selector}`)
}
console.log(JSON.stringify({
status: 'passed',
checked: [
'App.vue',
'ErpAppShell.vue',
'DashboardView.vue',
'ModuleWorkspaceView.vue',
'SystemToolsView.vue',
'SecurityAdminView.vue',
'OrderAdminView.vue',
'PartyAdminView.vue',
'ProductAdminView.vue',
'InventoryAdminView.vue',
'ManufacturingAdminView.vue',
'AccountingAdminView.vue',
'HumanResAdminView.vue',
'SalesAdminView.vue',
'ProcurementAdminView.vue',
'ScrumAdminView.vue',
'OperationsAdminView.vue',
'ContentAdminView.vue',
'MarketingAdminView.vue',
'CommerceAdminView.vue',
'PosAdminView.vue',
'MarketplaceAdminView.vue',
'AnalyticsAdminView.vue',
'SystemOperationsView.vue',
'BusinessCenterView.vue',
'ErpDomainAdminView.vue',
'converted tabbed admin pages',
'secondary domain admin pages',
'moduleCatalog.ts',
'modern.css',
'api.ts',
'api.ts types'
]
}, null, 2))
@@ -0,0 +1,150 @@
#!/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 { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
const parityScript = path.join(appRoot, 'scripts/verify-parity.mjs')
const browserScript = path.join(appRoot, 'scripts/verify-browser-runtime.mjs')
const adminProductScript = path.join(appRoot, 'scripts/verify-admin-product.mjs')
const adminSiteScript = path.join(appRoot, 'scripts/verify-admin-site.mjs')
const rendererCopyScript = path.join(appRoot, 'scripts/verify-erp-renderer-production-copy.mjs')
const packageFile = path.join(appRoot, 'package.json')
const source = await readFile(parityScript, 'utf8')
const browserSource = await readFile(browserScript, 'utf8')
const adminProductSource = await readFile(adminProductScript, 'utf8')
const adminSiteSource = await readFile(adminSiteScript, 'utf8')
const rendererCopySource = await readFile(rendererCopyScript, 'utf8')
const packageJson = JSON.parse(await readFile(packageFile, 'utf8'))
function functionBody(name) {
const marker = `async function ${name}`
const start = source.indexOf(marker)
if (start < 0) return ''
const braceStart = source.indexOf('{', start)
if (braceStart < 0) return ''
let depth = 0
for (let index = braceStart; index < source.length; index += 1) {
const char = source[index]
if (char === '{') depth += 1
if (char === '}') depth -= 1
if (depth === 0) return source.slice(braceStart + 1, index)
}
return ''
}
const smokeBody = functionBody('verifyRuntimeSmoke')
const pageBody = functionBody('verifyModernUiRuntime')
const runtimeBody = functionBody('openModernUiRuntime')
function includesAll(content, tokens) {
return tokens.every((token) => content.includes(token))
}
const checks = [
{
id: 'single-browser-runtime',
passed: runtimeBody.includes('launchChrome(')
&& smokeBody.includes('openModernUiRuntime(chrome)')
&& smokeBody.includes('closeModernUiRuntime(modernRuntime)')
},
{
id: 'no-per-page-chrome-launch',
passed: !pageBody.includes('launchChrome(') && !pageBody.includes('closeChrome(')
},
{
id: 'cdp-fallback-labelled',
passed: source.includes("renderMode: 'chrome-cdp'")
},
{
id: 'browser-runtime-script',
passed: packageJson.scripts?.['verify:browser-runtime'] === 'node scripts/verify-browser-runtime.mjs'
&& browserSource.includes('launchChrome(chrome')
&& browserSource.includes('Page.captureScreenshot')
&& browserSource.includes('browser-runtime-report.json')
},
{
id: 'browser-runtime-product-anchors',
passed: browserSource.includes('document.body.innerText')
&& includesAll(browserSource, ['ERP 管理员工作台', '今日运营', '待办队列', '最近记录', '快捷动作'])
&& !browserSource.includes("text.includes('<div id=\"app\">')")
&& !browserSource.includes("text.includes('<div id=\"app\"')")
},
{
id: 'runtime-console-capture',
passed: browserSource.includes('__modernRuntimeConsoleErrors')
&& browserSource.includes('runtimeDiagnostics(client')
},
{
id: 'parity-runtime-uses-authenticated-modern-session',
passed: runtimeBody.includes('Network.enable')
&& runtimeBody.includes('loginApiSession()')
&& runtimeBody.includes('setRuntimeCookie(client, cookie)')
&& source.includes('securedLoginToken')
&& source.includes('modernSecuredLoginIdCookieName')
},
{
id: 'admin-rendering-alias-is-product-verification',
passed: packageJson.scripts?.['verify:admin-rendering']?.includes('verify:admin-site')
&& packageJson.scripts?.['verify:admin-rendering']?.includes('verify:admin-product')
&& packageJson.scripts?.['verify:admin-rendering']?.includes('verify:erp-renderer-copy')
&& packageJson.scripts?.['verify:admin-rendering']?.includes('verify:browser-runtime-policy')
&& !packageJson.scripts?.['verify:admin-rendering']?.includes('verify:preview')
},
{
id: 'static-product-verifiers-block-visible-leakage',
passed: includesAll(adminProductSource, ['visibleUiLeakageTerms', 'assertNoVisibleUiLeakage'])
&& includesAll(adminSiteSource, ['visibleUiLeakageTerms', 'assertNoVisibleUiLeakage'])
&& rendererCopySource.includes('visibleLeakageTerms')
&& includesAll(adminProductSource + adminSiteSource + rendererCopySource, ['组件展厅', '业务等价', '待验收', '技术预览', '页面清单'])
},
{
id: 'static-product-verifiers-require-post-login-anchors',
passed: includesAll(adminProductSource, ['postLoginCoreModuleAnchors', 'businessPageAnchors', 'assertPostLoginProductAnchors'])
&& includesAll(adminSiteSource, ['postLoginCoreModuleAnchors', 'assertPostLoginProductAnchors'])
&& includesAll(adminProductSource + adminSiteSource, ['#/orders', '#/catalog/products', '#/parties', '#/accounting', '#/facility', '#/business'])
&& includesAll(adminProductSource, ['order__showcart', 'party__NewCustomer', 'accounting__ManualTransaction', 'facility__ReceiveInventoryAgainstPurchaseOrder'])
},
{
id: 'parity-report-keeps-honest-boundary',
passed: includesAll(source, [
'businessDepthParityGate',
'fullBusinessParityVerified',
'fullCompletionClaimAllowed',
'mustNotClaim100PercentBusinessParity',
'structural-route-action-coverage-complete-business-e2e-pending',
'This report separates frontend rewrite coverage from deep business parity'
])
}
]
const failed = checks.filter((check) => !check.passed)
console.log(JSON.stringify({
status: failed.length ? 'failed' : 'passed',
checks
}, null, 2))
if (failed.length) {
process.exitCode = 1
}
@@ -0,0 +1,457 @@
#!/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 { fileURLToPath } from 'node:url'
import {
CdpConnection,
browserExecutionContext,
closeChrome,
createBrowserSetupTracker,
evaluate,
findChrome,
installApiMock,
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'
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 reportFile = path.join(outDir, 'browser-runtime-report.json')
const screenshotFile = path.join(outDir, 'browser-runtime-admin.png')
const baseUrl = normalizeBaseUrl(process.env.MODERN_UI_BASE_URL || 'http://127.0.0.1:8080/modern/app/')
const baseUrlTimeoutMs = Number(process.env.MODERN_UI_BASE_URL_TIMEOUT_MS || 8000)
const cdpCommandTimeoutMs = Number(process.env.MODERN_UI_CDP_TIMEOUT_MS || 10000)
const chromeStartupTimeoutMs = Number(process.env.MODERN_UI_CHROME_STARTUP_TIMEOUT_MS || 15000)
const renderTimeoutMs = Number(process.env.MODERN_UI_BROWSER_RENDER_TIMEOUT_MS || 20000)
const overallTimeoutMs = Number(process.env.MODERN_UI_BROWSER_RUNTIME_TIMEOUT_MS || 60000)
const browserSetupAttempts = positiveIntegerEnv('MODERN_UI_BROWSER_SETUP_ATTEMPTS', 1)
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-browser-runtime-session'
}
const optionalEbayEntityNames = new Set([
'EbayConfig',
'EbayShippingMethod',
'EbayProductListing',
'EbayProductStoreInventory',
'EbayProductStorePref',
'EBayLogMessagesInfo',
'EbayUserBestOffer'
])
const optionalEbayEntityFields = {
EbayConfig: ['productStoreId', 'siteId', 'compatibilityLevel', 'apiServerUrl', 'xmlGatewayUri', 'webSiteId'],
EbayShippingMethod: ['productStoreId', 'shipmentMethodName', 'methodTypeEnumId', 'amount', 'carrierPartyId', 'shipmentMethodTypeId'],
EbayProductListing: ['productListingId', 'itemId', 'productStoreId', 'productId', 'statusId', 'startDateTime', 'endDateTime', 'autoRelisting'],
EbayProductStoreInventory: ['productStoreId', 'facilityId', 'productId', 'ebayProductId', 'availableToPromiseListing', 'activeListing', 'sold', 'successRatio'],
EbayProductStorePref: ['productStoreId', 'autoPrefEnumId', 'enabled', 'condition1', 'condition2', 'condition3', 'autoPrefJobId', 'parentPrefCondId'],
EBayLogMessagesInfo: ['productStoreId', 'logAck', 'functionName', 'logMessage', 'createDatetime'],
EbayUserBestOffer: ['productStoreId', 'itemId', 'bestOfferId', 'userId', 'contactStatus']
}
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 mockEntityRows = {
OrderHeader: [
{ orderId: 'DEMO-ORDER-1001', orderTypeId: 'SALES_ORDER', statusId: 'ORDER_APPROVED', orderDate: '2026-06-09', createdBy: 'admin' }
],
Product: [
{ productId: 'DEMO-PRODUCT-1001', productTypeId: 'FINISHED_GOOD', internalName: '演示商品', brandName: 'OFBiz' }
],
Party: [
{ partyId: 'DEMO-CUSTOMER-1001', partyTypeId: 'PERSON', statusId: 'PARTY_ENABLED', description: '演示客户' }
],
Invoice: [
{ invoiceId: 'DEMO-INVOICE-1001', invoiceTypeId: 'SALES_INVOICE', statusId: 'INVOICE_READY', partyId: 'DEMO-CUSTOMER-1001', invoiceDate: '2026-06-09' }
],
InventoryItem: [
{ inventoryItemId: 'DEMO-INVENTORY-1001', productId: 'DEMO-PRODUCT-1001', facilityId: 'DEMO_FACILITY', statusId: 'INV_AVAILABLE', datetimeReceived: '2026-06-09' }
]
}
function entityRowsPayload(url) {
const entityName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '')
const rows = mockEntityRows[entityName] || []
const fieldNames = rows[0]
? Object.keys(rows[0])
: optionalEbayEntityFields[entityName] || []
return {
ok: true,
data: {
entityName,
rows,
fields: fieldNames.map((name) => ({ name, title: name })),
page: 0,
pageSize: rows.length || 5,
total: rows.length,
hasMore: false,
unavailable: optionalEbayEntityNames.has(entityName) && rows.length === 0,
reason: optionalEbayEntityNames.has(entityName) && rows.length === 0
? optionalEbayBusinessEmptyReason
: undefined
},
messages: [],
warnings: [],
traceId: `modern-browser-runtime-${entityName || 'entity'}`
}
}
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 isAuthFailure(failure) {
const status = Number(failure.status || 0)
return (status === 401 || status === 403) && String(failure.url || '').includes('/api/')
}
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 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)
}
}
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 normalizeBaseUrl(url) {
return String(url || '').replace(/\/+$/, '')
}
function logProgress(message) {
console.error(`[verify-browser-runtime] ${message}`)
}
function positiveIntegerEnv(name, fallback) {
const parsed = Number(process.env[name] || fallback)
return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : fallback
}
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, { signal: controller.signal })
} finally {
clearTimeout(timeout)
}
}
async function assertBaseUrlReady() {
let response
try {
response = await fetchWithTimeout(`${baseUrl}/`, baseUrlTimeoutMs)
} catch (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(error)}.${localNetworkHint(error, 'npm --prefix plugins/modern-ui/app run verify:browser-runtime')}`)
}
if (!response.ok) {
throw new Error(`Modern UI base URL returned HTTP ${response.status}: ${baseUrl}/`)
}
const html = await response.text()
if (!html.includes('<!doctype html') && !html.includes('<div id="app">')) {
throw new Error(`Modern UI app shell was not returned from ${baseUrl}/`)
}
}
async function writeReport(report) {
await mkdir(outDir, { recursive: true })
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`)
}
async function openPreparedClient(chrome) {
let lastError = null
const setupTracker = createBrowserSetupTracker({
scope: 'Modern UI verification',
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-browser-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)
await installSessionMock(client, mockedSession)
await installApiMock(client, {
'*://*/api/v1/entities/*': entityRowsPayload
})
return {
session,
client,
network,
consoleCapture,
browserSetup: setupTracker.snapshot({
status: 'passed',
attemptsUsed: attempt
})
}
} 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} failed with ${setupTracker.attempts.at(-1)?.category}; retrying within MODERN_UI_BROWSER_SETUP_ATTEMPTS=${browserSetupAttempts}: ${error.message}`)
}
}
throw new Error(summarizeBrowserSetupFailure({
scope: 'Modern UI verification',
attempts: setupTracker.attempts,
maxAttempts: browserSetupAttempts,
chrome,
candidates: setupTracker.snapshot().candidates,
lastError
}))
}
async function main() {
const startedAt = new Date().toISOString()
const overallTimeout = setTimeout(() => {
console.error(`[verify-browser-runtime] overall timeout after ${overallTimeoutMs}ms`)
process.exit(124)
}, overallTimeoutMs)
overallTimeout.unref?.()
const chrome = findChrome()
const report = {
status: 'failed',
startedAt,
baseUrl: `${baseUrl}/`,
chrome,
browserSetup: browserExecutionContext(chrome, browserSetupAttempts),
screenshot: screenshotFile,
checks: [],
consoleErrors: [],
networkFailures: [],
businessEmptyData: [],
authFailures: [],
diagnostics: null
}
try {
logProgress(`checking base URL ${baseUrl}/`)
await assertBaseUrlReady()
report.checks.push({ id: 'base-url', status: 'passed' })
if (!chrome) {
throw new Error(summarizeBrowserSetupFailure({
scope: 'Modern UI verification',
maxAttempts: browserSetupAttempts,
chrome,
candidates: report.browserSetup.localChromeCdp.candidates,
lastError: new Error('Chrome executable not found. Set CHROME_BIN or install Google Chrome/Microsoft Edge.')
}))
}
report.checks.push({ id: 'chrome-executable', status: 'passed', chrome })
logProgress(`launching Chrome/CDP: ${chrome}`)
const { session, client, network, consoleCapture, browserSetup } = await openPreparedClient(chrome)
report.browserSetup = browserSetup
try {
report.checks.push({ id: 'chrome-cdp', status: 'passed' })
const url = `${baseUrl}/#/`
logProgress(`opening ${url}`)
await consoleCapture.reset()
await client.command('Page.navigate', { url })
await waitForExpression(client, "document.querySelector('#app') && document.body.innerText.trim().length > 0", renderTimeoutMs)
await waitForExpression(client, "document.body.innerText.includes('ERP 管理员工作台') && document.body.innerText.includes('今日运营') && document.body.innerText.includes('待办队列') && document.body.innerText.includes('业务状态') && document.body.innerText.includes('最近记录') && document.body.innerText.includes('快捷动作') && !document.body.innerText.includes('未连接')", renderTimeoutMs)
report.checks.push({ id: 'admin-render', status: 'passed', url })
// Includes the page window.__modernRuntimeConsoleErrors bucket plus CDP runtime events.
report.consoleErrors = await consoleCapture.snapshot()
const actionableErrors = actionableConsoleErrors(report.consoleErrors)
if (actionableErrors.length) {
throw new Error(`Modern UI emitted ${actionableErrors.length} runtime console error(s)`)
}
report.networkFailures = network.failures()
report.businessEmptyData = network.businessEmptyData()
report.authFailures = network.authFailures()
const criticalNetworkFailures = network.criticalFailures()
if (criticalNetworkFailures.length) {
throw new Error(`Modern UI failed to load critical browser resource(s): ${criticalNetworkFailures.map((failure) => `${failure.type} ${failure.status || failure.errorText} ${failure.url}`).join(' | ')}`)
}
report.checks.push({
id: 'console-errors',
status: 'passed',
count: 0,
ignored: report.consoleErrors.length - actionableErrors.length
})
report.checks.push({
id: 'network-auth-failures',
status: 'recorded',
count: report.authFailures.length
})
const screenshot = await client.command('Page.captureScreenshot', {
format: 'png',
fromSurface: true
})
await mkdir(outDir, { recursive: true })
await writeFile(screenshotFile, Buffer.from(screenshot.data, 'base64'))
report.checks.push({ id: 'screenshot', status: 'passed', file: screenshotFile })
report.status = 'passed'
} catch (error) {
report.networkFailures = network.failures()
report.businessEmptyData = network.businessEmptyData()
report.authFailures = network.authFailures()
report.diagnostics = await runtimeDiagnostics(client, 'browser-runtime').catch((diagnosticError) => ({
evaluateError: diagnosticError.message
}))
throw error
} finally {
client.close()
await closeChrome(session)
}
} catch (error) {
report.error = error.message
await writeReport(report)
throw error
} finally {
clearTimeout(overallTimeout)
}
await writeReport(report)
console.log(JSON.stringify(report, null, 2))
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
@@ -0,0 +1,31 @@
#!/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.
*/
process.env.OFBIZ_RUNTIME_SMOKE = process.env.OFBIZ_RUNTIME_SMOKE || 'sample'
process.env.OFBIZ_RUNTIME_SMOKE_LIMIT = process.env.OFBIZ_RUNTIME_SMOKE_LIMIT || '24'
const pageFilter = process.env.OFBIZ_RUNTIME_SMOKE_PAGE_IDS
? ` pages=${process.env.OFBIZ_RUNTIME_SMOKE_PAGE_IDS}`
: ''
console.log(`[verify-business-e2e-batch] runtime smoke mode=${process.env.OFBIZ_RUNTIME_SMOKE} limit=${process.env.OFBIZ_RUNTIME_SMOKE_LIMIT}${pageFilter}`)
console.log('[verify-business-e2e-batch] acceptance boundary: frontend rewrite coverage and full business parity are separate gates; sample smoke must not be reported as 100% full OFBiz business parity unless fullBusinessParityVerified=true.')
await import('./verify-parity.mjs')
@@ -0,0 +1,179 @@
#!/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 assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
const pageVue = await readFile(path.join(appRoot, 'src/views/BusinessPageView.vue'), 'utf8')
const tabbedDataPanelVue = await readFile(path.join(appRoot, 'src/components/erp/ErpTabbedDataPanel.vue'), 'utf8')
const rendererVue = await readFile(path.join(appRoot, 'src/components/erp/ErpPageRenderer.vue'), 'utf8')
const adapterVue = await readFile(path.join(appRoot, 'src/components/erp/ErpAdapterBlock.vue'), 'utf8')
const displayTs = await readFile(path.join(appRoot, 'src/utils/display.ts'), 'utf8')
const modernCss = await readFile(path.join(appRoot, 'src/styles/modern.css'), 'utf8')
for (const text of [
"activeTab = ref('overview')",
'const businessDataTabs = computed(() => [',
"{ id: 'overview', label: '处理台' }",
"{ id: 'records', label: '资料明细' }",
"{ id: 'actions', label: '处理动作' }",
"{ id: 'flow', label: '操作记录' }",
'<ErpTabbedDataPanel',
':tabs="businessDataTabs"',
'data-modern="business-page-tabbed-data"',
'data-modern-tab-panel="overview"',
'data-modern-tab-panel="records"',
'data-modern-tab-panel="actions"',
'data-modern-tab-panel="flow"',
"setActiveTab('records')",
"setActiveTab('actions')",
'businessDocumentRows',
'businessActionRows',
'businessFlowRows',
'businessRecordRows',
'businessCapabilityRows',
'businessContextRows',
'routePayloadRows',
'handoffRows',
'处理概况',
'处理步骤',
'后续处理',
'交接条件',
'办理资料',
'资料类型',
'明细列表',
'处理工具',
'可执行动作',
'business-page-console',
'business-page-workbench'
]) {
assert.ok(pageVue.includes(text), `business page must present an ERP document surface: ${text}`)
}
assert.doesNotMatch(
pageVue,
/<el-tabs|<el-tab-pane/,
'business pages must use ErpTabbedDataPanel instead of direct Element Plus tabs'
)
for (const text of [
'data-modern="erp-tabbed-data-panel"',
'v-for="tab in tabs"',
':name="tab.id"',
'{{ tab.label }}'
]) {
assert.ok(tabbedDataPanelVue.includes(text), `tabbed data wrapper must render data-driven business tabs: ${text}`)
}
for (const text of [
'处理台',
'资料明细',
'处理动作',
'操作记录',
'当前上下文'
]) {
assert.match(pageVue + rendererVue + modernCss, new RegExp(text), `business pages should read like an administrator ERP site: ${text}`)
}
assert.doesNotMatch(
pageVue,
/label="工作面"|label="数据区"|label="执行区"|label="审批流"|<el-tab-pane label="业务概览" name="overview"|<el-tab-pane label="数据与查询" name="data"|<el-tab-pane label="规则与权限" name="rules"|<el-tab-pane label="业务流程" name="workflow"|<el-tab-pane label="系统定义" name="definition"/,
'primary business tabs must not read like page-definition, engineering, or old console surfaces'
)
assert.doesNotMatch(
pageVue,
/页面数据面|控件能力|页面定义 JSON|接口定义|流程编号|后端服务协同|Service Dispatcher|统一 Lookup|业务工作面|页面结构|动作编码|元数据|适配器/,
'technical traceability copy should not appear in the primary business page chrome'
)
assert.doesNotMatch(
pageVue,
/前端已重写|适配器已覆盖|页面结构">已生成|界面规则|待输出比对|待验收|系统记录|页面配置|CodePreview|JSON\.stringify|{{\s*[^}]*pending-business-e2e[^}]*}}|待确认|规则与权限/,
'business page should use operational language instead of migration or acceptance wording'
)
assert.doesNotMatch(
pageVue,
/prop="method" label="Method"|prop="path" label="Path"|apiContracts|endpointRows/,
'ordinary ERP business pages must not expose API contract tables'
)
for (const text of ['处理台', '业务流转', '当前上下文', '业务记录', '业务参数已带入当前单据', '处理状态', '处理方式', '适用范围']) {
assert.match(pageVue + rendererVue, new RegExp(text), `business copy should orient users around operation: ${text}`)
}
assert.doesNotMatch(
pageVue,
/业务审计|审计轨迹|已留痕/,
'generated business pages must not present synthesized front-end context as real audit records'
)
assert.doesNotMatch(
pageVue,
/原 OFBiz 动作|旧 URL 参数|旧 GET|旧入口兼容|业务回归|功能等价|待业务回归|控制规则|页面规则|来源入口|来源参数/,
'operator-facing business pages must avoid migration, compatibility, and parity wording'
)
assert.match(rendererVue, /'client-behavior': '表单联动'/, 'technical client-behavior blocks should render with a business-facing label')
assert.match(rendererVue, /if \(block\.type === 'client-behavior'\) return '表单联动'/, 'renderer block titles should normalize client behavior blocks')
assert.match(adapterVue, /function blockHeading\(\)/, 'adapter blocks should normalize technical block titles')
assert.match(adapterVue, /kind\.value === 'client-behavior'\) return '表单联动'/, 'client behavior adapter blocks should not expose old technical titles')
assert.doesNotMatch(
pageVue + rendererVue + adapterVue,
/>\s*client-behavior\s*</,
'operator-facing business chrome must not expose generated client-behavior labels'
)
assert.match(pageVue, /function businessStepLabel\(/, 'business timeline should translate migration steps into operational steps')
assert.match(displayTs, /'order find order': '订单查询'/, 'common OFBiz order finder titles should render as Chinese product copy')
assert.match(displayTs, /function userFacingLabel\(/, 'shared display utilities should normalize user-visible generated labels')
assert.match(displayTs, /function userFacingText\(/, 'shared display utilities should normalize user-visible generated descriptions')
assert.match(displayTs, /forbiddenTechnicalText/, 'shared display utilities should centralize forbidden technical wording')
assert.match(displayTs, /replaceForbiddenTechnicalText/, 'shared display utilities should replace forbidden generated-page wording before rendering')
for (const [source, sourceName] of [
[pageVue, 'BusinessPageView.vue'],
[rendererVue, 'ErpPageRenderer.vue'],
[adapterVue, 'ErpAdapterBlock.vue']
]) {
assert.match(source, /userFacingLabel|userFacingText/, `${sourceName} must pass generated visible strings through business-language normalization`)
}
for (const selector of [
'business-page-console',
'business-page-workbench',
'business-page-side-panel',
'business-document-summary',
'business-timeline-list',
'business-page-queue-grid',
'business-page-action-card',
'business-page-handoff-row'
]) {
assert.match(modernCss, new RegExp(selector), `business page CSS should style console layout: ${selector}`)
}
console.log(JSON.stringify({
status: 'passed',
checked: ['BusinessPageView.vue', 'ErpPageRenderer.vue', 'modern.css']
}, null, 2))
@@ -0,0 +1,477 @@
#!/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 { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
const repoRoot = path.resolve(appRoot, '../../..')
const inventoryFile = path.join(repoRoot, 'plugins/modern-api/generated/ui-inventory.json')
const publicInventoryFile = path.join(appRoot, 'public/generated/ui-inventory.json')
const publicPagesDir = path.join(appRoot, 'public/generated/pages')
const builtInventoryFile = path.join(repoRoot, 'plugins/modern-ui/webapp/modern/app/generated/ui-inventory.json')
const builtPagesDir = path.join(repoRoot, 'plugins/modern-ui/webapp/modern/app/generated/pages')
const outDir = path.join(repoRoot, 'plugins/modern-ui/verification')
const outJson = path.join(outDir, 'coverage-verification.json')
const outMd = path.join(outDir, 'coverage-verification.md')
const supportedBlockTypes = new Set([
'legacy-screen',
'section',
'form',
'table',
'actions',
'menu',
'links',
'permission',
'client-behavior',
'report',
'search-workspace',
'entity-editor',
'domain-workspace',
'tree-workspace',
'calendar',
'lookup-workspace',
'commerce-surface',
'pos-workspace',
'route-workspace',
'template-adapter',
'html-template',
'empty'
])
function passed(id, label, actual = '') {
return { id, label, status: 'passed', actual }
}
function failed(id, label, expected, actual, samples = []) {
return { id, label, status: 'failed', expected, actual, samples: samples.slice(0, 25) }
}
function incomplete(id, label, expected, actual, samples = []) {
return { id, label, status: 'incomplete', expected, actual, samples: samples.slice(0, 25) }
}
function skipped(id, label, actual = '') {
return { id, label, status: 'skipped', actual }
}
function countBy(items, getKey) {
const counts = {}
for (const item of items) {
const key = getKey(item) || 'unknown'
counts[key] = (counts[key] || 0) + 1
}
return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])))
}
async function exists(file) {
try {
await access(file, constants.R_OK)
return true
} catch {
return false
}
}
async function readJson(file) {
return JSON.parse(await readFile(file, 'utf8'))
}
async function listJsonFiles(dir) {
try {
return (await readdir(dir)).filter((file) => file.endsWith('.json'))
} catch {
return null
}
}
function routePageUrl(route) {
return String(route.pageDefinitionUrl || '')
}
function pageFileForRoute(route, pagesDir) {
const url = routePageUrl(route)
if (!url) return ''
return path.join(pagesDir, path.basename(url))
}
function isBlockSupported(type = '') {
return supportedBlockTypes.has(type) || type.endsWith('-workspace')
}
function actionIdOf(action) {
return typeof action === 'string' ? action : action?.actionId
}
function samplePage(page) {
return {
pageId: page.pageId,
title: page.title,
legacyPath: page.legacy?.path || '',
domain: page.domain || '',
status: page.acceptance?.status || ''
}
}
function actionReferenceSamples(pages, actionDefinitions) {
const samples = []
for (const page of pages) {
for (const action of page.actions || []) {
const actionId = actionIdOf(action)
if (actionId && !actionDefinitions[actionId]) {
samples.push({ pageId: page.pageId, actionId, source: 'page.actions' })
}
}
for (const block of page.blocks || []) {
const submitAction = block.submitAction
const actionId = submitAction?.actionId
const status = String(submitAction?.status || '')
const mustResolve = submitAction?.apiExecutable
&& !['navigation-target', 'navigation-action', 'local-submit-contract', 'local-draft-contract', 'readonly-display', 'dynamic-target', 'unmapped-target-contract'].includes(status)
if (mustResolve && actionId && !actionDefinitions[actionId]) {
samples.push({ pageId: page.pageId, actionId, formName: block.formName, source: 'block.submitAction' })
}
}
}
return samples
}
function routeDuplicates(routes) {
const byPage = new Map()
for (const route of routes) {
const items = byPage.get(route.pageId) || []
items.push(route)
byPage.set(route.pageId, items)
}
return [...byPage.entries()]
.filter(([, items]) => items.length > 1)
.map(([pageId, items]) => ({
pageId,
count: items.length,
legacyPaths: items.map((item) => item.legacyPath).filter(Boolean)
}))
}
async function verifySplitInventory(label, inventoryPath, pagesDir, sourceInventory, checks) {
if (!await exists(inventoryPath)) {
checks.push(skipped(`${label}-inventory`, `${label} split inventory exists`, 'not generated yet'))
return
}
const splitInventory = await readJson(inventoryPath)
const pageCount = Object.keys(sourceInventory.pageDefinitions || {}).length
const pageFiles = await listJsonFiles(pagesDir)
const routeManifest = splitInventory.routeManifest || []
const missingUrls = routeManifest.filter((route) => !routePageUrl(route))
const embeddedPageDefinitions = Object.keys(splitInventory.pageDefinitions || {}).length
const embeddedActionDefinitions = Object.keys(splitInventory.actionDefinitions || {}).length
const missingFiles = []
if (pageFiles) {
const pageFileSet = new Set(pageFiles)
for (const route of routeManifest) {
const pageFile = pageFileForRoute(route, pagesDir)
if (pageFile && !pageFileSet.has(path.basename(pageFile))) {
missingFiles.push({ pageId: route.pageId, pageDefinitionUrl: route.pageDefinitionUrl })
}
}
}
checks.push(routeManifest.length === (sourceInventory.routeManifest || []).length
? passed(`${label}-route-count`, `${label} route manifest mirrors generated inventory`, routeManifest.length)
: failed(`${label}-route-count`, `${label} route manifest mirrors generated inventory`, (sourceInventory.routeManifest || []).length, routeManifest.length))
checks.push(pageFiles?.length === pageCount
? passed(`${label}-page-file-count`, `${label} split PageDefinition file count`, pageFiles.length)
: failed(`${label}-page-file-count`, `${label} split PageDefinition file count`, pageCount, pageFiles?.length ?? 'missing pages dir'))
checks.push(embeddedPageDefinitions === 0 && embeddedActionDefinitions === 0
? passed(`${label}-light-index`, `${label} inventory index stays lightweight`, 0)
: failed(`${label}-light-index`, `${label} inventory index stays lightweight`, 0, embeddedPageDefinitions + embeddedActionDefinitions))
checks.push(missingUrls.length === 0
? passed(`${label}-page-definition-urls`, `${label} every route points to a split PageDefinition`, routeManifest.length)
: failed(`${label}-page-definition-urls`, `${label} every route points to a split PageDefinition`, 0, missingUrls.length, missingUrls))
checks.push(missingFiles.length === 0
? passed(`${label}-page-files-resolve`, `${label} every route PageDefinition URL resolves to a file`, routeManifest.length)
: failed(`${label}-page-files-resolve`, `${label} every route PageDefinition URL resolves to a file`, 0, missingFiles.length, missingFiles))
}
const inventory = await readJson(inventoryFile)
const routes = inventory.routeManifest || []
const pagesById = inventory.pageDefinitions || {}
const pages = Object.values(pagesById)
const actionDefinitions = inventory.actionDefinitions || {}
const counts = inventory.counts || {}
const coverage = inventory.coverage || {}
const checks = []
const missingPageDefinitions = routes.filter((route) => !pagesById[route.pageId])
const pagesWithoutRoutes = pages.filter((page) => !routes.some((route) => route.pageId === page.pageId))
const duplicateRoutes = routeDuplicates(routes)
const unsupportedBlocks = []
const pagesMissingBasics = []
const pagesMissingAcceptance = []
const pagesMissingPermissions = []
const pagesMissingApiContract = []
const pagesPendingBusinessE2e = []
const pagesNotBusinessVerified = []
const pagesMissingBlocks = []
for (const page of pages) {
const blocks = page.blocks || []
const acceptance = page.acceptance || {}
const scenario = acceptance.e2eScenario || {}
if (!page.pageId || !page.title || !page.layout || !page.legacy?.path || !page.legacy?.controller) {
pagesMissingBasics.push(samplePage(page))
}
if (!blocks.length) {
pagesMissingBlocks.push(samplePage(page))
}
for (const block of blocks) {
if (!isBlockSupported(block.type)) {
unsupportedBlocks.push({ pageId: page.pageId, blockType: block.type, title: block.title || '' })
}
}
if (!Array.isArray(page.permissions) || page.permissions.length === 0) {
pagesMissingPermissions.push(samplePage(page))
}
if (!acceptance.scenarioId || !Array.isArray(scenario.steps) || scenario.steps.length === 0 || !Array.isArray(scenario.assertions) || scenario.assertions.length === 0) {
pagesMissingAcceptance.push(samplePage(page))
}
if (!Array.isArray(scenario.apiContracts) || !scenario.apiContracts.some((contract) => contract.method === 'GET' && String(contract.path || '').startsWith('/api/v1/pages/'))) {
pagesMissingApiContract.push(samplePage(page))
}
if (acceptance.scenarioStatus !== 'ready-for-business-e2e' && acceptance.businessParityStatus !== 'verified') {
pagesPendingBusinessE2e.push(samplePage(page))
}
if (acceptance.businessParityStatus !== 'verified') {
pagesNotBusinessVerified.push(samplePage(page))
}
}
const requestActions = []
for (const controller of inventory.controllers || []) {
for (const request of controller.requests || []) {
if (request.actionId) requestActions.push({ ...request, controller: controller.file, webapp: controller.webapp?.name || '' })
}
}
const missingRequestActions = requestActions.filter((request) => !actionDefinitions[request.actionId])
const missingActionReferences = actionReferenceSamples(pages, actionDefinitions)
const formContractCounts = coverage.formContractStatusCounts || {}
const explicitNonExecutableContracts = [
'readonly-display',
'local-submit-contract',
'local-draft-contract',
'dynamic-target',
'navigation-target',
'unmapped-target-contract'
].reduce((sum, key) => sum + (formContractCounts[key] || 0), 0)
checks.push(routes.length === counts.viewRoute
? passed('view-route-count', 'generated route count matches scanned legacy view-map count', routes.length)
: failed('view-route-count', 'generated route count matches scanned legacy view-map count', counts.viewRoute, routes.length))
checks.push(pages.length === counts.pageDefinition
? passed('page-definition-count', 'generated PageDefinition count matches inventory count', pages.length)
: failed('page-definition-count', 'generated PageDefinition count matches inventory count', counts.pageDefinition, pages.length))
checks.push(missingPageDefinitions.length === 0
? passed('missing-page-definitions', 'every legacy view route has a PageDefinition', 0)
: failed('missing-page-definitions', 'every legacy view route has a PageDefinition', 0, missingPageDefinitions.length, missingPageDefinitions))
checks.push(pagesWithoutRoutes.length === 0
? passed('orphan-page-definitions', 'every PageDefinition is reachable through a generated route', 0)
: failed('orphan-page-definitions', 'every PageDefinition is reachable through a generated route', 0, pagesWithoutRoutes.length, pagesWithoutRoutes.map(samplePage)))
checks.push(coverage.missingRoutes === 0
? passed('inventory-missing-routes', 'inventory reports missingRoutes=0', coverage.missingRoutes)
: failed('inventory-missing-routes', 'inventory reports missingRoutes=0', 0, coverage.missingRoutes))
checks.push(coverage.missingActions === 0
? passed('inventory-missing-actions', 'inventory reports missingActions=0', coverage.missingActions)
: failed('inventory-missing-actions', 'inventory reports missingActions=0', 0, coverage.missingActions))
checks.push(missingRequestActions.length === 0
? passed('request-action-definitions', 'every legacy request-map has an ActionDefinition', requestActions.length)
: failed('request-action-definitions', 'every legacy request-map has an ActionDefinition', 0, missingRequestActions.length, missingRequestActions))
checks.push(missingActionReferences.length === 0
? passed('page-action-references', 'every PageDefinition action reference resolves', pages.length)
: failed('page-action-references', 'every PageDefinition action reference resolves', 0, missingActionReferences.length, missingActionReferences))
checks.push(coverage.formMissingContractBlocks === 0
? passed('form-contracts', 'every generated form block has an explicit submit/read contract', coverage.formContractBlocks || 0)
: failed('form-contracts', 'every generated form block has an explicit submit/read contract', 0, coverage.formMissingContractBlocks))
checks.push(pagesMissingBasics.length === 0
? passed('page-definition-basics', 'every PageDefinition has title, layout and legacy source trace', pages.length)
: failed('page-definition-basics', 'every PageDefinition has title, layout and legacy source trace', 0, pagesMissingBasics.length, pagesMissingBasics))
checks.push(pagesMissingBlocks.length === 0
? passed('page-blocks', 'every PageDefinition has renderable blocks', pages.length)
: failed('page-blocks', 'every PageDefinition has renderable blocks', 0, pagesMissingBlocks.length, pagesMissingBlocks))
checks.push(unsupportedBlocks.length === 0
? passed('renderer-block-types', 'every generated block type is supported by the modern renderer layer', pages.length)
: failed('renderer-block-types', 'every generated block type is supported by the modern renderer layer', 0, unsupportedBlocks.length, unsupportedBlocks))
checks.push(pagesMissingPermissions.length === 0
? passed('permissions', 'every PageDefinition carries mapped OFBiz permissions', pages.length)
: failed('permissions', 'every PageDefinition carries mapped OFBiz permissions', 0, pagesMissingPermissions.length, pagesMissingPermissions))
checks.push(pagesMissingAcceptance.length === 0
? passed('acceptance-scenarios', 'every PageDefinition has an acceptance scenario', pages.length)
: failed('acceptance-scenarios', 'every PageDefinition has an acceptance scenario', 0, pagesMissingAcceptance.length, pagesMissingAcceptance))
checks.push(pagesMissingApiContract.length === 0
? passed('page-api-contracts', 'every PageDefinition has a GET /api/v1/pages/:pageId contract', pages.length)
: failed('page-api-contracts', 'every PageDefinition has a GET /api/v1/pages/:pageId contract', 0, pagesMissingApiContract.length, pagesMissingApiContract))
checks.push(pagesPendingBusinessE2e.length === 0
? passed('business-e2e-readiness', 'every page is ready for or has passed business e2e', pages.length)
: incomplete('business-e2e-readiness', 'pages still need domain business e2e execution before final rewrite completion', 0, pagesPendingBusinessE2e.length, pagesPendingBusinessE2e))
checks.push(pagesNotBusinessVerified.length === 0
? passed('business-parity-complete', 'every page has verified old/new business parity', pages.length)
: incomplete('business-parity-complete', 'full OFBiz functional parity is not complete until businessParityStatus is verified for every page', 0, pagesNotBusinessVerified.length, pagesNotBusinessVerified))
await verifySplitInventory('public', publicInventoryFile, publicPagesDir, inventory, checks)
await verifySplitInventory('built', builtInventoryFile, builtPagesDir, inventory, checks)
const failures = checks.filter((check) => check.status === 'failed')
const incompletes = checks.filter((check) => check.status === 'incomplete')
const byDomain = countBy(pages, (page) => page.domain)
const byLayout = countBy(pages, (page) => page.layout)
const byBusinessParityStatus = countBy(pages, (page) => page.acceptance?.businessParityStatus)
const byScenarioStatus = countBy(pages, (page) => page.acceptance?.scenarioStatus)
const report = {
status: failures.length ? 'failed' : incompletes.length ? 'structural-passed-business-incomplete' : 'passed',
generatedAt: new Date().toISOString(),
inventoryFile,
verificationBoundary: {
frontendRewriteCoverageGate: failures.length === 0 ? 'passed' : 'failed',
businessDepthParityGate: pagesNotBusinessVerified.length === 0 ? 'passed' : 'pending',
finalGate: failures.length === 0 && pagesNotBusinessVerified.length === 0
? 'full-business-parity-complete'
: failures.length === 0
? 'frontend-coverage-passed-business-depth-parity-pending'
: 'frontend-coverage-failed',
pendingBusinessParityPages: pagesNotBusinessVerified.length,
pendingBusinessE2ePages: pagesPendingBusinessE2e.length,
reportPath: outJson
},
summary: {
controllers: counts.controllerXml || 0,
widgetXml: counts.widgetXml || 0,
legacyViewRoutes: counts.viewRoute || 0,
routeManifest: routes.length,
uniquePageDefinitions: pages.length,
duplicateLegacyRouteAliases: duplicateRoutes.length,
requestActions: requestActions.length,
actionDefinitions: Object.keys(actionDefinitions).length,
serviceDefinitions: counts.service || 0,
renderedPages: coverage.renderablePages || 0,
structuredWidgetPages: coverage.structuredPages || 0,
adapterCoveredPages: coverage.adapterCoveredPages || 0,
pendingBusinessE2ePages: coverage.pendingE2ePages ?? pagesPendingBusinessE2e.length,
verifiedBusinessParityPages: byBusinessParityStatus.verified || 0,
explicitNonExecutableFormContracts: explicitNonExecutableContracts,
tableBlocks: coverage.tableBlocks || 0,
tableDataSourceBlocks: coverage.tableDataSourceBlocks || 0,
formContractBlocks: coverage.formContractBlocks || 0
},
checks,
routeAliases: duplicateRoutes,
distributions: {
byDomain,
byLayout,
byBusinessParityStatus,
byScenarioStatus,
formContractStatusCounts: formContractCounts,
blockTypeCounts: coverage.blockTypeCounts || {}
},
completionVerdict: {
structuralCoverageReady: failures.length === 0,
frontendRewriteCoverageGate: failures.length === 0,
fullBusinessParityVerified: pagesNotBusinessVerified.length === 0,
canClaimFrontendRewriteCoverageComplete: failures.length === 0,
canClaimFullBusinessParityComplete: failures.length === 0 && pagesNotBusinessVerified.length === 0,
mustNotClaim100PercentBusinessParity: pagesNotBusinessVerified.length > 0,
pendingBusinessParityPages: pagesNotBusinessVerified.length,
reason: pagesNotBusinessVerified.length === 0
? 'All generated legacy pages have verified business parity.'
: `Frontend rewrite coverage may pass independently, but ${pagesNotBusinessVerified.length} generated legacy pages still have pending business parity verification. Do not claim 100% full OFBiz business parity.`
}
}
function markdownTable(rows) {
return [
'| Check | Status | Actual |',
'| --- | --- | --- |',
...rows.map((check) => `| ${check.label.replaceAll('|', '\\|')} | ${check.status} | ${String(check.actual ?? '').replaceAll('|', '\\|')} |`)
].join('\n')
}
const md = [
'# Modern UI Coverage Verification',
'',
`Generated: ${report.generatedAt}`,
'',
`Status: ${report.status}`,
'',
'## Summary',
'',
`- Legacy view routes: ${report.summary.legacyViewRoutes}`,
`- Route manifest entries: ${report.summary.routeManifest}`,
`- Unique PageDefinitions: ${report.summary.uniquePageDefinitions}`,
`- Duplicate route aliases: ${report.summary.duplicateLegacyRouteAliases}`,
`- Legacy request actions: ${report.summary.requestActions}`,
`- ActionDefinitions: ${report.summary.actionDefinitions}`,
`- Rendered pages: ${report.summary.renderedPages}`,
`- Adapter-covered pages: ${report.summary.adapterCoveredPages}`,
`- Pending business e2e pages: ${report.summary.pendingBusinessE2ePages}`,
`- Verified business parity pages: ${report.summary.verifiedBusinessParityPages}`,
`- Pending business parity pages: ${report.verificationBoundary.pendingBusinessParityPages}`,
'',
'## Checks',
'',
markdownTable(checks),
'',
'## Completion Verdict',
'',
`- Structural coverage ready: ${report.completionVerdict.structuralCoverageReady}`,
`- Frontend rewrite coverage gate: ${report.verificationBoundary.frontendRewriteCoverageGate}`,
`- Business depth parity gate: ${report.verificationBoundary.businessDepthParityGate}`,
`- Final gate: ${report.verificationBoundary.finalGate}`,
`- Full business parity verified: ${report.completionVerdict.fullBusinessParityVerified}`,
`- Can claim frontend rewrite coverage complete: ${report.completionVerdict.canClaimFrontendRewriteCoverageComplete}`,
`- Can claim full business parity complete: ${report.completionVerdict.canClaimFullBusinessParityComplete}`,
`- Must not claim 100% business parity: ${report.completionVerdict.mustNotClaim100PercentBusinessParity}`,
`- Reason: ${report.completionVerdict.reason}`,
''
].join('\n')
await mkdir(outDir, { recursive: true })
await writeFile(outJson, JSON.stringify(report, null, 2))
await writeFile(outMd, md)
console.log(JSON.stringify({
status: report.status,
verificationBoundary: report.verificationBoundary,
summary: report.summary,
failedChecks: failures.map((check) => check.id),
incompleteChecks: incompletes.map((check) => check.id),
reports: {
json: outJson,
markdown: outMd
},
completionVerdict: report.completionVerdict
}, null, 2))
if (failures.length) {
process.exitCode = 1
}
@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const appRoot = resolve(here, '..')
const checks = [
{
file: 'src/components/erp/ErpAdapterBlock.vue',
forbidden: [
{ pattern: /<el-descriptions-item\s+label="布局"/, message: 'generic adapter must not expose layout metadata' },
{ pattern: /<el-descriptions-item\s+label="能力"/, message: 'generic adapter must not expose adapter capability metadata' }
]
},
{
file: 'src/components/erp/ErpDataTable.vue',
required: [
{ pattern: /记录状态/, message: 'drawer should include a business record status timeline' }
]
},
{
file: 'src/components/erp/ErpSearchForm.vue',
required: [
{ pattern: /查询条件/, message: 'search form should expose production query context' }
]
},
{
file: 'src/components/erp/ErpEntityForm.vue',
required: [
{ pattern: /资料状态/, message: 'entity form should expose production record context' }
]
}
]
const visibleLeakageTerms = [
{ pattern: /组件展厅|组件规范|排版规则|全量清单/, message: 'must not expose component gallery copy' },
{ pattern: /技术预览|>\s*预览\s*<|title="预览"|预览模式|页面预览/, message: 'must not expose preview copy' },
{ pattern: /迁移|自动迁移|旧入口|旧 URL|旧页面|历史地址|旧屏幕来源|旧 widget/i, message: 'must not expose migration or legacy-bridge copy' },
{ pattern: /业务等价|功能等价|旧新报表比对|逐页比对|真实输出比对/, message: 'must not expose parity copy' },
{ pattern: /验收|待验收|验收清单|业务回归|待业务回归/, message: 'must not expose acceptance copy' },
{ pattern: /页面清单|覆盖率|覆盖台账|待补齐|待输出比对/, message: 'must not expose inventory checklist copy' },
{ pattern: /页面定义|页面结构|动作契约|控制规则|模板适配|适配器已覆盖|前端已重写/, message: 'must not expose engineering metadata copy' }
]
const failures = []
for (const check of checks) {
const source = readFileSync(resolve(appRoot, check.file), 'utf8')
for (const item of check.forbidden || []) {
if (item.pattern.test(source)) {
failures.push(`${check.file}: ${item.message}`)
}
}
for (const item of check.required || []) {
if (!item.pattern.test(source)) {
failures.push(`${check.file}: ${item.message}`)
}
}
for (const item of visibleLeakageTerms) {
if (item.pattern.test(source)) {
failures.push(`${check.file}: ${item.message}`)
}
}
}
if (failures.length) {
console.error('ERP renderer production-copy policy failed:')
for (const failure of failures) console.error(`- ${failure}`)
process.exit(1)
}
console.log('ERP renderer production-copy policy passed')
@@ -0,0 +1,159 @@
#!/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 { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
const srcRoot = path.join(appRoot, 'src')
const files = {
base: path.join(srcRoot, 'styles/base.css'),
modern: path.join(srcRoot, 'styles/modern.css'),
tokens: path.join(srcRoot, 'styles/tokens.css'),
elementOverrides: path.join(srcRoot, 'styles/element-overrides.css'),
dataTable: path.join(srcRoot, 'components/erp/ErpDataTable.vue'),
appShell: path.join(srcRoot, 'components/erp/ErpAppShell.vue')
}
const bannedVisibleTerms = [
'组件展厅',
'预览',
'迁移',
'页面清单',
'业务等价',
'待验收',
'技术预览',
'旧入口'
]
async function sourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const nested = await Promise.all(entries.map(async (entry) => {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) return sourceFiles(file)
if (/\.(vue|ts|css)$/.test(entry.name)) return [file]
return []
}))
return nested.flat()
}
function blockFor(source, selector) {
const pattern = new RegExp(`${selector
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\s+/g, '\\s*')}\\s*\\{`, 'g')
let match
let lastMatch = null
while ((match = pattern.exec(source)) !== null) {
lastMatch = match
}
if (!lastMatch) return ''
const start = source.indexOf('{', lastMatch.index)
let depth = 0
for (let cursor = start; cursor < source.length; cursor += 1) {
const char = source[cursor]
if (char === '{') depth += 1
if (char === '}') depth -= 1
if (depth === 0) return source.slice(start + 1, cursor)
}
return ''
}
const sources = Object.fromEntries(await Promise.all(
Object.entries(files).map(async ([key, file]) => [key, await readFile(file, 'utf8')])
))
// Exclude the text-sanitizer utility: its whole purpose is to name and replace
// leaked internal terms (e.g. /预览/g -> 查看), so its replacement-rule literals
// are not visible UI copy and must not count as a leak.
const sanitizerSuffix = path.join('utils', 'display.ts')
const allSourceText = (await Promise.all(
(await sourceFiles(srcRoot))
.filter((file) => !file.endsWith(sanitizerSuffix))
.map((file) => readFile(file, 'utf8'))
)).join('\n')
const navActiveBlock = blockFor(sources.appShell, '.erp-nav :deep(.el-menu-item.is-active)')
const navHoverBlock = blockFor(sources.appShell, '.erp-nav :deep(.el-menu-item:hover),\n.erp-nav :deep(.el-sub-menu__title:hover)')
const tableBlock = blockFor(sources.modern, '.modern-table-block .erp-table')
const tableCellBlock = blockFor(sources.modern, '.erp-admin-surface .erp-table .el-table__cell,\n.business-center .erp-table .el-table__cell,\n.modern-table-block .erp-table .el-table__cell')
const checks = [
{
id: 'no-visible-internal-terms',
passed: bannedVisibleTerms.every((term) => !allSourceText.includes(term))
},
{
id: 'responsive-root-allows-mobile-width',
passed: !sources.base.includes('min-width: 1180px')
&& sources.base.includes('min-width: 0')
},
{
id: 'primary-navigation-is-a-grouped-collapsible-sidebar-tree',
passed: sources.appShell.includes('class="erp-nav"')
&& sources.appShell.includes('el-menu-item-group')
&& sources.appShell.includes('el-sub-menu')
&& sources.appShell.includes('核心业务')
&& sources.appShell.includes('渠道与扩展')
&& !sources.appShell.includes('modern-top-menu')
},
{
id: 'side-navigation-uses-line-state-not-pill-fill',
passed: navActiveBlock.includes('background: transparent')
&& navActiveBlock.includes('border-left: 2px solid var(--erp-color-primary)')
&& navActiveBlock.includes('color: var(--erp-color-primary)')
&& navHoverBlock.includes('background: var(--erp-color-surface-muted)')
},
{
id: 'erp-cards-have-large-medium-small-density-tokens',
passed: [
'--erp-card-density-large-min',
'--erp-card-density-medium-min',
'--erp-card-density-small-min',
'--erp-card-padding-large',
'--erp-card-padding-medium',
'--erp-card-padding-small'
].every((token) => sources.tokens.includes(token))
},
{
id: 'erp-table-density-is-quiet-and-readable',
passed: tableBlock.includes('border-radius: var(--erp-radius-xs)')
&& tableCellBlock.includes('padding: 5px 0')
&& sources.elementOverrides.includes('--el-table-row-hover-bg-color: var(--erp-color-surface-muted)')
&& sources.elementOverrides.includes('.el-table th.el-table__cell')
&& sources.dataTable.includes('data-modern="erp-data-table"')
},
{
id: 'sidebar-supports-collapse-for-narrow-screens',
passed: sources.appShell.includes(':collapse="collapsed"')
&& sources.appShell.includes("collapsed ? '64px' : '244px'")
&& (sources.appShell.includes('Fold') && sources.appShell.includes('Expand'))
}
]
const failed = checks.filter((check) => !check.passed)
console.log(JSON.stringify({
status: failed.length ? 'failed' : 'passed',
checks
}, null, 2))
if (failed.length) {
process.exitCode = 1
}
@@ -0,0 +1,88 @@
#!/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 assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
async function source(file) {
return readFile(path.join(appRoot, file), 'utf8')
}
const financeOperations = await source('src/components/erp/ErpFinanceOperationsWorkspace.vue')
const renderer = await source('src/components/erp/ErpPageRenderer.vue')
const previewVerifier = await source('scripts/verify-preview.mjs')
const screenshotScript = await source('scripts/capture-admin-screenshots.mjs')
const packageJson = await source('package.json')
for (const text of [
'财务运营',
'财务运营台',
'发票队列',
'发票查询',
'发票列表',
'收付款匹配',
'账龄风险',
'核销处理',
'发票详情'
]) {
assert.match(financeOperations, new RegExp(text), `custom finance operations workspace must expose ${text}`)
}
for (const text of [
'data-modern-workspace="finance-operations"',
'data-modern-finance-search="true"',
'data-modern-invoice-table="true"',
'data-modern-finance-drawer="true"'
]) {
assert.match(financeOperations, new RegExp(text), `finance operations workspace needs stable verification hook: ${text}`)
}
assert.match(financeOperations, /getEntityRows\('Invoice'/, 'finance operations workspace must load real Invoice rows from the entity API')
assert.match(financeOperations, /getEntityRows\('Payment'/, 'finance operations workspace must load real Payment rows from the entity API')
assert.match(financeOperations, /真实发票数据/, 'finance operations workspace must describe entity-backed invoice data')
assert.match(financeOperations, /真实付款数据/, 'finance operations workspace must describe entity-backed payment data')
assert.doesNotMatch(
financeOperations,
/fieldRows\.value\.map|actionRows\.value\.map\(\(action, index\)|字段来自当前 OFBiz 页面定义|个字段来自当前 OFBiz 页面定义|字段完整|发票号、往来方、状态和金额字段/,
'finance operations workspace must not synthesize invoices or payments from generated metadata'
)
assert.match(renderer, /ErpFinanceOperationsWorkspace/, 'page renderer should import the custom finance operations workspace')
assert.match(renderer, /isFinanceOperationsPage/, 'renderer should route invoice and payment pages through the finance operations workspace')
assert.match(previewVerifier, /finance-invoices-page/, 'preview verification should smoke-test the custom invoice finder')
assert.match(previewVerifier, /finance-payment-overview-page/, 'preview verification should smoke-test the custom payment overview')
assert.match(screenshotScript, /business-page-finance-invoices/, 'screenshots should capture the invoice operations page')
assert.match(packageJson, /verify:finance-operations/, 'package scripts should expose finance operations verification')
console.log(JSON.stringify({
status: 'passed',
checked: [
'ErpFinanceOperationsWorkspace.vue',
'ErpPageRenderer.vue',
'verify-preview.mjs',
'capture-admin-screenshots.mjs',
'package.json'
]
}, null, 2))
@@ -0,0 +1,211 @@
#!/usr/bin/env node
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
async function source(relativePath) {
return readFile(path.join(appRoot, relativePath), 'utf8')
}
const [
navigationSource,
moduleCatalogSource,
inventorySource,
pageRendererSource,
adapterBlockSource,
searchFormSource,
entityFormSource
] = await Promise.all([
source('src/utils/modernNavigation.ts'),
source('src/data/moduleCatalog.ts'),
source('public/generated/ui-inventory.json'),
source('src/components/erp/ErpPageRenderer.vue'),
source('src/components/erp/ErpAdapterBlock.vue'),
source('src/components/erp/ErpSearchForm.vue'),
source('src/components/erp/ErpEntityForm.vue')
])
const inventory = JSON.parse(inventorySource)
function loadNavigationExports(sourceCode) {
const executableSource = sourceCode
.replace(/export const legacyControlPagePrefixes:\s*Record<string,\s*string>\s*=/, 'const legacyControlPagePrefixes =')
.replace(/export function /g, 'function ')
.replace(/([,(]\s*)([A-Za-z_$][\w$]*):\s*(?:string|unknown)/g, '$1$2')
return Function(`${executableSource}
return {
legacyControlPagePrefixes,
isInternalLegacyControlTarget,
pageHashForTarget,
navigateTarget,
normalizeModernNavigationTarget
}
`)()
}
function moduleBlocks(sourceCode) {
return [...sourceCode.matchAll(/\{\n\s+id: '([^']+)'[\s\S]*?\n\s+workflows:/g)]
.map((match) => {
const block = match[0]
return {
id: match[1],
prefixes: stringArrayValue(block, 'prefixes'),
legacyIncludes: stringArrayValue(block, 'legacyIncludes'),
quickPages: [...block.matchAll(/pageId: '([^']+)'/g)].map((item) => item[1])
}
})
}
function stringArrayValue(sourceCode, propertyName) {
const match = sourceCode.match(new RegExp(`${propertyName}: \\[([\\s\\S]*?)\\]`))
return match?.[1].match(/'([^']+)'/g)?.map((item) => item.slice(1, -1)) || []
}
function moduleOwnsRoute(module, route) {
const haystack = [
route.pageId,
route.title,
route.component,
route.domain,
route.legacyPath,
route.modernPath,
...(route.permissions || [])
].filter(Boolean).join(' ').toLowerCase()
return module.quickPages.includes(route.pageId)
|| module.prefixes.some((prefix) => String(route.pageId || '').startsWith(prefix) || haystack.includes(prefix.toLowerCase()))
|| module.legacyIncludes.some((include) => haystack.includes(include.toLowerCase()))
}
const navigationExports = loadNavigationExports(navigationSource)
const routeManifest = inventory.routeManifest || []
const routePageIds = new Set(routeManifest.map((route) => route.pageId))
const modules = moduleBlocks(moduleCatalogSource)
for (const exportName of [
'legacyControlPagePrefixes',
'isInternalLegacyControlTarget',
'pageHashForTarget',
'navigateTarget',
'normalizeModernNavigationTarget'
]) {
assert.match(
navigationSource,
new RegExp(`export function ${exportName}|export const ${exportName}`),
`modern navigation utility must export ${exportName}`
)
}
for (const [legacyContext, pagePrefix] of [
['ordermgr', 'order'],
['partymgr', 'party'],
['sfa', 'SalesForceAutomation'],
['projectmgr', 'projectmgr'],
['webtools', 'webtools']
]) {
assert.match(
navigationSource,
new RegExp(`${legacyContext}: '${pagePrefix}'`),
`/${legacyContext}/control routes must map to ${pagePrefix} PageDefinition ids`
)
}
const generatedContexts = new Set()
for (const route of routeManifest) {
const context = String(route.legacyPath || '').match(/^\/([^/]+)\/control\//)?.[1]
if (context) generatedContexts.add(context)
}
const missingContextPrefixes = [...generatedContexts]
.filter((context) => !navigationExports.legacyControlPagePrefixes[context])
.sort()
assert.deepEqual(
missingContextPrefixes,
[],
'every generated legacy /control context must map to a PageDefinition prefix'
)
const mismatchedLegacyTargets = routeManifest
.map((route) => ({
legacyPath: route.legacyPath,
pageId: route.pageId,
actual: navigationExports.pageHashForTarget(route.legacyPath, 'ofbiz'),
expected: `#/pages/${route.pageId}`
}))
.filter((item) => item.actual !== item.expected)
assert.deepEqual(
mismatchedLegacyTargets,
[],
'legacy /control targets must normalize to existing #/pages PageDefinition ids'
)
const missingQuickPages = modules.flatMap((module) =>
module.quickPages
.filter((pageId) => !routePageIds.has(pageId))
.map((pageId) => ({ moduleId: module.id, pageId }))
)
assert.deepEqual(
missingQuickPages,
[],
'moduleCatalog quickPages must point at generated PageDefinition ids'
)
const unownedRoutes = routeManifest
.filter((route) => !modules.some((module) => moduleOwnsRoute(module, route)))
.map((route) => ({
pageId: route.pageId,
legacyPath: route.legacyPath,
component: route.component
}))
assert.deepEqual(
unownedRoutes,
[],
'every generated route must be owned by a Modern UI ERP module'
)
assert.doesNotMatch(
navigationSource,
/startsWith\('http'\)[\s\S]*return value/,
'absolute external URLs must not be treated as internal SPA page routes'
)
assert.match(
pageRendererSource,
/import \{[^}]*navigateTarget[^}]*normalizeModernNavigationTarget[^}]*\} from '\.\.\/\.\.\/utils\/modernNavigation'/,
'page renderer must use the shared modern navigation utility'
)
assert.match(
pageRendererSource,
/isSharedInternalLegacyControlTarget|sharedLegacyControlPagePrefixes|pageHashForTarget/,
'page renderer compatibility shims must delegate to the shared modern navigation utility'
)
assert.match(
pageRendererSource,
/normalizeModernNavigationTarget\(target, props\.page\.pageId\.split\('__'\)\[0\]/,
'action navigation targets must be normalized before changing location'
)
for (const [label, componentSource] of [
['adapter block', adapterBlockSource],
['search form', searchFormSource],
['entity form', entityFormSource]
]) {
assert.match(
componentSource,
/import \{[^}]*navigateTarget[^}]*\} from '\.\.\/\.\.\/utils\/modernNavigation'/,
`${label} must use the shared modern navigation utility`
)
assert.match(
componentSource,
/navigateTarget\([^)]*field\.target|navigateTarget\(item\.target/,
`${label} links must normalize generated internal targets`
)
}
console.log('Modern navigation source policy passed.')
@@ -0,0 +1,91 @@
#!/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 assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
async function source(file) {
return readFile(path.join(appRoot, file), 'utf8')
}
const orderWorkspace = await source('src/components/erp/ErpOrderWorkspace.vue')
const renderer = await source('src/components/erp/ErpPageRenderer.vue')
const moduleCatalog = await source('src/data/moduleCatalog.ts')
const previewVerifier = await source('scripts/verify-preview.mjs')
const screenshotScript = await source('scripts/capture-admin-screenshots.mjs')
const packageJson = await source('package.json')
for (const text of [
'订单运营',
'订单录入',
'订单运营台',
'Showcart',
'订单录入购物车',
'购物车',
'购物车合计',
'订单队列',
'订单查询',
'订单列表',
'履约进度',
'付款状态',
'风险提示',
'批量处理',
'订单详情'
]) {
assert.match(orderWorkspace, new RegExp(text), `custom order workspace must expose ${text}`)
}
assert.match(orderWorkspace, /getEntityRows\('OrderHeader'/, 'order workspace must load real OrderHeader rows from the entity API')
assert.match(orderWorkspace, /真实订单数据/, 'order workspace must describe entity-backed order data')
assert.doesNotMatch(
orderWorkspace,
/从页面定义生成处理入口|不展示固定示例订单|字段和动作来自当前 OFBiz 页面定义|根据 OFBiz 页面定义生成列表|已生成字段|默认字段|ORD-\d+/,
'order workspace must not present generated metadata or fake fixed orders as operations'
)
for (const text of ['data-modern-workspace', 'order-cart', 'data-modern-order-entry-cart="true"', 'data-modern-order-search="true"', 'data-modern-order-table="true"', 'data-modern-order-drawer="true"']) {
assert.match(orderWorkspace, new RegExp(text), `order workspace needs stable verification hook: ${text}`)
}
assert.match(renderer, /ErpOrderWorkspace/, 'page renderer should import the custom order workspace')
assert.match(renderer, /block\.type === 'order-workspace'/, 'order-workspace blocks should render through the custom order workspace')
assert.match(renderer, /isOrderCartPage/, 'renderer should route order Showcart pages into the cart/order-entry workspace')
assert.match(moduleCatalog, /pageId: 'order__findorders'/, 'order module quick entry should land on the custom order finder')
assert.match(previewVerifier, /order-findorders-page/, 'preview verification should smoke-test the custom order finder')
assert.match(previewVerifier, /topbar-quick-action-order-entry/, 'preview verification should cover the topbar order entry deep link')
assert.match(screenshotScript, /business-page-order-finder/, 'screenshots should capture the custom order finder')
assert.match(packageJson, /verify:order-workspace/, 'package scripts should expose order workspace verification')
console.log(JSON.stringify({
status: 'passed',
checked: [
'ErpOrderWorkspace.vue',
'ErpPageRenderer.vue',
'moduleCatalog.ts',
'verify-preview.mjs',
'capture-admin-screenshots.mjs',
'package.json'
]
}, null, 2))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
const CHROME = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const URL = process.env.URL || 'http://localhost:8080/modern/app/#/parties'
const OUT = process.env.OUT || '/Users/qiu/Desktop/ERP/ofbiz-framework/plugins/modern-ui/verification/scroll-check.png'
const profile = await mkdtemp(path.join(os.tmpdir(), 'scrollchk.'))
const chrome = spawn(CHROME, ['--headless=new', '--disable-gpu', '--no-first-run', '--remote-debugging-port=0', '--window-size=1280,900', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
let port = ''
const deadline = Date.now() + 15000
while (Date.now() < deadline && !port) {
try { const t = await readFile(path.join(profile, 'DevToolsActivePort'), 'utf8'); port = t.trim().split('\n')[0] } catch { await delay(150) }
}
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json())
const ws = new WebSocket(targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl).webSocketDebuggerUrl)
await new Promise((res) => ws.addEventListener('open', res, { once: true }))
let id = 0
const pending = new Map()
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
const cmd = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })) })
const evalJs = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })).result?.value
await cmd('Page.enable'); await cmd('Network.enable')
// Log in inside the page (same-origin fetch sets the session cookie), then load the target.
await cmd('Page.navigate', { url: 'http://localhost:8080/modern/app/' })
await delay(1500)
await evalJs(`fetch('/api/v1/login',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'admin',password:'ADMIN'})}).then(r=>r.status)`)
await delay(800)
await evalJs("window.location.hash = '#/parties'; true")
await cmd('Page.reload', { ignoreCache: true })
// wait for shell + table
const wd = Date.now() + 20000
while (Date.now() < wd) {
const ok = await evalJs("Boolean(document.querySelector('.erp-shell__main') && document.querySelector('.erp-shell__aside') && document.body.innerText.includes('客户与组织管理台'))").catch(() => false)
if (ok) break
await delay(300)
}
await delay(1200)
const before = await evalJs(`(() => {
const aside = document.querySelector('.erp-shell__aside');
const main = document.querySelector('.erp-shell__main');
return {
docScrollable: document.documentElement.scrollHeight > window.innerHeight + 2,
mainScrollable: main.scrollHeight > main.clientHeight + 2,
asideTop: Math.round(aside.getBoundingClientRect().top),
asideBottom: Math.round(aside.getBoundingClientRect().bottom),
viewportH: window.innerHeight,
mainScrollTop: main.scrollTop
};
})()`)
// scroll the main area down
await evalJs("document.querySelector('.erp-shell__main').scrollTop = 100000; true")
await delay(500)
const after = await evalJs(`(() => {
const aside = document.querySelector('.erp-shell__aside');
const main = document.querySelector('.erp-shell__main');
return {
docScrollTop: document.documentElement.scrollTop || document.body.scrollTop,
mainScrollTop: Math.round(main.scrollTop),
asideTop: Math.round(aside.getBoundingClientRect().top),
asideBottom: Math.round(aside.getBoundingClientRect().bottom),
brandVisible: Boolean(document.querySelector('.erp-brand'))
};
})()`)
const shot = await cmd('Page.captureScreenshot', { format: 'png' })
await writeFile(OUT, Buffer.from(shot.data, 'base64'))
console.log(JSON.stringify({ before, after, screenshot: OUT }, null, 2))
// verdict
const pass = !before.docScrollable && before.mainScrollable && after.mainScrollTop > 50 && after.asideTop === 0 && (after.docScrollTop || 0) === 0 && after.asideBottom >= before.viewportH - 2
console.log('SCROLL-FIX:', pass ? 'PASS (sidebar fixed, only main scrolls)' : 'CHECK')
ws.close(); chrome.kill('SIGTERM'); await delay(300); await rm(profile, { recursive: true, force: true })
process.exit(0)