Files
Genarrative/scripts/check-native-shells.mjs
T
kdletters 11b7694045 补齐移动扫码覆盖层单测
移动壳 QrScannerOverlay 增加相机权限和扫码结果组件单测

移动壳配置门禁和根级清单登记扫码覆盖层测试

宿主壳文档和共享决策同步扫码覆盖层边界
2026-06-20 09:10:30 +08:00

2116 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import {spawnSync} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const nativeShellPlanPath =
'docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md';
const hostBridgeProtocolDocPath =
'docs/【前端架构】宿主壳能力统一协议-2026-06-17.md';
const developmentWorkflowDocPath =
'docs/project-memory/shared-memory/development-workflow.md';
const decisionLogDocPath = 'docs/project-memory/shared-memory/decision-log.md';
const productionShellScanRoots = [
'apps/mobile-shell',
'apps/desktop-shell',
'miniprogram',
'packages/shared/src/contracts/hostBridge.ts',
'src/services/host-bridge',
];
const h5HostBridgeFacadeModule = 'src/services/host-bridge/hostBridge';
const h5HostBridgeScannedFacadeImports = new Set([
'canUseHostShareGrid',
'captureHostImageFile',
'exportHostAudioFile',
'exportHostImageFile',
'exportHostTextFile',
'getHostAppearanceColorScheme',
'getHostNetworkStatus',
'getNativeAppHostRuntime',
'importHostAudioFile',
'importHostDocumentFile',
'importHostImageFile',
'importHostTextFile',
'navigateHostNativePage',
'openHostExternalUrl',
'openHostShare',
'openHostShareGrid',
'readHostClipboardText',
'refreshNativeAppHostRuntime',
'reloadHostWebView',
'requestHostHapticsImpact',
'requestHostLogin',
'requestHostPayment',
'scanHostQrCode',
'setHostAppBadgeCount',
'setHostAppTitle',
'setHostShareTarget',
'showHostLocalNotification',
'subscribeHostAppLifecycle',
'subscribeHostImageDrop',
'subscribeHostNavigationCanGoBack',
'subscribeHostNetworkStatusChange',
'subscribeHostRuntimeChange',
'writeHostClipboardText',
]);
const h5HostBridgeCallChainWrapperFiles = [
'src/hooks/useHostLifecycleActive.ts',
'src/hooks/useHostNavigationCanGoBack.ts',
'src/hooks/useHostNetworkOnline.ts',
'src/components/platform-entry/platformProfileHostClipboard.ts',
'src/components/platform-entry/platformHostBridgeSync.ts',
];
const h5HostBridgeRequiredCallChainFiles = [
'src/App.tsx',
'src/components/auth/AuthGate.tsx',
'src/components/bark-battle-creation/BarkBattleResultView.tsx',
'src/components/common/CreativeAudioInputPanel.tsx',
'src/components/common/CreativeImageInputPanel.tsx',
'src/components/common/PublishShareModal.tsx',
'src/components/common/publishShareCardImage.ts',
'src/components/creation-agent/CreationAgentWorkspace.tsx',
'src/components/match3d-runtime/Match3DRuntimeShell.tsx',
'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx',
'src/components/platform-entry/PlatformFeedbackView.tsx',
'src/components/platform-entry/PlatformProfilePrimitives.tsx',
'src/components/platform-entry/PlatformProfileQrScannerModal.tsx',
'src/components/platform-entry/PlatformProfileReferralModal.tsx',
'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx',
'src/components/platform-entry/usePlatformProfileCenterController.ts',
'src/components/puzzle-runtime/PuzzleRuntimeShell.tsx',
'src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx',
'src/components/rpg-entry/RpgEntryHomeView.tsx',
'src/components/square-hole-result/SquareHoleResultView.tsx',
'src/components/visual-novel-result/VisualNovelResultView.tsx',
'src/hooks/useBackgroundMusic.ts',
'src/hooks/useHostLifecycleActive.ts',
'src/hooks/useHostNavigationCanGoBack.ts',
'src/hooks/useHostNetworkOnline.ts',
'src/main.tsx',
'src/services/appTitle.ts',
'src/services/authService.ts',
'src/services/clipboard.ts',
'src/services/payment/paymentRedirect.ts',
'src/services/runtimeAudioFeedback.ts',
'src/services/wechatMiniProgramShareTarget.ts',
'src/services/wechatMiniProgramSubscribe.ts',
'src/services/wechatMiniProgramShareGrid.ts',
];
const h5HostBridgeEventSubscriptionFacades = [
{
functionName: 'subscribeHostAppLifecycle',
eventName: 'app.lifecycle',
},
{
functionName: 'subscribeHostNetworkStatusChange',
eventName: 'network.statusChanged',
},
{
functionName: 'subscribeHostNavigationCanGoBack',
eventName: 'navigation.canGoBack',
},
{
functionName: 'subscribeHostImageDrop',
eventName: 'file.imageDropped',
},
];
const wechatCapabilityFlowContracts = [
{
capability: 'auth.requestLogin',
files: [
'miniprogram/host-bridge/protocol.js',
'miniprogram/host-bridge/webView.js',
'miniprogram/shell/webView.js',
'miniprogram/pages/web-view/index.js',
],
snippets: [
['miniprogram/host-bridge/protocol.js', 'WECHAT_AUTH_PAGE_URL'],
['miniprogram/host-bridge/webView.js', 'resolveWebViewUrlFromRuntimeConfig'],
['miniprogram/shell/webView.js', 'shouldStartAuthFromQuery'],
['miniprogram/shell/webView.js', 'wx.login'],
['miniprogram/shell/webView.js', '/api/auth/wechat/miniprogram-login'],
['miniprogram/pages/web-view/index.js', 'createWechatWebViewPage'],
],
tests: [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/webView.test.js',
'miniprogram/shell/webView.test.js',
'scripts/miniprogram-web-view-auth.test.ts',
],
},
{
capability: 'payment.request',
files: [
'miniprogram/host-bridge/protocol.js',
'miniprogram/host-bridge/payment.js',
'miniprogram/shell/payment.js',
'miniprogram/pages/wechat-pay/index.js',
],
snippets: [
['miniprogram/host-bridge/protocol.js', 'WECHAT_PAY_PAGE_URL'],
['miniprogram/host-bridge/payment.js', 'requestWechatPayment'],
['miniprogram/host-bridge/payment.js', 'wx.requestPayment'],
['miniprogram/host-bridge/payment.js', 'wx.requestVirtualPayment'],
['miniprogram/shell/payment.js', 'createWechatPayPage'],
['miniprogram/shell/payment.js', 'notifyPreviousWebView'],
['miniprogram/pages/wechat-pay/index.js', 'createWechatPayPage'],
],
tests: [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/payment.test.js',
'miniprogram/shell/payment.test.js',
'scripts/miniprogram-web-view-auth.test.ts',
],
},
{
capability: 'share.setTarget',
files: [
'miniprogram/host-bridge/protocol.js',
'miniprogram/host-bridge/webView.js',
'miniprogram/shell/webView.js',
],
snippets: [
['miniprogram/host-bridge/protocol.js', 'WECHAT_SHARE_TARGET_MESSAGE_TYPE'],
['miniprogram/host-bridge/webView.js', 'resolveShareTargetFromWebViewMessage'],
['miniprogram/shell/webView.js', 'handleWebViewMessage'],
['miniprogram/shell/webView.js', '_currentShareTarget'],
],
tests: [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/webView.test.js',
'miniprogram/shell/webView.test.js',
],
},
{
capability: 'share.open',
files: [
'miniprogram/host-bridge/protocol.js',
'miniprogram/host-bridge/webView.js',
'miniprogram/host-bridge/shareGrid.js',
'miniprogram/shell/webView.js',
'miniprogram/shell/shareGrid.js',
'miniprogram/pages/share-grid/index.js',
],
snippets: [
['miniprogram/host-bridge/protocol.js', 'WECHAT_SHARE_GRID_PAGE_URL'],
['miniprogram/host-bridge/webView.js', 'buildWebViewSharePath'],
['miniprogram/host-bridge/shareGrid.js', 'buildShareGridTilePlan'],
['miniprogram/shell/webView.js', 'onShareAppMessage'],
['miniprogram/shell/webView.js', 'onShareTimeline'],
['miniprogram/shell/shareGrid.js', 'wx.saveImageToPhotosAlbum'],
['miniprogram/pages/share-grid/index.js', 'createWechatShareGridPage'],
],
tests: [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/webView.test.js',
'miniprogram/host-bridge/shareGrid.test.js',
'miniprogram/shell/webView.test.js',
'miniprogram/shell/shareGrid.test.js',
],
},
];
const expectedWechatHostBridgeFiles = [
'dispatch.js',
'payment.js',
'payment.test.js',
'protocol.js',
'protocol.test.js',
'shareGrid.js',
'shareGrid.test.js',
'subscribeMessage.js',
'subscribeMessage.test.js',
'webView.js',
'webView.test.js',
];
const expectedWechatShellFiles = [
'payment.js',
'payment.test.js',
'shareGrid.js',
'shareGrid.test.js',
'subscribeMessage.js',
'subscribeMessage.test.js',
'webView.js',
'webView.test.js',
];
const expectedWechatPageFilesByRoute = {
'share-grid': ['index.js', 'index.json', 'index.wxml', 'index.wxss'],
'subscribe-message': ['index.js', 'index.json', 'index.wxml', 'index.wxss'],
'web-view': [
'index.js',
'index.json',
'index.style.test.js',
'index.wxml',
'index.wxss',
],
'wechat-pay': ['index.js', 'index.json', 'index.wxml', 'index.wxss'],
};
const expectedMobileHostBridgeFiles = [
'appearance.test.ts',
'appearance.ts',
'badge.test.ts',
'badge.ts',
'bridge.test.ts',
'bridge.ts',
'capabilities.test.ts',
'capabilities.ts',
'clipboard.test.ts',
'clipboard.ts',
'dispatch.ts',
'filePayloads.test.ts',
'filePayloads.ts',
'files.test.ts',
'files.ts',
'haptics.test.ts',
'haptics.ts',
'navigation.test.ts',
'navigation.ts',
'network.test.ts',
'network.ts',
'notifications.test.ts',
'notifications.ts',
'protocol.test.ts',
'protocol.ts',
'runtime.test.ts',
'runtime.ts',
'scanner.test.ts',
'scanner.ts',
'share.test.ts',
'share.ts',
];
const expectedMobileSrcRootEntries = [
'dir:host-bridge',
'dir:shell',
'file:env.d.ts',
];
const expectedMobileShellFiles = [
'QrScannerOverlay.test.tsx',
'QrScannerOverlay.tsx',
'ShellApp.test.tsx',
'ShellApp.tsx',
'deepLink.test.ts',
'deepLink.ts',
'lifecycle.test.ts',
'lifecycle.ts',
'loadFailure.test.ts',
'loadFailure.ts',
'navigation.test.ts',
'navigation.ts',
'network.test.ts',
'network.ts',
'runtime.test.ts',
'runtime.ts',
'safeArea.test.ts',
'safeArea.ts',
'url.test.ts',
'url.ts',
'webViewGlobals.d.ts',
'webViewHistory.test.ts',
'webViewHistory.ts',
'webViewPolicy.test.ts',
'webViewPolicy.ts',
];
const expectedDesktopHostBridgeRustFiles = [
'appearance.rs',
'badge.rs',
'capabilities.rs',
'clipboard.rs',
'dispatch.rs',
'file_payloads.rs',
'files.rs',
'mod.rs',
'navigation.rs',
'network.rs',
'notifications.rs',
'protocol.rs',
'runtime.rs',
'share.rs',
'title.rs',
];
const expectedDesktopShellRustFiles = [
'deep_link.rs',
'events.rs',
'file_drop.rs',
'lifecycle.rs',
'menu.rs',
'mod.rs',
'navigation.rs',
'network.rs',
'runtime.rs',
'tray.rs',
'url.rs',
'webview.rs',
'window_state.rs',
];
const documentedShellLayerGroups = [
{
label: 'wechat host bridge files',
files: expectedWechatHostBridgeFiles
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `miniprogram/host-bridge/${fileName}`),
},
{
label: 'wechat shell files',
files: expectedWechatShellFiles
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `miniprogram/shell/${fileName}`),
},
{
label: 'wechat page wrapper files',
files: Object.entries(expectedWechatPageFilesByRoute).flatMap(
([route, files]) =>
files
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `miniprogram/pages/${route}/${fileName}`),
),
},
{
label: 'mobile src root entries',
files: ['apps/mobile-shell/src/env.d.ts'],
},
{
label: 'mobile host bridge files',
files: expectedMobileHostBridgeFiles
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `apps/mobile-shell/src/host-bridge/${fileName}`),
},
{
label: 'mobile shell files',
files: expectedMobileShellFiles
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `apps/mobile-shell/src/shell/${fileName}`),
},
{
label: 'desktop entrypoint files',
files: [
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/main.rs',
],
},
{
label: 'desktop host bridge files',
files: expectedDesktopHostBridgeRustFiles.map(
(fileName) => `apps/desktop-shell/src-tauri/src/host_bridge/${fileName}`,
),
},
{
label: 'desktop shell files',
files: expectedDesktopShellRustFiles.map(
(fileName) => `apps/desktop-shell/src-tauri/src/shell/${fileName}`,
),
},
];
const capabilityListMarkers = {
desktop: '桌面壳当前真实能力完整清单为',
desktopCurrentState: '当前真实能力为',
mobile: '移动壳当前通用真实能力完整清单为',
mobileCurrentState: '首轮真实能力包括',
mobileIosExtra: '移动壳 iOS 额外真实能力为',
wechat: '微信小程序壳当前真实能力完整清单为',
};
const sharedHostBridgeContractPath =
'packages/shared/src/contracts/hostBridge.ts';
const productionShellExtensions = new Set([
'.js',
'.json',
'.mjs',
'.plist',
'.rs',
'.toml',
'.ts',
'.tsx',
'.wxml',
'.wxss',
]);
const h5ProductionSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
const productionShellExcludedSegments = new Set([
'.expo',
'.expo-export-smoke',
'node_modules',
'target',
'test-utils',
]);
const productionShellExcludedPaths = new Set([
'apps/desktop-shell/src-tauri/gen',
'apps/desktop-shell/src-tauri/permissions/autogenerated',
]);
const productionShellDevScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'place' + 'holder',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'占' + '位',
'模' + '拟',
'伪' + '造',
'未' + '实现',
'临' + '时',
];
const h5HostBridgeCallChainDevScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'模' + '拟',
'伪' + '造',
];
const h5HostBridgeTests = [
'packages/shared/src/contracts/hostBridge.test.ts',
'src/services/host-bridge/hostBridge.test.ts',
'src/services/host-bridge/nativeAppHostBridge.test.ts',
'src/App.test.tsx',
'src/components/auth/AuthGate.test.tsx',
'src/hooks/useHostNavigationCanGoBack.test.tsx',
'src/components/bark-battle-creation/BarkBattleResultView.test.tsx',
'src/components/common/CreativeAudioInputPanel.test.tsx',
'src/components/common/PublishShareModal.test.tsx',
'src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx',
'src/components/creation-agent/CreationAgentWorkspace.test.tsx',
'src/components/visual-novel-result/VisualNovelResultView.test.tsx',
'src/components/platform-entry/platformDraftGenerationShelfModel.test.ts',
'src/components/platform-entry/platformHostBridgeSync.test.ts',
'src/components/platform-entry/platformHostNotificationModel.test.ts',
'src/services/runtimeAudioFeedback.test.ts',
'src/services/clipboard.test.ts',
'src/services/appTitle.test.ts',
];
const h5PlatformHostBridgeIntegrationTest =
'native app jump hop draft completion sends host notification and badge from platform shell';
const wechatShellTests = [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/webView.test.js',
'miniprogram/host-bridge/payment.test.js',
'miniprogram/host-bridge/shareGrid.test.js',
'miniprogram/host-bridge/subscribeMessage.test.js',
'miniprogram/shell/webView.test.js',
'miniprogram/shell/payment.test.js',
'miniprogram/shell/shareGrid.test.js',
'miniprogram/shell/subscribeMessage.test.js',
'miniprogram/pages/web-view/index.style.test.js',
'scripts/miniprogram-web-view-auth.test.ts',
];
const steps = [
{
label: 'h5-host-bridge-tests',
command: npmCommand,
args: ['run', 'test', '--', ...h5HostBridgeTests],
},
{
label: 'h5-platform-host-bridge-integration',
command: npmCommand,
args: [
'run',
'test',
'--',
'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx',
'-t',
h5PlatformHostBridgeIntegrationTest,
],
},
{
label: 'wechat-shell-tests',
command: npmCommand,
args: ['run', 'test', '--', ...wechatShellTests],
},
{
label: 'mobile-shell-typecheck',
command: npmCommand,
args: ['run', 'mobile-shell:typecheck'],
},
{
label: 'mobile-shell-test',
command: npmCommand,
args: ['run', 'mobile-shell:test'],
},
{
label: 'mobile-shell-eas-build-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:build-config'],
},
{
label: 'mobile-shell-expo-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:config'],
},
{
label: 'mobile-shell-expo-export-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:export'],
},
{
label: 'desktop-shell-typecheck',
command: npmCommand,
args: ['run', 'desktop-shell:typecheck'],
},
{
label: 'desktop-shell-test',
command: npmCommand,
args: ['run', 'desktop-shell:test'],
},
{
label: 'desktop-shell-release-build-smoke',
command: npmCommand,
args: ['run', 'desktop-shell:build', '--', '--no-bundle'],
},
];
function shouldScanProductionShellFile(filePath) {
const normalizedPath = filePath.split(path.sep).join('/');
if (
normalizedPath.includes('.test.') ||
normalizedPath.endsWith('/scripts/check-config.mjs')
) {
return false;
}
return productionShellExtensions.has(path.extname(filePath));
}
function shouldScanH5ProductionSourceFile(filePath) {
const normalizedPath = filePath.split(path.sep).join('/');
if (
normalizedPath.includes('.test.') ||
normalizedPath.includes('/test-utils/') ||
normalizedPath.includes('/services/host-bridge/')
) {
return false;
}
return h5ProductionSourceExtensions.has(path.extname(filePath));
}
function collectProductionShellFiles(entryPath) {
const normalizedPath = entryPath.split(path.sep).join('/');
if (productionShellExcludedPaths.has(normalizedPath)) {
return [];
}
if (!fs.existsSync(entryPath)) {
throw new Error(`production shell scan path does not exist: ${entryPath}`);
}
const stats = fs.statSync(entryPath);
if (stats.isDirectory()) {
const name = path.basename(entryPath);
if (productionShellExcludedSegments.has(name)) {
return [];
}
return fs
.readdirSync(entryPath, { withFileTypes: true })
.flatMap((entry) => collectProductionShellFiles(path.join(entryPath, entry.name)));
}
return shouldScanProductionShellFile(entryPath) ? [entryPath] : [];
}
function collectFiles(entryPath, shouldIncludeFile) {
if (!fs.existsSync(entryPath)) {
throw new Error(`scan path does not exist: ${entryPath}`);
}
const stats = fs.statSync(entryPath);
if (stats.isDirectory()) {
const name = path.basename(entryPath);
if (productionShellExcludedSegments.has(name)) {
return [];
}
return fs
.readdirSync(entryPath, { withFileTypes: true })
.flatMap((entry) => collectFiles(path.join(entryPath, entry.name), shouldIncludeFile));
}
return shouldIncludeFile(entryPath) ? [entryPath] : [];
}
function normalizeModulePath(modulePath) {
return modulePath.split(path.sep).join('/').replace(/\.(jsx?|tsx?)$/, '');
}
function importedModulePath(fromFile, specifier) {
if (specifier === '@') {
return '.';
}
if (specifier.startsWith('@/')) {
return normalizeModulePath(specifier.slice(2));
}
if (!specifier.startsWith('.')) {
return specifier;
}
return normalizeModulePath(path.normalize(path.join(path.dirname(fromFile), specifier)));
}
function extractImportSpecifiers(source) {
const specifiers = [];
for (const match of source.matchAll(/import\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]/g)) {
specifiers.push(match[1]);
}
for (const match of source.matchAll(/export\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s+['"]([^'"]+)['"]/g)) {
specifiers.push(match[1]);
}
for (const match of source.matchAll(/import\s*['"]([^'"]+)['"]/g)) {
specifiers.push(match[1]);
}
return specifiers;
}
function extractNamedImportsFromModule(source, fromFile, targetModule) {
const importedNames = new Set();
const importPattern =
/import\s+(?:type\s+)?(?:\{([\s\S]*?)\}|[A-Za-z0-9_$]+\s*,\s*\{([\s\S]*?)\})\s+from\s+['"]([^'"]+)['"]/g;
const exportPattern =
/export\s+(?:type\s+)?\{([\s\S]*?)\}\s+from\s+['"]([^'"]+)['"]/g;
const collectNamedImports = (importBody) => {
for (const rawImport of importBody.split(',')) {
const cleanedImport = rawImport
.replace(/\btype\s+/g, '')
.trim();
if (!cleanedImport) {
continue;
}
importedNames.add(cleanedImport.split(/\s+as\s+/)[0]?.trim() ?? '');
}
};
for (const match of source.matchAll(importPattern)) {
if (importedModulePath(fromFile, match[3]) !== targetModule) {
continue;
}
collectNamedImports(match[1] ?? match[2] ?? '');
}
for (const match of source.matchAll(exportPattern)) {
if (importedModulePath(fromFile, match[2]) !== targetModule) {
continue;
}
collectNamedImports(match[1] ?? '');
}
return importedNames;
}
function collectH5HostBridgeCallChainFiles() {
const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile);
const wrapperModules = new Set(
h5HostBridgeCallChainWrapperFiles.map(normalizeModulePath),
);
const scannedFiles = new Set();
for (const file of sourceFiles) {
const source = fs.readFileSync(file, 'utf8');
const imports = extractImportSpecifiers(source).map((specifier) =>
importedModulePath(file, specifier),
);
const facadeImports = extractNamedImportsFromModule(
source,
file,
h5HostBridgeFacadeModule,
);
const importsScannedFacadeCapability = [...facadeImports].some((importName) =>
h5HostBridgeScannedFacadeImports.has(importName),
);
if (
importsScannedFacadeCapability ||
imports.some((specifier) => wrapperModules.has(specifier))
) {
scannedFiles.add(file);
}
}
for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) {
scannedFiles.add(wrapperFile);
}
const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter(
(file) => !scannedFiles.has(file),
);
if (missingRequiredFiles.length > 0) {
throw new Error(
`H5 HostBridge call chain scan is missing required files: ${missingRequiredFiles.join(', ')}`,
);
}
return [...scannedFiles].sort();
}
function assertNoDevScaffoldTermsInFiles(files, terms) {
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const lowerSource = source.toLowerCase();
for (const term of terms) {
const matchIndex = lowerSource.indexOf(term.toLowerCase());
if (matchIndex === -1) {
continue;
}
const line =
source.slice(0, matchIndex).split('\n').length;
throw new Error(
`production native shell source must not include ${term}: ${file}:${line}`,
);
}
}
}
function assertNoProductionShellDevScaffoldTerms() {
assertNoDevScaffoldTermsInFiles(
productionShellScanRoots.flatMap(collectProductionShellFiles),
productionShellDevScaffoldTerms,
);
assertNoDevScaffoldTermsInFiles(
collectH5HostBridgeCallChainFiles().flatMap(collectProductionShellFiles),
h5HostBridgeCallChainDevScaffoldTerms,
);
}
function assertSameList(actual, expected, label) {
if (
actual.length !== expected.length ||
actual.some((value, index) => value !== expected[index])
) {
throw new Error(
`${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`,
);
}
}
function assertSameSet(actual, expected, label) {
const sortedActual = [...actual].sort();
const sortedExpected = [...expected].sort();
assertSameList(sortedActual, sortedExpected, label);
}
function readDirectoryEntryList(directory, label) {
const entries = fs
.readdirSync(directory, { withFileTypes: true })
.map((entry) => {
if (entry.isFile()) {
return `file:${entry.name}`;
}
if (entry.isDirectory()) {
return `dir:${entry.name}`;
}
throw new Error(`${label} must not contain special entries: ${entry.name}`);
})
.sort();
if (entries.length === 0) {
throw new Error(`${label} must not be empty`);
}
return entries;
}
function readDirectoryFileList(directory, label) {
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (!entry.isFile()) {
throw new Error(`${label} must not contain nested entries: ${entry.name}`);
}
files.push(entry.name);
}
return files.sort();
}
function readDirectoryNameList(directory, label) {
const directories = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (!entry.isDirectory()) {
throw new Error(`${label} must not contain root files: ${entry.name}`);
}
directories.push(entry.name);
}
return directories.sort();
}
function assertShellLayerLayoutDocumented(source, label) {
for (const group of documentedShellLayerGroups) {
for (const fileName of group.files) {
if (!source.includes(fileName)) {
throw new Error(`${label} missing ${group.label}: ${fileName}`);
}
}
}
}
function extractTsStringArray(source, exportName, seen = new Set()) {
if (seen.has(exportName)) {
throw new Error(`cyclic string array export ${exportName}`);
}
const match = source.match(
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
const nextSeen = new Set(seen);
nextSeen.add(exportName);
const entries = [];
for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) {
if (entry[1]) {
entries.push(...extractTsStringArray(source, entry[1], nextSeen));
} else {
entries.push(entry[2]);
}
}
return entries;
}
function extractRustCapabilities(source) {
const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/);
if (!match) {
throw new Error('unable to read desktop shell capabilities');
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractRustStringArray(source, constName) {
const match = source.match(
new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`),
);
if (!match) {
throw new Error(`unable to read Rust ${constName}`);
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractStringConst(source, constName) {
const match = source.match(
new RegExp(`const ${constName}\\s*=\\s*['"]([^'"]+)['"];`),
);
if (!match) {
throw new Error(`unable to read string const ${constName}`);
}
return match[1];
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractFunctionSource(source, functionName) {
const declarationStart = source.indexOf(`function ${functionName}`);
const exportedDeclarationStart = source.indexOf(`export function ${functionName}`);
const start =
exportedDeclarationStart === -1
? declarationStart
: exportedDeclarationStart;
if (start === -1) {
throw new Error(`unable to read function ${functionName}`);
}
const openBrace = source.indexOf('{', start);
if (openBrace === -1) {
throw new Error(`unable to read function body ${functionName}`);
}
let depth = 0;
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') {
depth += 1;
} else if (source[index] === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(start, index + 1);
}
}
}
throw new Error(`unterminated function body ${functionName}`);
}
function extractTsStringObject(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return Object.fromEntries(
[...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [
entry[1],
entry[2],
]),
);
}
function assertH5HostBridgeEventSubscriptionGates() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const h5HostBridgeSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.ts',
'utf8',
);
const sharedEvents = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
assertSameList(
h5HostBridgeEventSubscriptionFacades.map((entry) => entry.eventName),
sharedEvents,
'H5 HostBridge event subscription facade coverage',
);
const helperSource = extractFunctionSource(
h5HostBridgeSource,
'canUseNativeHostEventCapability',
);
if (
!helperSource.includes("canUseNativeHostCapability('host.events')") ||
!helperSource.includes('canUseNativeHostCapability(capability)')
) {
throw new Error(
'H5 HostBridge event capability helper must require host.events and the event capability',
);
}
const subscribedEvents = [
...h5HostBridgeSource.matchAll(
/subscribeNativeAppHostBridgeEvent(?:<[^>]+>)?\(\s*['"]([^'"]+)['"]/g,
),
].map((entry) => entry[1]);
assertSameList(
subscribedEvents,
sharedEvents,
'H5 HostBridge subscribed event list',
);
for (const { functionName, eventName } of h5HostBridgeEventSubscriptionFacades) {
const functionSource = extractFunctionSource(h5HostBridgeSource, functionName);
if (
!functionSource.includes(`canUseNativeHostEventCapability('${eventName}')`)
) {
throw new Error(
`${functionName} must gate ${eventName} with host.events and the event capability`,
);
}
const directCapabilityPattern = new RegExp(
`canUseNativeHostCapability\\('${escapeRegExp(eventName)}'\\)`,
);
if (directCapabilityPattern.test(functionSource)) {
throw new Error(
`${functionName} must not bypass canUseNativeHostEventCapability for ${eventName}`,
);
}
}
}
function assertH5HostBridgePayloadBoundaries() {
const sharedContractSource = fs.readFileSync(
'packages/shared/src/contracts/hostBridge.ts',
'utf8',
);
const h5HostBridgeSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.ts',
'utf8',
);
const nativeRequestCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_METHODS',
).filter((method) => method !== 'host.getRuntime');
for (const sharedBoundary of [
'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS',
'HOST_BRIDGE_SCANNER_TIMEOUT_MS',
'HOST_BRIDGE_APP_TITLE_MAX_LENGTH',
]) {
if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) {
throw new Error(
`H5 HostBridge facade must import shared HostBridge boundary ${sharedBoundary}`,
);
}
if (new RegExp(`const ${sharedBoundary}\\s*=\\s*new Set`).test(h5HostBridgeSource)) {
throw new Error(
`H5 HostBridge facade must not redeclare shared payload boundary ${sharedBoundary}`,
);
}
}
for (const mimeLiteral of [
"'text/plain'",
"'text/markdown'",
"'text/csv'",
"'application/json'",
"'image/png'",
"'image/jpeg'",
"'image/webp'",
"'audio/mpeg'",
"'audio/mp4'",
"'audio/wav'",
"'audio/ogg'",
"'audio/webm'",
]) {
if (h5HostBridgeSource.includes(mimeLiteral)) {
throw new Error(
`H5 HostBridge facade must read MIME boundaries from shared contract instead of ${mimeLiteral}`,
);
}
}
if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) {
throw new Error(
'H5 HostBridge facade must use shared HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
);
}
if (h5HostBridgeSource.includes('timeoutMs: 30000')) {
throw new Error(
'H5 HostBridge facade must use shared HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS',
);
}
if (h5HostBridgeSource.includes('timeoutMs: 60000')) {
throw new Error(
'H5 HostBridge facade must use shared HOST_BRIDGE_SCANNER_TIMEOUT_MS',
);
}
if (
!h5HostBridgeSource.includes(
'return normalizeHostBridgeExternalUrlPayload(trimmedUrl);',
) ||
!h5HostBridgeSource.includes(
'return normalizeHostBridgeExternalUrlPayload(\n new URL(trimmedUrl, window.location.origin).toString(),\n );',
) ||
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostExternalUrlPayload(url);',
) ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean(\n 'app.openExternalUrl',\n normalizedPayload,\n );",
)
) {
throw new Error(
'H5 HostBridge facade must normalize app.openExternalUrl payloads with the shared external URL boundary',
);
}
if (
!h5HostBridgeSource.includes('function normalizeNativeAppPageUrl(url: string)') ||
!h5HostBridgeSource.includes("trimmedUrl.startsWith('//')") ||
!h5HostBridgeSource.includes(
"nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN",
) ||
!h5HostBridgeSource.includes('const normalizedUrl = normalizeNativeAppPageUrl(url);') ||
!h5HostBridgeSource.includes("{ url: normalizedUrl },")
) {
throw new Error(
'H5 HostBridge facade must reject unsafe native app navigation targets before sending navigation.openNativePage',
);
}
if (
!h5HostBridgeSource.includes(
'absolutizeHostSharePayloadUrls(params),',
) ||
!h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean(\n 'share.open',\n normalizedPayload.payload,\n );",
)
) {
throw new Error(
'H5 HostBridge facade must normalize share.open payloads with the shared share boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostBridgeShareOpenPayload(message);',
) ||
!h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") ||
!h5HostBridgeSource.includes("'share.setTarget', {\n target: message,")
) {
throw new Error(
'H5 HostBridge facade must validate native share.setTarget payloads before sending them to native shells',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostBridgeExportTextPayload(params);',
) ||
!h5HostBridgeSource.includes(
"'file.exportText',\n normalizedPayload,",
)
) {
throw new Error(
'H5 HostBridge facade must normalize file.exportText payloads with the shared text export boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostBridgeExportImagePayload(params);',
) ||
!h5HostBridgeSource.includes(
"'file.exportImage',\n normalizedPayload,",
)
) {
throw new Error(
'H5 HostBridge facade must normalize file.exportImage payloads with the shared image export boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostBridgeExportAudioPayload(params);',
) ||
!h5HostBridgeSource.includes(
"'file.exportAudio',\n normalizedPayload,",
)
) {
throw new Error(
'H5 HostBridge facade must normalize file.exportAudio payloads with the shared audio export boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const clipboardText = normalizeHostBridgeClipboardText(text);',
) ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean('clipboard.writeText', clipboardText);",
)
) {
throw new Error(
'H5 HostBridge facade must normalize clipboard.writeText payloads with the shared clipboard boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const clipboardText = normalizeHostBridgeClipboardText(',
) ||
!h5HostBridgeSource.includes(
"await requestNativeAppHostBridge<ClipboardReadTextResult>(\n 'clipboard.readText',",
) ||
!h5HostBridgeSource.includes(
'return clipboardText ?? false;',
)
) {
throw new Error(
'H5 HostBridge facade must normalize clipboard.readText results with the shared clipboard boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const style = normalizeHostBridgeHapticsImpactStyle(params.style);',
) ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean('haptics.impact', { style });",
)
) {
throw new Error(
'H5 HostBridge facade must normalize haptics.impact payloads with the shared style boundary',
);
}
if (
!h5HostBridgeSource.includes(
'colorScheme: normalizeHostBridgeColorScheme(result?.colorScheme),',
)
) {
throw new Error(
'H5 HostBridge facade must normalize appearance.getColorScheme results with the shared color scheme boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const connectionType = normalizeHostBridgeConnectionType(\n payload?.connectionType ?? payload?.nativeType,\n );',
) ||
!h5HostBridgeSource.includes(
'listener(normalizeHostNetworkStatus(payload));',
)
) {
throw new Error(
'H5 HostBridge facade must normalize network.status results and network.statusChanged events with shared network boundaries',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedTitle = normalizeHostBridgeAppTitle(title);',
) ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean('app.setTitle', normalizedTitle);",
)
) {
throw new Error(
'H5 HostBridge facade must normalize app.setTitle payloads with the shared title boundary',
);
}
for (const nativeCapability of nativeRequestCapabilities) {
if (
h5HostBridgeSource.includes(
`runtime.hostCapabilities.includes('${nativeCapability}')`,
) ||
h5HostBridgeSource.includes(
`runtime.hostCapabilities.includes("${nativeCapability}")`,
)
) {
throw new Error(
`H5 HostBridge facade must gate ${nativeCapability} through canUseNativeHostCapability()`,
);
}
if (
!h5HostBridgeSource.includes(
`canUseNativeHostCapability('${nativeCapability}')`,
)
) {
throw new Error(
`H5 HostBridge facade must check ${nativeCapability} with canUseNativeHostCapability()`,
);
}
}
for (const importBoundary of [
{
functionName: 'importHostTextFile',
normalizer: 'normalizeHostBridgeImportTextResult',
preserveCancellation: true,
},
{
functionName: 'importHostDocumentFile',
normalizer: 'normalizeHostBridgeImportDocumentResult',
preserveCancellation: true,
},
{
functionName: 'importHostImageFile',
normalizer: 'normalizeHostBridgeImportImageResult',
preserveCancellation: true,
},
{
functionName: 'captureHostImageFile',
normalizer: 'normalizeHostBridgeImportImageResult',
preserveCancellation: true,
},
{
functionName: 'importHostAudioFile',
normalizer: 'normalizeHostBridgeImportAudioResult',
preserveCancellation: true,
},
{
functionName: 'subscribeHostImageDrop',
normalizer: 'normalizeHostBridgeImportImageResult',
preserveCancellation: false,
},
]) {
const functionSource = extractFunctionSource(
h5HostBridgeSource,
importBoundary.functionName,
);
if (!functionSource.includes(`${importBoundary.normalizer}(`)) {
throw new Error(
`${importBoundary.functionName} must normalize imported file results with shared ${importBoundary.normalizer}`,
);
}
if (
importBoundary.preserveCancellation &&
(!functionSource.includes('isCancelledHostBridgeError(error)') ||
!functionSource.includes('return null;'))
) {
throw new Error(
`${importBoundary.functionName} must preserve native user cancellation as null`,
);
}
}
const unsupportedHelperSource = extractFunctionSource(
h5HostBridgeSource,
'isUnsupportedHostBridgeError',
);
if (unsupportedHelperSource.includes("'cancelled'")) {
throw new Error(
'H5 HostBridge facade must not treat native user cancellation as unsupported capability',
);
}
for (const localMimeSet of [
'HOST_BRIDGE_TEXT_MIME_TYPE_SET',
'HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET',
'HOST_BRIDGE_IMAGE_MIME_TYPE_SET',
'HOST_BRIDGE_AUDIO_MIME_TYPE_SET',
]) {
if (h5HostBridgeSource.includes(localMimeSet)) {
throw new Error(
`H5 HostBridge facade must use shared import normalizers instead of local ${localMimeSet}`,
);
}
}
}
function assertH5NativeAppTransportTimeoutBoundaries() {
const nativeAppHostBridgeSource = fs.readFileSync(
'src/services/host-bridge/nativeAppHostBridge.ts',
'utf8',
);
for (const sharedBoundary of [
'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS',
'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS',
]) {
if (!nativeAppHostBridgeSource.includes(`${sharedBoundary},`)) {
throw new Error(
`H5 native app transport must import shared timeout boundary ${sharedBoundary}`,
);
}
}
for (const staleTimeoutBoundary of [
'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS',
'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS',
]) {
if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) {
throw new Error(
`H5 native app transport must not redeclare timeout boundary ${staleTimeoutBoundary}`,
);
}
}
}
function extractDocumentCapabilityList(source, marker) {
return extractDocumentCapabilityListBefore(source, marker, '。');
}
function extractDocumentCapabilityListBefore(source, marker, terminator) {
const markerIndex = source.indexOf(marker);
if (markerIndex === -1) {
throw new Error(`native shell plan missing ${marker}`);
}
const sentenceEnd = source.indexOf(terminator, markerIndex);
const sentence = source.slice(
markerIndex,
sentenceEnd === -1 ? undefined : sentenceEnd,
);
return [...sentence.matchAll(/`([^`]+)`/g)].map((entry) => entry[1]);
}
function extractDocumentMethodTable(source) {
const start = source.indexOf('首批 method');
if (start === -1) {
throw new Error('native shell plan missing HostBridge method table');
}
const end = source.indexOf('每个 method 都必须', start);
if (end === -1) {
throw new Error('native shell plan method table missing end marker');
}
return [...source.slice(start, end).matchAll(/^\| `([^`]+)` \|/gm)].map(
(entry) => entry[1],
);
}
function assertNativeShellScaffoldScanWording(source, label) {
if (
source.includes('三端生产壳临时替身词扫描') ||
source.includes('三端壳生产源码') ||
source.includes('壳生产源码禁替身') ||
!source.includes('H5 HostBridge 真实调用链的临时替身词扫描')
) {
throw new Error(
`${label} must document production scaffold scanning for the H5 HostBridge call chain`,
);
}
}
function assertNativeShellCapabilityPlan() {
const planSource = fs.readFileSync(nativeShellPlanPath, 'utf8');
const hostBridgeProtocolDocSource = fs.readFileSync(
hostBridgeProtocolDocPath,
'utf8',
);
const developmentWorkflowDocSource = fs.readFileSync(
developmentWorkflowDocPath,
'utf8',
);
const decisionLogDocSource = fs.readFileSync(decisionLogDocPath, 'utf8');
if (
planSource.includes('permissions` 必须只包含 `core:default`') ||
planSource.includes('permissions=["core:default","allow-host-bridge-request"]')
) {
throw new Error(
'native shell plan must not document core:default as a desktop capability permission',
);
}
if (!planSource.includes('主窗口 capability 只授予 `allow-host-bridge-request`')) {
throw new Error(
'native shell plan must document the minimal desktop capability permission',
);
}
for (const staleMobilePermissionText of [
'Android `permissions` 不手写显式权限',
'最终 Expo public config 只允许扫码能力由 `expo-camera` plugin 带入 `android.permission.CAMERA`',
'移动拍摄不请求麦克风权限',
]) {
if (
planSource.includes(staleMobilePermissionText) ||
hostBridgeProtocolDocSource.includes(staleMobilePermissionText)
) {
throw new Error(
`native shell docs must not keep stale mobile permission wording: ${staleMobilePermissionText}`,
);
}
}
assertNativeShellScaffoldScanWording(planSource, 'native shell plan');
assertNativeShellScaffoldScanWording(
hostBridgeProtocolDocSource,
'HostBridge protocol document',
);
assertNativeShellScaffoldScanWording(
developmentWorkflowDocSource,
'development workflow document',
);
assertNativeShellScaffoldScanWording(
decisionLogDocSource,
'decision log document',
);
assertShellLayerLayoutDocumented(planSource, 'native shell plan');
assertShellLayerLayoutDocumented(
hostBridgeProtocolDocSource,
'HostBridge protocol document',
);
const desktopCapabilitySource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs',
'utf8',
);
const sharedContractSource = fs.readFileSync(sharedHostBridgeContractPath, 'utf8');
const sharedMethods = extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_METHODS');
const sharedEvents = extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_EVENTS');
const sharedWechatCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES',
);
const mobileCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
);
const iosMobileCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
);
const iosExtraCapabilities = iosMobileCapabilities.filter(
(capability) => !mobileCapabilities.includes(capability),
);
const sharedDesktopCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES',
);
const desktopCapabilities = extractRustCapabilities(desktopCapabilitySource);
const desktopEventSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/shell/events.rs',
'utf8',
);
const wechatProtocol = requireCommonJsModule(
'miniprogram/host-bridge/protocol.js',
);
assertSameList(
wechatProtocol.WECHAT_HOST_CAPABILITIES ?? [],
sharedWechatCapabilities,
'wechat mini program runtime capability profile',
);
assertSameList(
extractRustStringArray(desktopEventSource, 'HOST_BRIDGE_EVENTS'),
sharedEvents,
'desktop shell runtime event whitelist',
);
for (const eventName of sharedEvents) {
if (!sharedDesktopCapabilities.includes(eventName)) {
throw new Error(`shared HostBridge event must be in desktop capability profile: ${eventName}`);
}
}
assertSameList(
desktopCapabilities,
sharedDesktopCapabilities,
'desktop shell runtime capability profile',
);
assertSameList(
extractDocumentMethodTable(planSource),
[
...sharedMethods.slice(0, 8),
'app.lifecycle',
'navigation.canGoBack',
...sharedMethods.slice(8, 12),
'network.statusChanged',
...sharedMethods.slice(12, 22),
sharedMethods[22],
'file.imageDropped',
...sharedMethods.slice(23),
],
'native shell documented method table',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.wechat),
sharedWechatCapabilities,
'wechat mini program documented capabilities',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.mobile),
mobileCapabilities,
'mobile shell documented common capabilities',
);
assertSameSet(
extractDocumentCapabilityListBefore(
planSource,
capabilityListMarkers.mobileCurrentState,
'',
).filter((capability) => capability !== 'Android 返回键回退'),
mobileCapabilities,
'mobile shell current state documented capabilities',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.mobileIosExtra),
iosExtraCapabilities,
'mobile shell documented iOS extra capabilities',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.desktop),
desktopCapabilities,
'desktop shell documented capabilities',
);
assertSameSet(
extractDocumentCapabilityListBefore(
planSource,
capabilityListMarkers.desktopCurrentState,
'',
),
desktopCapabilities,
'desktop shell current state documented capabilities',
);
}
function assertExternalUrlProtocolParity() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const desktopNavigationSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'utf8',
);
assertSameList(
extractRustStringArray(desktopNavigationSource, 'EXTERNAL_URL_PROTOCOLS'),
extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS'),
'desktop shell external URL protocol list',
);
}
function requireCommonJsModule(modulePath) {
const absolutePath = path.resolve(modulePath);
const module = { exports: {} };
const sandbox = {
module,
exports: module.exports,
URL,
URLSearchParams,
};
vm.runInNewContext(fs.readFileSync(absolutePath, 'utf8'), sandbox, {
filename: absolutePath,
});
return module.exports;
}
function pageRouteFromMiniProgramUrl(url) {
return String(url).split('?')[0].replace(/^\//, '');
}
function assertHttpsDomain(value, label) {
const trimmed = String(value ?? '').trim();
let url;
try {
url = new URL(trimmed);
} catch {
throw new Error(`${label} must be a valid HTTPS domain`);
}
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.port ||
url.pathname !== '/' ||
url.search ||
url.hash ||
(trimmed !== url.origin && trimmed !== `${url.origin}/`) ||
url.hostname === 'localhost' ||
/^\d+\.\d+\.\d+\.\d+$/u.test(url.hostname) ||
url.hostname.includes(':') ||
!url.hostname.includes('.')
) {
throw new Error(`${label} must be an HTTPS domain without path or query`);
}
}
function assertWechatMiniProgramRouteParity() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const appConfig = JSON.parse(fs.readFileSync('miniprogram/app.json', 'utf8'));
const runtimeConfig = requireCommonJsModule('miniprogram/config.js');
const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js');
const webViewBridgeSource = fs.readFileSync(
'miniprogram/host-bridge/webView.js',
'utf8',
);
const webViewShellSource = fs.readFileSync(
'miniprogram/shell/webView.js',
'utf8',
);
const appPageRoutesSource = fs.readFileSync(
'src/routing/appPageRoutes.ts',
'utf8',
);
const h5HostBridgeSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.ts',
'utf8',
);
const h5SubscribeSource = fs.readFileSync(
'src/services/wechatMiniProgramSubscribe.ts',
'utf8',
);
assertSameList(
appConfig.pages ?? [],
[
protocol.WECHAT_WEB_VIEW_PAGE_URL,
protocol.WECHAT_SHARE_GRID_PAGE_URL,
protocol.WECHAT_PAY_PAGE_URL,
protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL,
].map(pageRouteFromMiniProgramUrl),
'wechat mini program app pages',
);
const h5RoutePairs = [
['MINI_PROGRAM_AUTH_PAGE_URL', protocol.WECHAT_AUTH_PAGE_URL],
['MINI_PROGRAM_PAY_PAGE_URL', protocol.WECHAT_PAY_PAGE_URL],
['MINI_PROGRAM_SHARE_GRID_PAGE_URL', protocol.WECHAT_SHARE_GRID_PAGE_URL],
];
for (const [constName, expectedValue] of h5RoutePairs) {
const actualValue = extractStringConst(h5HostBridgeSource, constName);
if (actualValue !== expectedValue) {
throw new Error(
`H5 HostBridge ${constName} drifted: expected ${expectedValue} but got ${actualValue}`,
);
}
}
const h5SubscribePageUrl = extractStringConst(
h5SubscribeSource,
'MINI_PROGRAM_SUBSCRIBE_MESSAGE_PAGE_URL',
);
if (h5SubscribePageUrl !== protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL) {
throw new Error(
`H5 subscribe page URL drifted: expected ${protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL} but got ${h5SubscribePageUrl}`,
);
}
if (
extractStringConst(webViewBridgeSource, 'WEB_VIEW_SHARE_PATH') !==
protocol.WECHAT_WEB_VIEW_PAGE_URL
) {
throw new Error('wechat mini program share path must use the web-view page route');
}
if (
extractStringConst(webViewBridgeSource, 'SHARE_TARGET_MESSAGE_TYPE') !==
protocol.WECHAT_SHARE_TARGET_MESSAGE_TYPE
) {
throw new Error('wechat mini program share target message type drifted');
}
for (const [configKey, label] of [
['WEB_VIEW_ENTRY_URL', 'wechat release web-view entry URL'],
['DEV_WEB_VIEW_ENTRY_URL', 'wechat dev web-view entry URL'],
['API_BASE_URL', 'wechat release API base URL'],
['DEV_API_BASE_URL', 'wechat dev API base URL'],
]) {
assertHttpsDomain(runtimeConfig[configKey], label);
}
const sourceQuery = runtimeConfig.WEB_VIEW_SOURCE_QUERY ?? {};
const sharedRuntimeContextQueryKey = extractTsStringObject(
sharedContractSource,
'HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY',
);
const sharedPreservedRuntimeContextQueryKeys = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
);
const sharedWechatSourceQuery = extractTsStringObject(
sharedContractSource,
'HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY',
);
assertSameList(
Object.keys(sourceQuery),
Object.keys(sharedWechatSourceQuery),
'wechat web-view source query keys',
);
if (
Object.entries(sharedWechatSourceQuery).some(
([key, value]) => sourceQuery[key] !== value,
)
) {
throw new Error('wechat web-view source query drifted from HostBridge runtime markers');
}
assertSameList(
sharedPreservedRuntimeContextQueryKeys,
[
sharedRuntimeContextQueryKey.clientType,
sharedRuntimeContextQueryKey.clientRuntime,
sharedRuntimeContextQueryKey.miniProgramEnv,
sharedRuntimeContextQueryKey.hostShell,
sharedRuntimeContextQueryKey.hostPlatform,
sharedRuntimeContextQueryKey.hostVersion,
sharedRuntimeContextQueryKey.bridgeVersion,
sharedRuntimeContextQueryKey.hostCapabilities,
],
'preserved HostBridge runtime context query keys',
);
if (
!appPageRoutesSource.includes(
'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
) ||
!appPageRoutesSource.includes(
'APP_RUNTIME_CONTEXT_QUERY_KEYS =\n HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
)
) {
throw new Error('H5 app routes must preserve HostBridge runtime context keys from shared contract');
}
if (
!h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY') ||
h5HostBridgeSource.includes("params.get('clientType')") ||
h5HostBridgeSource.includes("params.get('clientRuntime')") ||
h5HostBridgeSource.includes("params.get('hostCapabilities')") ||
h5HostBridgeSource.includes("params.get('miniProgramEnv')")
) {
throw new Error('H5 HostBridge runtime parser must read query keys from shared contract');
}
for (const snippet of [
"readWebViewSourceQueryValue('clientType')",
"readWebViewSourceQueryValue('clientRuntime')",
]) {
if (!webViewShellSource.includes(snippet)) {
throw new Error('wechat request headers must read runtime markers from WEB_VIEW_SOURCE_QUERY');
}
}
if (
webViewShellSource.includes('MINI_PROGRAM_CLIENT_TYPE') ||
webViewShellSource.includes('MINI_PROGRAM_CLIENT_RUNTIME')
) {
throw new Error('wechat request runtime markers must not be duplicated outside WEB_VIEW_SOURCE_QUERY');
}
}
function assertFileIncludesSnippet(filePath, snippet, label) {
const source = fs.readFileSync(filePath, 'utf8');
if (!source.includes(snippet)) {
throw new Error(`${label} must include ${snippet} in ${filePath}`);
}
}
function assertWechatMiniProgramCapabilityFlows() {
const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js');
const declaredCapabilities = protocol.WECHAT_HOST_CAPABILITIES ?? [];
const declaredCapabilitySet = new Set(declaredCapabilities);
const contractedCapabilities = wechatCapabilityFlowContracts.map(
({ capability }) => capability,
);
assertSameList(
contractedCapabilities,
declaredCapabilities,
'wechat mini program capability flow contracts',
);
const wechatShellTestSet = new Set(wechatShellTests);
for (const contract of wechatCapabilityFlowContracts) {
if (!declaredCapabilitySet.has(contract.capability)) {
throw new Error(
`wechat capability flow contract declared for missing capability: ${contract.capability}`,
);
}
for (const filePath of contract.files) {
if (!fs.existsSync(filePath)) {
throw new Error(
`wechat ${contract.capability} flow file is missing: ${filePath}`,
);
}
}
for (const [filePath, snippet] of contract.snippets) {
assertFileIncludesSnippet(
filePath,
snippet,
`wechat ${contract.capability} flow`,
);
}
for (const testPath of contract.tests) {
if (!wechatShellTestSet.has(testPath)) {
throw new Error(
`wechat ${contract.capability} flow test is not part of check:native-shells: ${testPath}`,
);
}
}
}
}
function assertHostBridgeLayerLayout() {
assertSameList(
readDirectoryFileList(
'miniprogram/host-bridge',
'wechat host bridge files',
),
expectedWechatHostBridgeFiles,
'wechat host bridge files',
);
assertSameList(
readDirectoryFileList('miniprogram/shell', 'wechat shell files'),
expectedWechatShellFiles,
'wechat shell files',
);
assertSameList(
readDirectoryNameList('miniprogram/pages', 'wechat page directories'),
Object.keys(expectedWechatPageFilesByRoute).sort(),
'wechat page directories',
);
for (const [route, expectedFiles] of Object.entries(
expectedWechatPageFilesByRoute,
)) {
assertSameList(
readDirectoryFileList(
`miniprogram/pages/${route}`,
`wechat ${route} page wrapper files`,
),
expectedFiles,
`wechat ${route} page wrapper files`,
);
const pagePath = `miniprogram/pages/${route}/index.js`;
const source = fs.readFileSync(pagePath, 'utf8');
if (!source.includes("require('../../shell/")) {
throw new Error(`${pagePath} must import from miniprogram/shell`);
}
if (
source.includes("require('../../host-bridge/") ||
source.includes("require('./index.shared')")
) {
throw new Error(`${pagePath} must not import bridge logic directly`);
}
}
assertSameList(
readDirectoryEntryList('apps/mobile-shell/src', 'mobile shell src root entries'),
expectedMobileSrcRootEntries,
'mobile shell src root entries',
);
assertSameList(
readDirectoryFileList(
'apps/mobile-shell/src/host-bridge',
'mobile host bridge files',
),
expectedMobileHostBridgeFiles,
'mobile host bridge files',
);
assertSameList(
readDirectoryFileList('apps/mobile-shell/src/shell', 'mobile shell files'),
expectedMobileShellFiles,
'mobile shell files',
);
const mobileAppSource = fs.readFileSync('apps/mobile-shell/App.tsx', 'utf8');
if (!mobileAppSource.includes("import ShellApp from './src/shell/ShellApp';")) {
throw new Error('mobile shell App.tsx must import from apps/mobile-shell/src/shell');
}
if (mobileAppSource.includes('./src/host-bridge/')) {
throw new Error('mobile shell App.tsx must not import HostBridge directly');
}
assertSameList(
readDirectoryEntryList(
'apps/desktop-shell/src-tauri/src',
'desktop shell Rust root entries',
),
['dir:host_bridge', 'dir:shell', 'file:app.rs', 'file:main.rs'],
'desktop shell Rust root entries',
);
const desktopMainSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/main.rs',
'utf8',
);
const desktopAppSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/app.rs',
'utf8',
);
if (!desktopMainSource.includes('mod app;') || !desktopMainSource.includes('app::run();')) {
throw new Error('desktop shell main.rs must stay a thin app entrypoint');
}
if (
desktopMainSource.includes('tauri::Builder::default()') ||
desktopMainSource.includes('tauri::generate_handler!')
) {
throw new Error('desktop shell main.rs must not own Tauri app setup');
}
if (
!desktopAppSource.includes('tauri::Builder::default()') ||
!desktopAppSource.includes('crate::host_bridge::host_bridge_request')
) {
throw new Error('desktop shell app.rs must own Tauri app setup');
}
assertSameList(
readDirectoryFileList(
'apps/desktop-shell/src-tauri/src/host_bridge',
'desktop host bridge Rust files',
),
expectedDesktopHostBridgeRustFiles,
'desktop host bridge Rust files',
);
assertSameList(
readDirectoryFileList(
'apps/desktop-shell/src-tauri/src/shell',
'desktop shell Rust files',
),
expectedDesktopShellRustFiles,
'desktop shell Rust files',
);
}
function assertDesktopReleaseBinaryArtifact() {
const releaseDir = path.join(
'apps',
'desktop-shell',
'src-tauri',
'target',
'release',
);
const executableName =
process.platform === 'win32'
? 'genarrative-desktop-shell.exe'
: 'genarrative-desktop-shell';
const executablePath = path.join(releaseDir, executableName);
if (!fs.existsSync(executablePath)) {
throw new Error(`desktop release binary is missing: ${executablePath}`);
}
const stat = fs.statSync(executablePath);
if (!stat.isFile() || stat.size < 1024 * 1024) {
throw new Error('desktop release binary must be a real non-empty executable file');
}
const header = fs.readFileSync(executablePath, { start: 0, end: 7 });
if (process.platform === 'linux') {
const isElf =
header[0] === 0x7f &&
header[1] === 0x45 &&
header[2] === 0x4c &&
header[3] === 0x46;
if (!isElf || (stat.mode & 0o111) === 0) {
throw new Error('desktop Linux release binary must be an executable ELF file');
}
return;
}
if (process.platform === 'darwin') {
const machMagic = header.readUInt32BE(0);
const isMachO =
machMagic === 0xcafebabe ||
machMagic === 0xcafed00d ||
machMagic === 0xfeedface ||
machMagic === 0xfeedfacf;
if (!isMachO || (stat.mode & 0o111) === 0) {
throw new Error('desktop macOS release binary must be an executable Mach-O file');
}
return;
}
if (process.platform === 'win32') {
if (header[0] !== 0x4d || header[1] !== 0x5a) {
throw new Error('desktop Windows release binary must be a PE executable');
}
}
}
for (const step of steps) {
console.log(`[check:native-shells] ${step.label}`);
const result = spawnSync(step.command, step.args, {
cwd: process.cwd(),
stdio: 'inherit',
});
if (result.error) {
console.error(
`[check:native-shells] failed to start ${step.label}: ${result.error.message}`,
);
process.exit(1);
}
if (result.signal) {
console.error(
`[check:native-shells] ${step.label} was terminated by signal ${result.signal}`,
);
process.exit(1);
}
if ((result.status ?? 0) !== 0) {
process.exit(result.status ?? 1);
}
}
console.log('[check:native-shells] desktop-release-binary-artifact');
assertDesktopReleaseBinaryArtifact();
console.log('[check:native-shells] host-bridge-layer-layout');
assertHostBridgeLayerLayout();
console.log('[check:native-shells] native-shell-capability-plan');
assertNativeShellCapabilityPlan();
console.log('[check:native-shells] external-url-protocol-parity');
assertExternalUrlProtocolParity();
console.log('[check:native-shells] wechat-mini-program-route-parity');
assertWechatMiniProgramRouteParity();
console.log('[check:native-shells] wechat-mini-program-capability-flows');
assertWechatMiniProgramCapabilityFlows();
console.log('[check:native-shells] h5-host-bridge-event-subscription-gates');
assertH5HostBridgeEventSubscriptionGates();
console.log('[check:native-shells] h5-host-bridge-payload-boundaries');
assertH5HostBridgePayloadBoundaries();
console.log('[check:native-shells] h5-native-app-transport-timeout-boundaries');
assertH5NativeAppTransportTimeoutBoundaries();
console.log('[check:native-shells] production-shell-dev-scaffold-scan');
assertNoProductionShellDevScaffoldTerms();
console.log('[check:native-shells] OK');