#!/usr/bin/env node import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const nativeShellPlanPath = 'docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md'; const productionShellRoots = [ 'apps/mobile-shell', 'apps/desktop-shell', 'miniprogram', ]; 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 expectedMobileHostBridgeFiles = [ 'bridge.test.ts', 'bridge.ts', 'capabilities.ts', 'dispatch.ts', 'files.ts', 'protocol.ts', 'share.ts', ]; const expectedMobileShellFiles = [ '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.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 = [ 'capabilities.rs', 'dispatch.rs', 'files.rs', 'mod.rs', 'protocol.rs', 'share.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 capabilityListMarkers = { desktop: '桌面壳当前真实能力完整清单为', mobile: '移动壳当前通用真实能力完整清单为', mobileIosExtra: '移动壳 iOS 额外真实能力为', }; const sharedHostBridgeContractPath = 'packages/shared/src/contracts/hostBridge.ts'; const productionShellExtensions = new Set([ '.json', '.mjs', '.rs', '.toml', '.ts', '.tsx', '.wxml', '.wxss', ]); 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 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/common/PublishShareModal.test.tsx', 'src/services/runtimeAudioFeedback.test.ts', 'src/services/clipboard.test.ts', 'src/services/appTitle.test.ts', ]; 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: '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-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 collectProductionShellFiles(entryPath) { const normalizedPath = entryPath.split(path.sep).join('/'); if (productionShellExcludedPaths.has(normalizedPath)) { return []; } 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 assertNoProductionShellDevScaffoldTerms() { const files = productionShellRoots.flatMap(collectProductionShellFiles); for (const file of files) { const source = fs.readFileSync(file, 'utf8'); const lowerSource = source.toLowerCase(); for (const term of productionShellDevScaffoldTerms) { 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 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 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 extractDocumentCapabilityList(source, marker) { const markerIndex = source.indexOf(marker); if (markerIndex === -1) { throw new Error(`native shell plan missing ${marker}`); } const sentenceEnd = source.indexOf('。', markerIndex); const sentence = source.slice( markerIndex, sentenceEnd === -1 ? undefined : sentenceEnd, ); return [...sentence.matchAll(/`([^`]+)`/g)].map((entry) => entry[1]); } function assertNativeShellCapabilityPlan() { const planSource = fs.readFileSync(nativeShellPlanPath, 'utf8'); const mobileCapabilitySource = fs.readFileSync( 'apps/mobile-shell/src/host-bridge/capabilities.ts', 'utf8', ); const desktopCapabilitySource = fs.readFileSync( 'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs', 'utf8', ); const mobileCapabilities = extractTsStringArray( mobileCapabilitySource, 'MOBILE_HOST_CAPABILITIES', ); const iosMobileCapabilities = extractTsStringArray( mobileCapabilitySource, 'IOS_MOBILE_HOST_CAPABILITIES', ); const iosExtraCapabilities = iosMobileCapabilities.filter( (capability) => !mobileCapabilities.includes(capability), ); const desktopCapabilities = extractRustCapabilities(desktopCapabilitySource); assertSameList( extractDocumentCapabilityList(planSource, capabilityListMarkers.mobile), mobileCapabilities, 'mobile shell documented common capabilities', ); assertSameList( extractDocumentCapabilityList(planSource, capabilityListMarkers.mobileIosExtra), iosExtraCapabilities, 'mobile shell documented iOS extra capabilities', ); assertSameList( extractDocumentCapabilityList(planSource, capabilityListMarkers.desktop), desktopCapabilities, 'desktop shell 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 assertHostBridgeLayerLayout() { const wechatBridgeFiles = fs .readdirSync('miniprogram/host-bridge', { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name) .sort(); assertSameList( wechatBridgeFiles, expectedWechatHostBridgeFiles, 'wechat host bridge files', ); const wechatShellLayerFiles = fs .readdirSync('miniprogram/shell', { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name) .sort(); assertSameList( wechatShellLayerFiles, expectedWechatShellFiles, 'wechat shell files', ); const wechatPagePaths = [ 'miniprogram/pages/web-view/index.js', 'miniprogram/pages/wechat-pay/index.js', 'miniprogram/pages/share-grid/index.js', 'miniprogram/pages/subscribe-message/index.js', ]; for (const pagePath of wechatPagePaths) { 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`); } } const mobileHostBridgeFiles = fs .readdirSync('apps/mobile-shell/src/host-bridge', { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name) .sort(); assertSameList( mobileHostBridgeFiles, expectedMobileHostBridgeFiles, 'mobile host bridge files', ); const mobileShellLayerFiles = fs .readdirSync('apps/mobile-shell/src/shell', { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name) .sort(); assertSameList( mobileShellLayerFiles, expectedMobileShellFiles, 'mobile shell bridge 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'); } const desktopEntrypointRustFiles = fs .readdirSync('apps/desktop-shell/src-tauri/src', { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.rs')) .map((entry) => entry.name) .sort(); assertSameList( desktopEntrypointRustFiles, ['main.rs'], 'desktop shell entrypoint Rust files', ); const desktopHostBridgeRustFiles = fs .readdirSync('apps/desktop-shell/src-tauri/src/host_bridge', { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.rs')) .map((entry) => entry.name) .sort(); assertSameList( desktopHostBridgeRustFiles, expectedDesktopHostBridgeRustFiles, 'desktop host bridge Rust files', ); const desktopShellRustFiles = fs .readdirSync('apps/desktop-shell/src-tauri/src/shell', { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.rs')) .map((entry) => entry.name) .sort(); assertSameList( desktopShellRustFiles, expectedDesktopShellRustFiles, 'desktop shell Rust bridge files', ); } 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] 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] production-shell-dev-scaffold-scan'); assertNoProductionShellDevScaffoldTerms(); console.log('[check:native-shells] OK');