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'); function moduleUrl(relativePath) { return pathToFileURL(path.join(taroSrc, relativePath)).href; } function readSource(relativePath) { return fs.readFileSync(path.join(taroSrc, relativePath), 'utf8').replace(/\r\n/g, '\n'); } const storageScope = await import(moduleUrl('app/storage-scope.ts')); const tenantA = { portal: 'student', host: 'a.example.com' }; const tenantB = { portal: 'student', host: 'b.example.com' }; assert.notEqual(storageScope.tenantContextStorageKey(tenantA), storageScope.tenantContextStorageKey(tenantB)); assert.notEqual( storageScope.tenantContextStorageKey(tenantA), storageScope.tenantContextStorageKey({ ...tenantA, portal: 'tenant-admin' }), ); assert.notEqual(storageScope.sessionStorageKey(tenantA, 'tenant-a'), storageScope.sessionStorageKey(tenantA, 'tenant-b')); assert.notEqual( storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice'), storageScope.tenantDataStorageKey(tenantA, 'tenant-b', 'user-a', 'practice'), ); assert.notEqual( storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice'), storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-b', 'practice'), ); assert.ok( storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice') .startsWith(storageScope.userDataStoragePrefix(tenantA, 'tenant-a', 'user-a')), ); assert.match(storageScope.sessionStorageKey(tenantA, 'tenant-a'), /^tiku:v2:/); const tenantLaunch = await import(moduleUrl('app/tenant-launch.ts')); assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { tenantCode: 'school-a' } }), 'school-a'); assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { scene: 'tenantCode%3Dschool-b' } }), 'school-b'); assert.equal(tenantLaunch.tenantCodeFromLaunch({ referrerExtraData: { tenant: 'school-c' } }), 'school-c'); assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { tenantCode: '../unsafe' } }), ''); const tenantResolution = await import(moduleUrl('app/tenant-resolution.ts')); assert.deepEqual( tenantResolution.tenantResolveQuery({ host: 'school.example.com:443', tenantCode: 'compiled-tenant' }), { host: 'school.example.com:443', tenantCode: undefined }, 'A browser host must suppress a compiled tenantCode override', ); assert.deepEqual( tenantResolution.tenantResolveQuery({ host: 'localhost:5173', tenantCode: 'school-a' }), { host: 'localhost:5173', tenantCode: 'school-a' }, 'Local H5 development may select an explicit tenant code', ); assert.deepEqual( tenantResolution.tenantResolveQuery({ host: '', tenantCode: 'school-a' }), { host: undefined, tenantCode: 'school-a' }, 'Hostless WeApp resolution must retain its tenant code', ); const routePath = await import(moduleUrl('app/route-path.ts')); assert.equal(routePath.normalizePagePath('/pages/student/login/index'), '/pages/student/login/index'); assert.equal(routePath.normalizePagePath('/pages/student/login'), '/pages/student/login/index'); assert.equal(routePath.normalizePagePath('/pages/student/login/'), '/pages/student/login/index'); assert.equal(routePath.normalizePagePath('#!/pages/bootstrap'), '/pages/bootstrap/index'); assert.equal(routePath.normalizePagePath('/health'), '/health'); assert.equal( routePath.safePageRedirectPath('/pages/student/home', 'student', '/pages/student/home/index'), '/pages/student/home/index', 'clean student redirects must resolve to the registered Taro page', ); assert.equal( routePath.safePageRedirectPath('/pages/student/practice?mode=mock', 'student', '/pages/student/home/index'), '/pages/student/practice/index?mode=mock', 'safe redirect normalization must preserve local query parameters', ); assert.equal( routePath.safePageRedirectPath('/pages/student/login', 'student', '/pages/student/home/index'), '/pages/student/home/index', 'clean login redirects must not loop back to the login page', ); assert.equal( routePath.safePageRedirectPath('/pages/student/home', 'tenant-admin', '/pages/tenant-admin/workbench/index'), '/pages/tenant-admin/workbench/index', 'redirects must stay inside the compiled portal', ); const permissions = await import(moduleUrl('app/permissions.ts')); const tenantAccess = { role: 'tenant_operator', permissions: { 'students:read': false, '*': true }, templatePermissions: {}, effectivePermissions: {}, menuPermissions: { students: false, content: true }, modulePermissions: {}, fieldPermissions: {}, dataScope: {}, roleDefaults: { tenant_operator: ['content:*'] }, }; assert.equal(permissions.hasTenantPermission(tenantAccess, 'students:read'), false); assert.equal(permissions.hasTenantPermission(tenantAccess, 'content:write'), true); assert.equal(permissions.hasTenantMenuAccess(tenantAccess, { menuKey: 'students', permission: 'students:read' }), false); assert.equal(permissions.hasTenantMenuAccess(tenantAccess, { menuKey: 'content', permission: 'content:read' }), true); assert.equal(permissions.hasPlatformPermission({ permissions: { '*': true }, effectivePermissions: {} }, 'platform:tenant:read'), true); const theme = await import(moduleUrl('theme/tokens.ts')); const resolvedTheme = theme.resolveTheme({ logoUrl: 'https://cdn.example.com/fallback.png', theme: { primaryColor: '#123456', accentColor: 'url(javascript:alert(1))', borderRadius: 99, buttonRadius: 7, customCssVars: { '--tiku-focus-ring': '#123abc', '--tiku-unsafe': 'url(https://tracker.example.com/pixel.png)', '--other-product': '#ffffff', }, }, publicAssets: { logoUrl: 'https://cdn.example.com/logo.png', shareImageUrl: 'https://cdn.example.com/share.png', }, }); assert.equal(resolvedTheme.tokens.primary, '#123456'); assert.equal(resolvedTheme.tokens.accent, theme.defaultThemeTokens.accent); assert.equal(resolvedTheme.tokens.radius, '32px'); assert.equal(resolvedTheme.tokens.radiusSmall, '7px'); assert.deepEqual(resolvedTheme.customCssVars, { '--tiku-focus-ring': '#123abc' }); assert.equal(resolvedTheme.assets.logoUrl, 'https://cdn.example.com/logo.png'); assert.equal(theme.themeCssVariables(resolvedTheme.tokens)['--tiku-primary'], '#123456'); const sessionEvents = await import(moduleUrl('app/session-events.ts')); const reasons = []; const unsubscribe = sessionEvents.subscribeSessionChanges(reason => reasons.push(reason)); sessionEvents.emitSessionChange('cleared'); sessionEvents.emitSessionChange('cleared'); sessionEvents.emitSessionChange('saved'); sessionEvents.emitSessionChange('cleared'); unsubscribe(); assert.deepEqual(reasons, ['cleared', 'saved', 'cleared']); assert.match(readSource('app/session-events.ts'), /addEventListener\('storage'/); assert.match(readSource('app/session-events.ts'), /h5SessionEventKey/); const appSource = readSource('app.tsx'); assert.match(appSource, //); assert.match(appSource, //); assert.match(appSource, /className=\{routeReady \? '' : 'route-guard-hidden'\}/); assert.match(appSource, /\{content\}/, 'Taro page content must stay mounted while the route guard overlay is visible'); assert.doesNotMatch(appSource, /routeReady \? content : null/, 'Route readiness must not remove the Taro page instance'); assert.match(appSource, /useRouter\(true\)/, 'App must react to WeApp and subpackage route changes'); assert.match(appSource, /normalizePagePath\(router\.path/); assert.match(appSource, /applyWeappLaunchTenant\(\)/); assert.match(appSource, /identityKey/); assert.match(appSource, /key=\{identityKey\}/); assert.doesNotMatch(appSource, /katex\/dist\/katex\.min\.css/, 'KaTeX CSS must not stay in the global app entry'); assert.match(readSource('components/RichContent.tsx'), /katex-platform\.css/); assert.match(readSource('components/katex-platform.h5.css'), /katex\/dist\/katex\.min\.css/); assert.doesNotMatch(readSource('components/katex-platform.css'), /katex\/dist\/katex\.min\.css/); const navigationSource = readSource('capabilities/navigation.ts'); assert.match(navigationSource, /taroWeappTenantMode\(\) !== 'launch'/, 'Fixed WeApp builds must ignore launch tenant overrides'); assert.match(navigationSource, /appEnv\.tenantCode = ''/, 'Launch mode must clear any compiled tenant fallback before parsing launch data'); assert.match(navigationSource, /tenantCodeFromLaunch/, 'Launch mode must parse query, scene, and referrer tenant data'); const themeProviderSource = readSource('theme/ThemeProvider.tsx'); assert.match(themeProviderSource, /data-tiku-theme-managed/); assert.match(themeProviderSource, /updateManagedMeta\([^\n]+assets\.shareImageUrl\)/); assert.match(themeProviderSource, /updateManagedFavicon\(assets\.faviconUrl\)/); assert.match(themeProviderSource, /originalHrefAttribute/); assert.match(themeProviderSource, /managedCustomCssVars/); assert.match(themeProviderSource, /removeProperty\(key\)/); assert.match(themeProviderSource, /\.\.\.resolved\.customCssVars/); const loginSource = readSource('pages/student/login/index.tsx'); assert.match(loginSource, /bootstrapStatus === 'forbidden' \? bootstrapError : ''/); assert.doesNotMatch(loginSource, /bootstrapStatus === 'unauthenticated' \? bootstrapError/, '401 must not be presented as a permission failure'); const tenantSettingsSource = readSource('pages/tenant-admin/settings/index.tsx'); assert.match(tenantSettingsSource, /await publishTenantTheme/); assert.match(tenantSettingsSource, /await refreshTenant\(\)/, 'published branding must refresh the active ThemeProvider'); for (const shellPath of ['components/AdminLegacyShell.tsx', 'components/StudentLegacyShell.tsx']) { const source = readSource(shellPath); assert.match(source, /useApp\(\)/, `${shellPath} must consume AppProvider state`); assert.doesNotMatch(source, /Taro\.(?:navigateTo|redirectTo|reLaunch)/, `${shellPath} must use navigation capability`); } const apiSource = readSource('services/api.ts'); assert.match(apiSource, /currentTenantContextStorageKey/); assert.match(apiSource, /currentSessionStorageKey/); assert.match(apiSource, /expiresAt <= Date\.now\(\)/); assert.match(apiSource, /emitSessionChange\('expired'\)/); assert.match(apiSource, /signOut\(\{ scope: 'local' \}\)/); assert.match(apiSource, /clearActiveStorageUserData\(tenant\.tenantId\)/); assert.match(apiSource, /tenantResolveQuery/, 'Tenant resolution requests must use the host-authority contract'); assert.match(apiSource, /TENANT_DOMAIN_NOT_BOUND/, 'An unbound domain must clear stale tenant context'); const appProviderSource = readSource('app/AppProvider.tsx'); assert.match(appProviderSource, /appEnv\.portal === 'platform-admin'\s*\? null/, 'Platform portal must not require a business tenant at bootstrap'); assert.match(appProviderSource, /appEnv\.portal !== 'platform-admin' && !tenant/, 'Only tenant-scoped portals may resolve a business tenant'); assert.match(appProviderSource, /H5 租户由当前域名确定/, 'H5 tenant switching must not override the authoritative host'); assert.match(apiSource, /clearSession\(options: \{ emit\?: boolean \} = \{\}\)/); assert.match(apiSource, /rejectedToken.*currentToken/s); const storageCapabilitySource = readSource('capabilities/storage.ts'); assert.match(storageCapabilitySource, /getActiveStorageUserId\(tenantId\)/); assert.match(storageCapabilitySource, /getActiveStorageUserId\(tenantId\) \|\| 'anonymous'/); assert.match(storageCapabilitySource, /removeStorageByPrefix\(userDataStoragePrefix/); assert.match(storageCapabilitySource, /previousUserId !== userId/); assert.match(storageCapabilitySource, /legacyTenantDataStoragePrefix/); assert.match(storageCapabilitySource, /tiku:practice:/); assert.match(readSource('services/storage.ts'), /getActiveStorageUserId\(scope\.tenantId\) === scope\.userId/); assert.match(readSource('app/AppProvider.tsx'), /activateStorageUser\(tenant\.tenantId, currentUser\.id\)/); assert.match(readSource('app/AppProvider.tsx'), /event === 'SIGNED_IN'\) clearSession\(\{ emit: false \}\)/); const authSource = readSource('services/auth.ts'); assert.match(authSource, /clearSession\(\{ emit: false \}\)/); assert.match(authSource, /emitSessionChange\('cleared'\)/); assert.match(authSource, /source: 'app_session'/); assert.match(readSource('app/AppProvider.tsx'), /payload\.session\.source !== 'app_session'/); for (const pagePath of ['pages/student/practice/index.tsx', 'pages/student/vocabulary/index.tsx']) { const source = readSource(pagePath); assert.match(source, /createUserStorage/); assert.match(source, /currentUser\?\.id/); } const routeGuardSource = readSource('services/routeGuard.ts'); assert.match(routeGuardSource, /normalizePagePath.*@\/app\/route-path/); assert.match(routeGuardSource, /export \{ normalizePagePath \}/); assert.match(routeGuardSource, /safePageRedirectPath\(path, appEnv\.portal, landingPath\(\)\)/); assert.match(readSource('app/route-path.ts'), /path\.indexOf\('pages\/'\)/, 'route normalization must preserve student subpackage paths'); for (const pagePath of [ 'pages/platform-admin/workbench/index.tsx', 'pages/tenant-admin/content/index.tsx', 'pages/tenant-admin/marketing/index.tsx', 'pages/student/ai-school/index.tsx', 'pages/student/checkout/index.tsx', 'pages/student/order-detail/index.tsx', ]) { const source = readSource(pagePath); assert.doesNotMatch(source, /document\.createElement|window\.location/, `${pagePath} must use a cross-platform capability`); } const paymentAdapter = readSource('capabilities/payment.ts'); assert.match(paymentAdapter, /isWeappRuntime\(\)/); assert.match(paymentAdapter, /Taro\.requestPayment/); console.log('[PASS] Taro app foundation contracts');