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:
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)
|
||||
Reference in New Issue
Block a user