#!/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\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.')