Files
Genarrative/scripts/check-native-shells.mjs
T
kdletters aca596a441 原生壳纳入微信桥接测试
让统一原生壳检查运行微信 WebView 与桥接层测试集合

补充微信壳行为验收覆盖支付订阅分享和登录入口

同步原生壳方案文档与团队共享开发流程
2026-06-18 22:46:13 +08:00

534 lines
14 KiB
JavaScript

#!/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');