#!/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 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', 'navigation.test.ts', 'navigation.ts', 'network.test.ts', 'network.ts', 'runtime.ts', 'safeArea.test.ts', 'safeArea.ts', 'url.test.ts', 'url.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', 'mod.rs', 'navigation.rs', 'network.rs', 'runtime.rs', 'tray.rs', 'url.rs', 'webview.rs', ]; 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 steps = [ { label: 'h5-host-bridge-tests', command: npmCommand, args: ['run', 'test', '--', ...h5HostBridgeTests], }, { 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 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] production-shell-dev-scaffold-scan'); assertNoProductionShellDevScaffoldTerms(); console.log('[check:native-shells] OK');