forked from wangziqi/gongxue-base
254 lines
14 KiB
JavaScript
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');
|