Files
gongxue-base/scripts/taro-app-foundation-test.js
2026-07-12 19:26:57 +08:00

254 lines
14 KiB
JavaScript

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, /<AppProvider path=\{path\}>/);
assert.match(appSource, /<ThemeProvider>/);
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');