import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; const repoRoot = process.cwd(); const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src'); const pagesRoot = path.join(taroSrc, 'pages'); const appConfigPath = path.join(taroSrc, 'app.config.ts'); const bootstrapPath = path.join(pagesRoot, 'bootstrap', 'index.tsx'); const routeGuardPath = path.join(taroSrc, 'services', 'routeGuard.ts'); const h5StaticSmokePath = path.join(repoRoot, 'scripts', 'taro-h5-static-smoke.js'); const frontendHandoffPath = path.join(repoRoot, 'docs', 'refactor', 'frontend-handoff-index.md'); function readText(filePath) { return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); } function normalizeSlashes(value) { return value.replace(/\\/g, '/'); } function walkIndexPages(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...walkIndexPages(entryPath)); continue; } if (entry.name === 'index.tsx') files.push(entryPath); } return files; } function parsePortalLandingRoutes() { const text = readText(appConfigPath); const match = text.match(/const\s+portalLandingRoutes\s*:\s*Record\s*=\s*\{([\s\S]*?)\}/m); assert.ok(match, 'apps/taro/src/app.config.ts must define portalLandingRoutes'); return Object.fromEntries( [...match[1].matchAll(/['"`]?([\w-]+)['"`]?\s*:\s*['"`]([^'"`]+)['"`]/g)] .map(item => [item[1], item[2]]), ); } function routeFromIndexFile(filePath) { return normalizeSlashes(path.relative(taroSrc, filePath)).replace(/\/index\.tsx$/, '/index'); } function parseLiteralPagePaths(text) { return [...text.matchAll(/['"`](\/pages\/[^'"`?#]+)['"`]/g)] .map(item => item[1].replace(/^\/+/, '')) .filter(route => route.endsWith('/index')); } function uniqueSorted(items) { return [...new Set(items)].sort(); } async function loadAppConfig(portal, taroEnv) { process.env.TARO_APP_PORTAL = portal; process.env.TARO_ENV = taroEnv; globalThis.defineAppConfig = value => value; const moduleUrl = pathToFileURL(appConfigPath); moduleUrl.searchParams.set('portal', portal); moduleUrl.searchParams.set('env', taroEnv); return (await import(moduleUrl.href)).default; } function expandedRoutes(config) { const mainRoutes = config.pages || []; const packageRoutes = (config.subPackages || config.subpackages || []).flatMap(item => ( (item.pages || []).map(route => `${String(item.root || '').replace(/\/$/, '')}/${String(route).replace(/^\//, '')}`) )); return [...mainRoutes, ...packageRoutes]; } const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile); const actualRouteSet = new Set(actualRoutes); const portalLandingRouteMap = parsePortalLandingRoutes(); const originalPortal = process.env.TARO_APP_PORTAL; const originalTaroEnv = process.env.TARO_ENV; const portalConfigs = { student: await loadAppConfig('student', 'h5'), 'tenant-admin': await loadAppConfig('tenant-admin', 'h5'), 'platform-admin': await loadAppConfig('platform-admin', 'h5'), }; const studentWeappConfig = await loadAppConfig('student', 'weapp'); if (originalPortal === undefined) delete process.env.TARO_APP_PORTAL; else process.env.TARO_APP_PORTAL = originalPortal; if (originalTaroEnv === undefined) delete process.env.TARO_ENV; else process.env.TARO_ENV = originalTaroEnv; const sharedH5Routes = ['pages/bootstrap/index', 'pages/student/login/index']; const expectedRoutesByPortal = { student: actualRoutes.filter(route => route === 'pages/bootstrap/index' || route.startsWith('pages/student/')), 'tenant-admin': actualRoutes.filter(route => sharedH5Routes.includes(route) || route.startsWith('pages/tenant-admin/')), 'platform-admin': actualRoutes.filter(route => sharedH5Routes.includes(route) || route.startsWith('pages/platform-admin/')), }; for (const [portal, config] of Object.entries(portalConfigs)) { const routes = config.pages || []; assert.equal(routes.length, new Set(routes).size, `${portal} H5 config must not contain duplicate page routes`); assert.equal(routes[0], portalLandingRouteMap[portal], `${portal} H5 landing route must be the first page`); assert.deepEqual( uniqueSorted(routes), uniqueSorted(expectedRoutesByPortal[portal]), `${portal} H5 build must package only its own portal pages plus shared bootstrap/login pages`, ); } assert.deepEqual(studentWeappConfig.pages, ['pages/bootstrap/index'], 'Student WeApp main package must contain only the bootstrap page'); assert.equal(studentWeappConfig.subPackages?.length, 1, 'Student WeApp must use one stable student subpackage'); assert.equal(studentWeappConfig.subPackages?.[0]?.root, 'pages/student', 'Student WeApp subpackage root must preserve existing student routes'); assert.deepEqual( uniqueSorted(expandedRoutes(studentWeappConfig)), uniqueSorted(expectedRoutesByPortal.student), 'Student WeApp main package and subpackage must cover every student route without admin pages', ); const appRoutes = uniqueSorted(Object.values(portalConfigs).flatMap(config => config.pages || [])); const appRouteSet = new Set(appRoutes); assert.equal(appRoutes.length, appRouteSet.size, 'app.config.ts must not contain duplicate page routes'); const missingFiles = appRoutes.filter(route => !actualRouteSet.has(route)); const unregisteredPages = actualRoutes.filter(route => !appRouteSet.has(route)); assert.deepEqual(missingFiles, [], 'Every app.config.ts route must have a matching pages/**/index.tsx file'); assert.deepEqual(unregisteredPages, [], 'Every pages/**/index.tsx file must be registered in app.config.ts'); const expectedPortalLandingRoutes = [ 'pages/platform-admin/workbench/index', 'pages/student/home/index', 'pages/tenant-admin/workbench/index', ]; assert.deepEqual( uniqueSorted(Object.values(portalLandingRouteMap)), expectedPortalLandingRoutes, 'Portal-specific H5 builds must put each portal landing route first', ); for (const route of Object.values(portalLandingRouteMap)) { assert.ok(appRouteSet.has(route), `Portal landing route is not registered in app.config.ts: ${route}`); } const bootstrapPageRoutes = parseLiteralPagePaths(readText(bootstrapPath)); const routeGuardPageRoutes = parseLiteralPagePaths(readText(routeGuardPath)); const publicRoutes = new Set([ 'pages/bootstrap/index', 'pages/student/login/index', 'pages/student/region/index', ]); const routeGuardLandingRoutes = routeGuardPageRoutes.filter(route => !publicRoutes.has(route)); assert.deepEqual( uniqueSorted(routeGuardLandingRoutes), expectedPortalLandingRoutes, 'Route guard landing routes must stay explicit for the three H5 portals', ); for (const route of [...bootstrapPageRoutes, ...routeGuardPageRoutes]) { assert.ok(appRouteSet.has(route), `Referenced route is not registered in app.config.ts: ${route}`); } const staticSmokeLandingRoutes = parseLiteralPagePaths(readText(h5StaticSmokePath)); assert.deepEqual( uniqueSorted(staticSmokeLandingRoutes), uniqueSorted(routeGuardLandingRoutes), 'H5 static smoke landing routes must match route guard landing routes', ); for (const route of staticSmokeLandingRoutes) { assert.ok(appRouteSet.has(route), `H5 static smoke landing route is not registered in app.config.ts: ${route}`); } const handoffRoutes = [...readText(frontendHandoffPath).matchAll(/apps\/taro\/src\/(pages\/[^`|\s]+\/index\.tsx)/g)] .map(item => item[1].replace(/\/index\.tsx$/, '/index')); for (const route of uniqueSorted(handoffRoutes)) { assert.ok(appRouteSet.has(route), `Frontend handoff doc references a page that is not registered in app.config.ts: ${route}`); assert.ok(actualRouteSet.has(route), `Frontend handoff doc references a page file that is missing: ${route}`); } console.log(`[PASS] Taro route contract (${appRoutes.length} registered pages; H5 portals cropped; WeApp student subpackage verified)`);