Files
Genarrative/apps/mobile-shell/scripts/check-config.mjs
T
kdletters 7c8ef52e86 记录移动返回栈同步失败
移动 WebView 返回栈注入脚本写入 history state 失败时记录日志

移动壳测试和配置检查拒绝返回栈脚本空 catch

共享决策日志补充移动返回栈同步失败边界
2026-06-20 10:13:14 +08:00

2621 lines
88 KiB
JavaScript

import fs from 'node:fs';
import { PNG } from 'pngjs';
const appConfigPath = new URL('../app.json', import.meta.url);
const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo;
const appPath = new URL('../App.tsx', import.meta.url);
const appSource = fs.readFileSync(appPath, 'utf8');
const shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url);
const shellAppSource = fs.readFileSync(shellAppPath, 'utf8');
const shellAppTestPath = new URL('../src/shell/ShellApp.test.tsx', import.meta.url);
const shellAppTestSource = fs.readFileSync(shellAppTestPath, 'utf8');
const qrScannerOverlayPath = new URL('../src/shell/QrScannerOverlay.tsx', import.meta.url);
const qrScannerOverlaySource = fs.readFileSync(qrScannerOverlayPath, 'utf8');
const qrScannerOverlayTestPath = new URL(
'../src/shell/QrScannerOverlay.test.tsx',
import.meta.url,
);
const qrScannerOverlayTestSource = fs.readFileSync(
qrScannerOverlayTestPath,
'utf8',
);
const appearancePath = new URL('../src/host-bridge/appearance.ts', import.meta.url);
const appearanceSource = fs.readFileSync(appearancePath, 'utf8');
const appearanceTestPath = new URL(
'../src/host-bridge/appearance.test.ts',
import.meta.url,
);
const appearanceTestSource = fs.readFileSync(appearanceTestPath, 'utf8');
const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
const bridgeTestPath = new URL('../src/host-bridge/bridge.test.ts', import.meta.url);
const bridgeTestSource = fs.readFileSync(bridgeTestPath, 'utf8');
const badgePath = new URL('../src/host-bridge/badge.ts', import.meta.url);
const badgeSource = fs.readFileSync(badgePath, 'utf8');
const badgeTestPath = new URL('../src/host-bridge/badge.test.ts', import.meta.url);
const badgeTestSource = fs.readFileSync(badgeTestPath, 'utf8');
const capabilitiesTestPath = new URL(
'../src/host-bridge/capabilities.test.ts',
import.meta.url,
);
const capabilitiesTestSource = fs.readFileSync(capabilitiesTestPath, 'utf8');
const clipboardPath = new URL('../src/host-bridge/clipboard.ts', import.meta.url);
const clipboardSource = fs.readFileSync(clipboardPath, 'utf8');
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
const filesPath = new URL('../src/host-bridge/files.ts', import.meta.url);
const filesSource = fs.readFileSync(filesPath, 'utf8');
const filesTestPath = new URL('../src/host-bridge/files.test.ts', import.meta.url);
const filesTestSource = fs.readFileSync(filesTestPath, 'utf8');
const filePayloadsPath = new URL(
'../src/host-bridge/filePayloads.ts',
import.meta.url,
);
const filePayloadsSource = fs.readFileSync(filePayloadsPath, 'utf8');
const hapticsPath = new URL('../src/host-bridge/haptics.ts', import.meta.url);
const hapticsSource = fs.readFileSync(hapticsPath, 'utf8');
const hostBridgeNavigationPath = new URL(
'../src/host-bridge/navigation.ts',
import.meta.url,
);
const hostBridgeNavigationSource = fs.readFileSync(
hostBridgeNavigationPath,
'utf8',
);
const hostBridgeNavigationTestPath = new URL(
'../src/host-bridge/navigation.test.ts',
import.meta.url,
);
const hostBridgeNavigationTestSource = fs.readFileSync(
hostBridgeNavigationTestPath,
'utf8',
);
const hostBridgeNetworkPath = new URL(
'../src/host-bridge/network.ts',
import.meta.url,
);
const hostBridgeNetworkSource = fs.readFileSync(
hostBridgeNetworkPath,
'utf8',
);
const hostBridgeNetworkTestPath = new URL(
'../src/host-bridge/network.test.ts',
import.meta.url,
);
const hostBridgeNetworkTestSource = fs.readFileSync(
hostBridgeNetworkTestPath,
'utf8',
);
const notificationsPath = new URL('../src/host-bridge/notifications.ts', import.meta.url);
const notificationsSource = fs.readFileSync(notificationsPath, 'utf8');
const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url);
const protocolSource = fs.readFileSync(protocolPath, 'utf8');
const protocolTestPath = new URL(
'../src/host-bridge/protocol.test.ts',
import.meta.url,
);
const protocolTestSource = fs.readFileSync(protocolTestPath, 'utf8');
const scannerPath = new URL('../src/host-bridge/scanner.ts', import.meta.url);
const scannerSource = fs.readFileSync(scannerPath, 'utf8');
const scannerTestPath = new URL(
'../src/host-bridge/scanner.test.ts',
import.meta.url,
);
const scannerTestSource = fs.readFileSync(scannerTestPath, 'utf8');
const hostBridgeRuntimePath = new URL('../src/host-bridge/runtime.ts', import.meta.url);
const hostBridgeRuntimeSource = fs.readFileSync(hostBridgeRuntimePath, 'utf8');
const hostBridgeRuntimeTestPath = new URL(
'../src/host-bridge/runtime.test.ts',
import.meta.url,
);
const hostBridgeRuntimeTestSource = fs.readFileSync(
hostBridgeRuntimeTestPath,
'utf8',
);
const sharePath = new URL('../src/host-bridge/share.ts', import.meta.url);
const shareSource = fs.readFileSync(sharePath, 'utf8');
const shareTestPath = new URL('../src/host-bridge/share.test.ts', import.meta.url);
const shareTestSource = fs.readFileSync(shareTestPath, 'utf8');
const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url);
const bridgeSourceFiles = fs
.readdirSync(bridgeDirPath, { withFileTypes: true })
.filter(
(entry) =>
entry.isFile() &&
entry.name.endsWith('.ts') &&
!entry.name.includes('.test.'),
)
.map((entry) => new URL(entry.name, bridgeDirPath))
.sort((left, right) => left.pathname.localeCompare(right.pathname));
const hostBridgeSource = bridgeSourceFiles
.map((file) => fs.readFileSync(file, 'utf8'))
.join('\n');
const urlPath = new URL('../src/shell/url.ts', import.meta.url);
const urlSource = fs.readFileSync(urlPath, 'utf8');
const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url);
const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8');
const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url);
const navigationSource = fs.readFileSync(navigationPath, 'utf8');
const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url);
const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8');
const webViewHistoryPath = new URL('../src/shell/webViewHistory.ts', import.meta.url);
const webViewHistorySource = fs.readFileSync(webViewHistoryPath, 'utf8');
const loadFailurePath = new URL('../src/shell/loadFailure.ts', import.meta.url);
const loadFailureSource = fs.readFileSync(loadFailurePath, 'utf8');
const loadFailureTestPath = new URL(
'../src/shell/loadFailure.test.ts',
import.meta.url,
);
const loadFailureTestSource = fs.readFileSync(loadFailureTestPath, 'utf8');
const runtimePath = new URL('../src/shell/runtime.ts', import.meta.url);
const runtimeSource = fs.readFileSync(runtimePath, 'utf8');
const lifecyclePath = new URL('../src/shell/lifecycle.ts', import.meta.url);
const lifecycleSource = fs.readFileSync(lifecyclePath, 'utf8');
const lifecycleTestPath = new URL('../src/shell/lifecycle.test.ts', import.meta.url);
const lifecycleTestSource = fs.readFileSync(lifecycleTestPath, 'utf8');
const safeAreaTestPath = new URL('../src/shell/safeArea.test.ts', import.meta.url);
const safeAreaTestSource = fs.readFileSync(safeAreaTestPath, 'utf8');
const sharedContractPath = new URL(
'../../../packages/shared/src/contracts/hostBridge.ts',
import.meta.url,
);
const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8');
const packagePath = new URL('../package.json', import.meta.url);
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const easConfigPath = new URL('../eas.json', import.meta.url);
const easConfig = JSON.parse(fs.readFileSync(easConfigPath, 'utf8'));
const rootPackagePath = new URL('../../../package.json', import.meta.url);
const rootPackageConfig = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url);
const rootPackageLock = JSON.parse(fs.readFileSync(rootPackageLockPath, 'utf8'));
const iconPath = new URL('../assets/icon.png', import.meta.url);
const icon = PNG.sync.read(fs.readFileSync(iconPath));
const brandBackgroundColor = '#fffdf9';
const productionSourceRoots = [
new URL('../App.tsx', import.meta.url),
new URL('../app.json', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../scripts/', import.meta.url),
new URL('../src/', import.meta.url),
];
const productionFileExtensions = new Set(['.json', '.mjs', '.ts', '.tsx']);
const requiredMobileShellSourceModules = [
'env.d.ts',
'host-bridge/appearance.ts',
'host-bridge/badge.ts',
'host-bridge/bridge.ts',
'host-bridge/capabilities.ts',
'host-bridge/clipboard.ts',
'host-bridge/dispatch.ts',
'host-bridge/filePayloads.ts',
'host-bridge/files.ts',
'host-bridge/haptics.ts',
'host-bridge/navigation.ts',
'host-bridge/network.ts',
'host-bridge/notifications.ts',
'host-bridge/protocol.ts',
'host-bridge/runtime.ts',
'host-bridge/scanner.ts',
'host-bridge/share.ts',
'shell/QrScannerOverlay.tsx',
'shell/ShellApp.tsx',
'shell/deepLink.ts',
'shell/lifecycle.ts',
'shell/loadFailure.ts',
'shell/navigation.ts',
'shell/network.ts',
'shell/runtime.ts',
'shell/safeArea.ts',
'shell/url.ts',
'shell/webViewGlobals.d.ts',
'shell/webViewHistory.ts',
'shell/webViewPolicy.ts',
];
const requiredMobileShellScriptModules = [
'check-eas-build-config.mjs',
'check-expo-config.mjs',
'check-expo-export.mjs',
];
const devScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'place' + 'holder',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'占' + '位',
'模' + '拟',
'伪' + '造',
'未' + '实现',
'临' + '时',
];
const blockedMobileChannelDependencies = [
'@react-native-firebase/analytics',
'@react-native-firebase/app',
'@segment/analytics-react-native',
'@sentry/react-native',
'amplitude-react-native',
'expo-application',
'expo-updates',
'posthog-react-native',
'react-native-code-push',
];
const blockedMobileChannelLockDependencies = blockedMobileChannelDependencies.filter(
(dependency) => dependency !== 'expo-application',
);
const blockedMobileChannelSnippets = [
'@react-native-firebase/analytics',
'@react-native-firebase/app',
'@segment/analytics-react-native',
'@sentry/react-native',
'Amplitude.getInstance',
'Analytics.screen',
'Analytics.track',
'CodePush.sync',
'PostHogProvider',
'Sentry.init',
'Updates.checkForUpdateAsync',
'Updates.fetchUpdateAsync',
'Updates.reloadAsync',
'analytics().logEvent',
'amplitude.init',
'codePush(',
'posthog.capture',
'posthog.init',
];
const blockedScheduledNotificationSnippets = [
'SchedulableTriggerInputTypes',
'cancelScheduledNotificationAsync',
'getAllScheduledNotificationsAsync',
'getNextTriggerDateAsync',
'seconds:',
'repeats:',
"type: 'calendar'",
"type: 'daily'",
"type: 'date'",
"type: 'monthly'",
"type: 'timeInterval'",
"type: 'weekly'",
"type: 'yearly'",
'DateTriggerInput',
'TimeIntervalTriggerInput',
];
const blockedAndroidPermissions = [
'android.permission.MANAGE_EXTERNAL_STORAGE',
'android.permission.READ_EXTERNAL_STORAGE',
'android.permission.RECEIVE_BOOT_COMPLETED',
'android.permission.REQUEST_INSTALL_PACKAGES',
'android.permission.SCHEDULE_EXACT_ALARM',
'android.permission.USE_EXACT_ALARM',
'android.permission.WRITE_EXTERNAL_STORAGE',
];
function extractStringArrayExport(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]*?)\\][^;]*;`),
);
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(...extractStringArrayExport(source, entry[1], nextSeen));
} else {
entries.push(entry[2]);
}
}
return entries;
}
function extractStringConstExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*'([^']+)';`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return match[1];
}
function extractStringObjectConstExport(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 extractNumberConstExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return Number(match[1]);
}
function extractMobileBridgeHandledMethods(source) {
const match = source.match(
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
);
if (!match) {
throw new Error('unable to read mobile shell HostBridge handler methods');
}
return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[1]);
}
function extractFunctionBody(source, functionName) {
const start = source.indexOf(`function ${functionName}`);
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) {
const character = source[index];
if (character === '{') {
depth += 1;
} else if (character === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(openBrace + 1, index);
}
}
}
throw new Error(`unable to read complete function body ${functionName}`);
}
function assertMobileDocumentPickerBoundary(
functionName,
expectedTypeExpression,
) {
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
if (!functionBody.includes('DocumentPicker.getDocumentAsync({')) {
throw new Error(`mobile shell ${functionName} must use DocumentPicker`);
}
for (const requiredPickerOption of [
'copyToCacheDirectory: true',
'multiple: false',
`type: ${expectedTypeExpression}`,
]) {
if (!functionBody.includes(requiredPickerOption)) {
throw new Error(
`mobile shell ${functionName} picker options missing ${requiredPickerOption}`,
);
}
}
}
function assertNoBlockedMobileChannelDependencies(packageJson, packageLabel) {
const dependencySections = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
];
for (const dependency of blockedMobileChannelDependencies) {
for (const section of dependencySections) {
if (packageJson[section]?.[dependency]) {
throw new Error(
`${packageLabel} must not depend on ${dependency} before the real mobile channel contract exists`,
);
}
}
}
}
function assertNoBlockedMobileChannelLockPackages() {
const packageNames = Object.keys(rootPackageLock.packages ?? {})
.filter((packagePath) => packagePath.startsWith('node_modules/'))
.map((packagePath) => packagePath.replace(/^node_modules\//, ''));
const dependencyNames = Object.keys(rootPackageLock.dependencies ?? {});
const lockedPackageNames = new Set([...packageNames, ...dependencyNames]);
for (const dependency of blockedMobileChannelLockDependencies) {
if (lockedPackageNames.has(dependency)) {
throw new Error(
`root package-lock must not resolve ${dependency} before the real mobile channel contract exists`,
);
}
}
}
function assertNoBlockedMobileChannelSnippets() {
const sources = [
['app.json', JSON.stringify(appConfig)],
['App.tsx', appSource],
['ShellApp.tsx', shellAppSource],
['src/host-bridge', hostBridgeSource],
['url.ts', urlSource],
['runtime.ts', runtimeSource],
];
for (const [sourceName, source] of sources) {
for (const snippet of blockedMobileChannelSnippets) {
if (source.includes(snippet)) {
throw new Error(
`mobile shell ${sourceName} must not initialize ${snippet} before the real channel contract exists`,
);
}
}
}
}
function assertPackageScript(packageJson, packageLabel, scriptName, expected) {
const actual = packageJson.scripts?.[scriptName];
if (actual !== expected) {
throw new Error(
`${packageLabel} script ${scriptName} drifted: expected ${expected} but got ${actual}`,
);
}
}
function assertPackageDependencyVersion(
packageJson,
packageLabel,
section,
dependency,
expected,
) {
const actual = packageJson[section]?.[dependency];
if (actual !== expected) {
throw new Error(
`${packageLabel} ${section}.${dependency} drifted: expected ${expected} but got ${actual}`,
);
}
}
function assertPackageLockVersion(dependency, expected) {
const actual = rootPackageLock.packages?.[`node_modules/${dependency}`]?.version;
if (actual !== expected) {
throw new Error(
`root package-lock ${dependency} resolved version drifted: expected ${expected} but got ${actual}`,
);
}
}
function collectProductionSourceFiles(entry) {
const stats = fs.statSync(entry);
if (stats.isDirectory()) {
const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`);
return fs
.readdirSync(entry, { withFileTypes: true })
.flatMap((child) =>
collectProductionSourceFiles(
new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory),
),
);
}
const path = entry.pathname;
const extension = path.match(/\.[^.]+$/)?.[0] ?? '';
if (!productionFileExtensions.has(extension)) {
return [];
}
if (path.includes('.test.') || path.endsWith('/scripts/check-config.mjs')) {
return [];
}
return [entry];
}
function assertNoDevScaffoldTerms(files) {
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const lineStarts = [0];
for (let index = 0; index < source.length; index += 1) {
if (source[index] === '\n') {
lineStarts.push(index + 1);
}
}
for (const term of devScaffoldTerms) {
const matchIndex = source.toLowerCase().indexOf(term.toLowerCase());
if (matchIndex === -1) {
continue;
}
const line = lineStarts.filter((start) => start <= matchIndex).length;
throw new Error(
`mobile shell production source must not include ${term}: ${file.pathname}:${line}`,
);
}
}
}
function countAlphaPixels(png) {
let transparent = 0;
let translucent = 0;
let opaque = 0;
for (let index = 3; index < png.data.length; index += 4) {
const alpha = png.data[index];
if (alpha === 0) {
transparent += 1;
} else if (alpha === 255) {
opaque += 1;
} else {
translucent += 1;
}
}
return { transparent, translucent, opaque };
}
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 collectMobileShellSourceRelativePaths(files) {
const sourceRootPath = new URL('../src/', import.meta.url).pathname;
return files
.map((file) => file.pathname.replace(sourceRootPath, ''))
.filter((filePath) => !filePath.includes('.test.'))
.sort();
}
assertSameList(
collectMobileShellSourceRelativePaths(collectProductionSourceFiles(new URL('../src/', import.meta.url))),
requiredMobileShellSourceModules,
'mobile shell source modules',
);
assertSameList(
fs
.readdirSync(new URL('../scripts/', import.meta.url), {
withFileTypes: true,
})
.filter((entry) => entry.isFile() && entry.name !== 'check-config.mjs')
.map((entry) => entry.name)
.sort(),
requiredMobileShellScriptModules,
'mobile shell smoke scripts',
);
assertNoDevScaffoldTerms(
productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)),
);
assertNoBlockedMobileChannelDependencies(packageConfig, 'mobile shell package');
assertNoBlockedMobileChannelDependencies(rootPackageConfig, 'root H5 package');
assertNoBlockedMobileChannelLockPackages();
assertNoBlockedMobileChannelSnippets();
for (const [scriptName, expected] of Object.entries({
dev: 'expo start',
android: 'expo run:android',
ios: 'expo run:ios',
test: 'vitest run -c vitest.config.ts',
'build:android': 'eas build --local --profile production --platform android',
'build:ios': 'eas build --local --profile production-simulator --platform ios',
'build-config:smoke': 'node scripts/check-eas-build-config.mjs',
'config:smoke': 'node scripts/check-expo-config.mjs',
'export:smoke': 'node scripts/check-expo-export.mjs',
typecheck: 'tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs',
})) {
assertPackageScript(packageConfig, 'mobile shell package', scriptName, expected);
}
for (const [scriptName, expected] of Object.entries({
'mobile-shell:dev': 'npm --prefix apps/mobile-shell run dev',
'mobile-shell:typecheck': 'npm --prefix apps/mobile-shell run typecheck',
'mobile-shell:test': 'npm --prefix apps/mobile-shell run test',
'mobile-shell:build-config': 'npm --prefix apps/mobile-shell run build-config:smoke',
'mobile-shell:build:android': 'npm --prefix apps/mobile-shell run build:android',
'mobile-shell:build:ios': 'npm --prefix apps/mobile-shell run build:ios',
'mobile-shell:config': 'npm --prefix apps/mobile-shell run config:smoke',
'mobile-shell:export': 'npm --prefix apps/mobile-shell run export:smoke',
})) {
assertPackageScript(rootPackageConfig, 'root package', scriptName, expected);
}
for (const [dependency, expected] of Object.entries({
'@expo/metro-runtime': '^56.0.15',
expo: '^56.0.12',
'expo-camera': '56.0.8',
'expo-clipboard': '^56.0.4',
'expo-document-picker': '^56.0.4',
'expo-file-system': '^56.0.8',
'expo-haptics': '^56.0.3',
'expo-image-picker': '^56.0.18',
'expo-linking': '^56.0.14',
'expo-network': '^56.0.5',
'expo-notifications': '^56.0.18',
'expo-sharing': '^56.0.18',
'expo-status-bar': '^56.0.4',
react: '^19.0.0',
'react-native': '^0.86.0',
'react-native-safe-area-context': '^5.8.0',
'react-native-webview': '^13.16.1',
})) {
assertPackageDependencyVersion(
packageConfig,
'mobile shell package',
'dependencies',
dependency,
expected,
);
assertPackageDependencyVersion(
rootPackageConfig,
'root package',
'dependencies',
dependency,
expected,
);
}
for (const [dependency, expected] of Object.entries({
'@expo/metro-runtime': '56.0.15',
expo: '56.0.12',
'expo-camera': '56.0.8',
'expo-clipboard': '56.0.4',
'expo-document-picker': '56.0.4',
'expo-file-system': '56.0.8',
'expo-haptics': '56.0.3',
'expo-image-picker': '56.0.18',
'expo-linking': '56.0.14',
'expo-network': '56.0.5',
'expo-notifications': '56.0.18',
'expo-sharing': '56.0.18',
'expo-status-bar': '56.0.4',
react: '19.2.4',
'react-native': '0.86.0',
'react-native-safe-area-context': '5.8.0',
'react-native-webview': '13.16.1',
})) {
assertPackageLockVersion(dependency, expected);
}
for (const [dependency, expected] of Object.entries({
typescript: '~5.8.2',
vitest: '^0.34.6',
})) {
assertPackageDependencyVersion(
packageConfig,
'mobile shell package',
'devDependencies',
dependency,
expected,
);
assertPackageDependencyVersion(
rootPackageConfig,
'root package',
'devDependencies',
dependency,
expected,
);
}
assertPackageDependencyVersion(
rootPackageConfig,
'root package',
'devDependencies',
'eas-cli',
'^20.3.0',
);
assertPackageLockVersion('eas-cli', '20.3.0');
for (const [dependency, expected] of Object.entries({
typescript: '5.8.3',
vitest: '0.34.6',
})) {
assertPackageLockVersion(dependency, expected);
}
const sharedCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_CAPABILITIES',
);
const sharedMethods = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const sharedEvents = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
const sharedBlockedDownloadProtocols = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
);
const sharedMobileBaseCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
);
const sharedMobileIosCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
);
const sharedHostBridgeProtocol = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_PROTOCOL',
);
const sharedHostBridgeVersion = extractNumberConstExport(
sharedContractSource,
'HOST_BRIDGE_VERSION',
);
const sharedPublicWebOrigin = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
);
const sharedPublicWebUrl = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_PUBLIC_WEB_URL',
);
const sharedMobileLocalNotificationChannelId = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
);
const sharedNativeAppQueryKeys = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS',
);
const sharedNativeAppQueryKey = extractStringObjectConstExport(
sharedContractSource,
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY',
);
const sharedNativeAppQuery = extractStringObjectConstExport(
sharedContractSource,
'HOST_BRIDGE_NATIVE_APP_QUERY',
);
const sharedPublicWebOriginUrl = new URL(sharedPublicWebOrigin);
if (sharedPublicWebOriginUrl.protocol !== 'https:') {
throw new Error('shared HostBridge public web origin must use https for mobile app links');
}
const sharedPublicWebHost = sharedPublicWebOriginUrl.hostname;
const sharedPublicWebAssociatedDomain = `applinks:${sharedPublicWebHost}`;
const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource);
const mobileCapabilities = sharedMobileBaseCapabilities;
const iosMobileCapabilities = sharedMobileIosCapabilities;
const mobileCapabilitySet = new Set(mobileCapabilities);
const iosMobileCapabilitySet = new Set(iosMobileCapabilities);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
const sharedPayloadBoundaryImports = [
'HOST_BRIDGE_AUDIO_MIME_TYPES',
'HOST_BRIDGE_BADGE_COUNT_MAX',
'HOST_BRIDGE_DOCUMENT_MIME_TYPES',
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT',
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
'normalizeHostBridgeImportFileName',
'normalizeHostBridgeQrCodeValue',
'HOST_BRIDGE_TEXT_MIME_TYPES',
];
for (const boundaryImport of sharedPayloadBoundaryImports) {
if (!hostBridgeSource.includes(boundaryImport)) {
throw new Error(
`mobile shell must import shared HostBridge payload boundary ${boundaryImport}`,
);
}
}
if (
!hostBridgeSource.includes(
'HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)',
)
) {
throw new Error(
'mobile shell file.exportText must validate MIME against the shared text MIME set',
);
}
const forbiddenLocalPayloadBoundaryDeclarations = [
'EXPORT_TEXT_MAX_BYTES',
'IMPORT_TEXT_MAX_BYTES',
'IMPORT_DOCUMENT_MAX_BYTES',
'EXPORT_IMAGE_MAX_BYTES',
'IMPORT_IMAGE_MAX_BYTES',
'EXPORT_AUDIO_MAX_BYTES',
'IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_BADGE_COUNT_MAX',
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES',
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH',
'HOST_BRIDGE_TEXT_MIME_TYPES',
'HOST_BRIDGE_DOCUMENT_MIME_TYPES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_AUDIO_MIME_TYPES',
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
];
for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) {
if (new RegExp(`const ${localBoundary}\\s*=`).test(hostBridgeSource)) {
throw new Error(
`mobile shell must not redeclare HostBridge payload boundary ${localBoundary}`,
);
}
}
if (!hostBridgeSource.includes('function assertImportedFileSizeWithinLimit')) {
throw new Error('mobile shell must centralize imported file size checks');
}
if (!bridgeSource.includes('HOST_BRIDGE_RESPONSE_CACHE_MAX,')) {
throw new Error('mobile shell bridge must import shared HostBridge response cache limit');
}
if (/const HOST_BRIDGE_RESPONSE_CACHE_MAX\s*=/.test(protocolSource)) {
throw new Error('mobile shell protocol must not redeclare HostBridge response cache limit');
}
for (const expectedErrorCode of [
'invalid_request',
'unsupported_method',
'unsupported_capability',
'timeout',
'cancelled',
'host_error',
]) {
if (!protocolSource.includes(`'${expectedErrorCode}'`)) {
throw new Error(
`mobile shell protocol error code allowlist missing ${expectedErrorCode}`,
);
}
}
for (const snippet of [
'parseRequest',
'isHostBridgeRequest',
'normalizeMobileHostBridgeError',
'unsupported',
'invalidRequest',
'ok(request(), { shell: ',
'failure(request(), invalidRequest(',
"request({ id: 'bad\\u0000id' })",
"request({ id: 'x'.repeat(129) })",
"method: 'unknown.method'",
"code: 'private_error'",
"nativeStack: 'hidden'",
"message: 'mobile host bridge request failed'",
]) {
if (!protocolTestSource.includes(snippet)) {
throw new Error(`mobile shell protocol helper test missing ${snippet}`);
}
}
if (
!protocolSource.includes('HOST_BRIDGE_ERROR_CODES.has(') ||
!protocolSource.includes('mobile host bridge request failed')
) {
throw new Error('mobile shell protocol must normalize non-contract host errors');
}
for (const [functionName, readCall] of [
['importTextFile', 'file.text()'],
['importDocumentFile', 'file.base64()'],
['importAudioFile', 'file.base64()'],
]) {
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
const sizeCheckIndex = functionBody.indexOf('assertImportedFileSizeWithinLimit(');
const readIndex = functionBody.indexOf(readCall);
if (sizeCheckIndex === -1 || readIndex === -1 || sizeCheckIndex > readIndex) {
throw new Error(
`mobile shell ${functionName} must check file size before ${readCall}`,
);
}
}
for (const profileSource of [
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
]) {
if (!hostBridgeSource.includes(profileSource)) {
throw new Error(`mobile shell must use shared HostBridge profile ${profileSource}`);
}
}
if (
/export const MOBILE_HOST_CAPABILITIES[^=]*= \[/.test(hostBridgeSource) ||
/export const IOS_MOBILE_HOST_CAPABILITIES[^=]*= \[/.test(hostBridgeSource)
) {
throw new Error('mobile shell must not redeclare HostBridge capability profiles');
}
for (const snippet of [
'HOST_BRIDGE_METHODS',
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
'const IOS_UNDECLARED_HOST_BRIDGE_METHODS = HOST_BRIDGE_METHODS.filter(',
'(method) => !HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)',
'test.each(IOS_UNDECLARED_HOST_BRIDGE_METHODS)',
"expect(failedResponse.error.code).toBe('unsupported_method')",
]) {
if (!bridgeTestSource.includes(snippet)) {
throw new Error(
`mobile shell bridge tests must derive unsupported method coverage from shared profiles: ${snippet}`,
);
}
}
const unknownSharedEvents = sharedEvents.filter(
(eventName) => !sharedCapabilities.includes(eventName),
);
if (unknownSharedEvents.length > 0) {
throw new Error(
`shared HostBridge events must also be capabilities: ${unknownSharedEvents.join(', ')}`,
);
}
const unknownHandledMobileMethods = handledMobileMethods.filter(
(method) => !sharedMethods.includes(method),
);
if (unknownHandledMobileMethods.length > 0) {
throw new Error(
`mobile shell handles unknown HostBridge methods: ${unknownHandledMobileMethods.join(', ')}`,
);
}
const unknownMobileCapabilities = mobileCapabilities.filter(
(capability) => !sharedCapabilities.includes(capability),
);
if (unknownMobileCapabilities.length > 0) {
throw new Error(
`mobile shell declares unknown HostBridge capabilities: ${unknownMobileCapabilities.join(', ')}`,
);
}
const unknownIosMobileCapabilities = iosMobileCapabilities.filter(
(capability) => !sharedCapabilities.includes(capability),
);
if (unknownIosMobileCapabilities.length > 0) {
throw new Error(
`iOS mobile shell declares unknown HostBridge capabilities: ${unknownIosMobileCapabilities.join(', ')}`,
);
}
for (const capability of sdkBackedCapabilities) {
if (
mobileCapabilitySet.has(capability) ||
iosMobileCapabilitySet.has(capability)
) {
throw new Error(
`mobile shell must not declare ${capability} until a real SDK/channel flow is implemented`,
);
}
}
const missingMobileMethodHandlers = iosMobileCapabilities.filter(
(capability) =>
sharedMethods.includes(capability) &&
!handledMobileMethods.includes(capability),
);
if (missingMobileMethodHandlers.length > 0) {
throw new Error(
`mobile shell declares request capabilities without HostBridge handlers: ${missingMobileMethodHandlers.join(', ')}`,
);
}
const undeclaredMobileMethodHandlers = handledMobileMethods.filter(
(method) =>
!iosMobileCapabilitySet.has(method) && !sdkBackedCapabilities.includes(method),
);
if (undeclaredMobileMethodHandlers.length > 0) {
throw new Error(
`mobile shell handles unadvertised HostBridge methods: ${undeclaredMobileMethodHandlers.join(', ')}`,
);
}
if (appConfig.scheme !== 'genarrative') {
throw new Error('mobile shell scheme must be genarrative');
}
if (appConfig.name !== 'Genarrative') {
throw new Error('mobile shell app name must be Genarrative');
}
if (appConfig.slug !== 'genarrative-mobile-shell') {
throw new Error('mobile shell slug must be genarrative-mobile-shell');
}
if (appConfig.version !== '0.1.0') {
throw new Error('mobile shell app version must be 0.1.0');
}
const mobileHostVersion = extractStringConstExport(
runtimeSource,
'MOBILE_SHELL_HOST_VERSION_FALLBACK',
);
if (mobileHostVersion !== appConfig.version) {
throw new Error('mobile shell HostBridge fallback host version must match app.json version');
}
if (appConfig.orientation !== 'default') {
throw new Error('mobile shell must allow device orientation for landscape-capable H5 games');
}
if (packageConfig.version !== appConfig.version) {
throw new Error('mobile shell package version must match app.json version');
}
if (appConfig.icon !== './assets/icon.png') {
throw new Error('mobile shell must use the real brand icon asset');
}
if (appConfig.userInterfaceStyle !== 'automatic') {
throw new Error('mobile shell must follow the native system appearance setting');
}
assertSameList(
appConfig.assetBundlePatterns ?? [],
['**/*'],
'mobile shell asset bundle patterns',
);
if (icon.width < 512 || icon.height < 512) {
throw new Error('mobile shell icon must be a production-size brand asset');
}
const iconAlpha = countAlphaPixels(icon);
if (iconAlpha.transparent === 0 || iconAlpha.opaque === 0) {
throw new Error('mobile shell adaptive icon foreground must use the real transparent brand asset');
}
if (
appConfig.splash?.image !== './assets/icon.png' ||
appConfig.splash?.resizeMode !== 'contain' ||
appConfig.splash?.backgroundColor !== brandBackgroundColor
) {
throw new Error('mobile shell splash must use the real brand icon and brand background');
}
if (appConfig.updates?.enabled !== false) {
throw new Error('mobile shell OTA updates must stay disabled until a real release channel exists');
}
if (Object.keys(appConfig.updates ?? {}).some((key) => key !== 'enabled')) {
throw new Error('mobile shell must not configure OTA update metadata without a real release channel');
}
if ('runtimeVersion' in appConfig) {
throw new Error('mobile shell must not configure runtimeVersion without a real OTA release channel');
}
if ('releaseChannel' in appConfig || 'channel' in appConfig) {
throw new Error('mobile shell must not configure an app release channel without a real release process');
}
if (easConfig.cli?.version !== '>= 20.3.0' || easConfig.cli?.appVersionSource !== 'local') {
throw new Error('mobile shell EAS builds must pin CLI floor and use local app version fields');
}
if (
easConfig.build?.production?.distribution !== 'internal' ||
easConfig.build?.production?.channel !== 'production' ||
easConfig.build?.production?.android?.buildType !== 'apk' ||
easConfig.build?.production?.env?.EXPO_NO_DOTENV !== '1'
) {
throw new Error('mobile shell EAS Android production profile must build an internal APK without local dotenv');
}
if (
easConfig.build?.['production-simulator']?.distribution !== 'internal' ||
easConfig.build?.['production-simulator']?.channel !== 'production' ||
easConfig.build?.['production-simulator']?.ios?.simulator !== true ||
easConfig.build?.['production-simulator']?.env?.EXPO_NO_DOTENV !== '1'
) {
throw new Error('mobile shell EAS iOS production smoke profile must build an internal simulator package without local dotenv');
}
for (const [profileName, profile] of Object.entries(easConfig.build ?? {})) {
for (const blockedKey of ['credentialsSource', 'autoIncrement', 'submit', 'releaseChannel']) {
if (blockedKey in profile) {
throw new Error(`mobile shell EAS ${profileName} profile must not configure ${blockedKey}`);
}
}
}
if ('submit' in easConfig) {
throw new Error('mobile shell EAS config must not include store submit profiles yet');
}
assertSameList(
appConfig.ios?.associatedDomains ?? [],
[sharedPublicWebAssociatedDomain],
'mobile shell iOS associated domains',
);
if (appConfig.ios?.bundleIdentifier !== 'world.genarrative.mobile') {
throw new Error('mobile shell iOS bundle identifier must be world.genarrative.mobile');
}
if (appConfig.ios?.buildNumber !== '1') {
throw new Error('mobile shell iOS build number must start at 1');
}
if (appConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption !== false) {
throw new Error('mobile shell iOS encryption export flag must be explicit');
}
if (
appConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads !== false
) {
throw new Error('mobile shell iOS ATS must not allow arbitrary network loads');
}
if (
appConfig.ios?.infoPlist?.NSMicrophoneUsageDescription !==
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。'
) {
throw new Error('mobile shell iOS microphone permission text must describe same-origin H5 gameplay input');
}
const iosPrivacyManifests = appConfig.ios?.privacyManifests;
if (!iosPrivacyManifests) {
throw new Error('mobile shell iOS privacy manifests must be configured');
}
if (iosPrivacyManifests.NSPrivacyTracking !== false) {
throw new Error('mobile shell iOS privacy manifest must not enable tracking');
}
assertSameList(
iosPrivacyManifests.NSPrivacyCollectedDataTypes ?? [],
[],
'mobile shell iOS privacy collected data types',
);
assertSameList(
iosPrivacyManifests.NSPrivacyTrackingDomains ?? [],
[],
'mobile shell iOS privacy tracking domains',
);
const requiredPrivacyAccessedApiTypes = new Map([
[
'NSPrivacyAccessedAPICategoryFileTimestamp',
['0A2A.1', '3B52.1', 'C617.1'],
],
['NSPrivacyAccessedAPICategoryDiskSpace', ['85F4.1', 'E174.1']],
['NSPrivacyAccessedAPICategorySystemBootTime', ['35F9.1']],
['NSPrivacyAccessedAPICategoryUserDefaults', ['CA92.1']],
]);
const privacyAccessedApiTypes =
iosPrivacyManifests.NSPrivacyAccessedAPITypes ?? [];
if (privacyAccessedApiTypes.length !== requiredPrivacyAccessedApiTypes.size) {
throw new Error('mobile shell iOS privacy manifest accessed API type count drifted');
}
for (const [apiType, reasons] of requiredPrivacyAccessedApiTypes) {
const entry = privacyAccessedApiTypes.find(
(candidate) => candidate.NSPrivacyAccessedAPIType === apiType,
);
if (!entry) {
throw new Error(`mobile shell iOS privacy manifest missing ${apiType}`);
}
assertSameList(
entry.NSPrivacyAccessedAPITypeReasons ?? [],
reasons,
`mobile shell iOS privacy reasons for ${apiType}`,
);
}
if (appConfig.android?.package !== 'world.genarrative.mobile') {
throw new Error('mobile shell Android package must be world.genarrative.mobile');
}
if (appConfig.android?.versionCode !== 1) {
throw new Error('mobile shell Android versionCode must start at 1');
}
if (appConfig.android?.usesCleartextTraffic !== false) {
throw new Error('mobile shell Android package must disable cleartext traffic');
}
if (appConfig.android?.allowBackup !== false) {
throw new Error('mobile shell Android package must disable app data backup');
}
if (appConfig.android?.softwareKeyboardLayoutMode !== 'resize') {
throw new Error('mobile shell Android keyboard layout must resize the WebView');
}
if (
!Array.isArray(appConfig.android?.permissions) ||
appConfig.android.permissions.length !== 1 ||
appConfig.android.permissions[0] !== 'android.permission.RECORD_AUDIO'
) {
throw new Error('mobile shell Android package must request only RECORD_AUDIO for same-origin H5 microphone gameplay');
}
if (appConfig.android?.permissions?.includes('android.permission.CAMERA')) {
throw new Error('mobile shell Android CAMERA permission must come from real camera plugins only');
}
for (const permission of blockedAndroidPermissions) {
if (!appConfig.android?.blockedPermissions?.includes(permission)) {
throw new Error(`mobile shell Android package must block ${permission}`);
}
}
for (const permission of blockedAndroidPermissions) {
if (appConfig.android?.permissions?.includes(permission)) {
throw new Error(`mobile shell Android package must not request ${permission}`);
}
}
if (appConfig.android?.blockedPermissions?.includes('android.permission.RECORD_AUDIO')) {
throw new Error('mobile shell Android package must not block RECORD_AUDIO needed by same-origin H5 microphone gameplay');
}
if (
appConfig.android?.adaptiveIcon?.foregroundImage !== './assets/icon.png' ||
appConfig.android?.adaptiveIcon?.backgroundColor !== brandBackgroundColor
) {
throw new Error('mobile shell Android adaptive icon must use the real brand icon and brand background');
}
const androidFilters = appConfig.android?.intentFilters ?? [];
if (androidFilters.length !== 1) {
throw new Error('mobile shell Android app link filter must be the only intent filter');
}
const [androidFilter] = androidFilters;
if (androidFilter.action !== 'VIEW' || androidFilter.autoVerify !== true) {
throw new Error('mobile shell Android app link filter must be a verified VIEW filter');
}
assertSameList(
androidFilter.category ?? [],
['BROWSABLE', 'DEFAULT'],
'mobile shell Android app link categories',
);
const androidFilterData = androidFilter.data ?? [];
if (
androidFilterData.length !== 1 ||
androidFilterData[0]?.scheme !== 'https' ||
androidFilterData[0]?.host !== sharedPublicWebHost ||
Object.keys(androidFilterData[0] ?? {}).some(
(key) => key !== 'scheme' && key !== 'host',
)
) {
throw new Error(
`mobile shell Android app link data must only bind ${sharedPublicWebOrigin}`,
);
}
if (appConfig.extra?.genarrativeHostBridgeVersion !== sharedHostBridgeVersion) {
throw new Error('mobile shell extra HostBridge version must match shared HostBridge version');
}
for (const snippet of [
'Linking.getInitialURL()',
"Linking.addEventListener('url'",
'buildMobileShellUrlFromDeepLink',
'configureMobileHostBridgeNavigation',
'HOST_BRIDGE_PROTOCOL',
'HOST_BRIDGE_VERSION',
'shouldAcceptMobileShellHostBridgeMessage',
'webViewRef.current?.reload()',
'const reloadCurrentWebView = useCallback(() => {',
'reloadWebView: reloadCurrentWebView',
'onContentProcessDidTerminate={reloadCurrentWebView}',
'onRenderProcessGone={reloadCurrentWebView}',
'normalizeMobileShellLoadFailure',
'handleWebViewLoadError',
'handleWebViewHttpError',
'handleRetryLoadFailure',
'onError={handleWebViewLoadError}',
'onHttpError={handleWebViewHttpError}',
'loadFailurePanel',
'loadFailureButton',
'AppState.addEventListener',
'app.lifecycle',
'network.statusChanged',
'getMobileNetworkStatus',
'subscribeMobileNetworkStatus',
'nativeCanGoBackRef',
'h5CanGoBackRef',
'syncNavigationCanGoBack',
'resetNavigationCanGoBack',
'injectHostBridgeEvent',
'injectLifecycleEvent',
'injectNetworkStatusEvent',
'logMobileHostEventFailure',
"logMobileHostEventFailure('network.statusChanged', error)",
'handleWebViewLoad',
'onLoad={handleWebViewLoad}',
'navigation.canGoBack',
"syncNavigationCanGoBack('h5', historyState.canGoBack)",
"syncNavigationCanGoBack('native', event.canGoBack)",
"webViewRef.current?.injectJavaScript('window.history.back(); true;')",
'buildHostBridgeMessageScript',
'parseMobileWebViewHistoryStateMessage',
'origin: window.location.origin',
'source: window',
'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT',
'shouldBlockMobileWebViewNavigationRequest',
'SafeAreaProvider',
'SafeAreaView',
'MOBILE_SHELL_SAFE_AREA_EDGES',
'resolveMobileShellBaseWebUrl',
'javaScriptCanOpenWindowsAutomatically={false}',
'mixedContentMode="never"',
'allowFileAccess={false}',
'allowFileAccessFromFileURLs={false}',
'allowUniversalAccessFromFileURLs={false}',
'allowsFullscreenVideo',
'allowsInlineMediaPlayback',
'mediaCapturePermissionGrantType="grantIfSameHostElsePrompt"',
'mediaPlaybackRequiresUserAction',
'thirdPartyCookiesEnabled={false}',
'sharedCookiesEnabled={false}',
'webviewDebuggingEnabled={false}',
'injectedJavaScriptBeforeContentLoaded={MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT}',
'handleBlockedFileDownload',
'onFileDownload={handleBlockedFileDownload}',
'setSupportMultipleWindows={false}',
'logMobileShellNavigationFailure',
'mobile shell navigation failed for',
'external_navigation.open',
]) {
if (!shellAppSource.includes(snippet)) {
throw new Error(`mobile shell ShellApp missing ${snippet}`);
}
}
if (shellAppSource.includes('catch(() => undefined)')) {
throw new Error('mobile shell ShellApp must not hide async navigation failures');
}
for (const snippet of [
"describe('mobile shell safe area'",
"test('protects the WebView from every device edge'",
"test('keeps the edge list fixed for shell layout usage'",
'expect(MOBILE_SHELL_SAFE_AREA_EDGES).toHaveLength(4)',
"'bottom',",
"'left',",
"'right',",
"'top',",
]) {
if (!safeAreaTestSource.includes(snippet)) {
throw new Error(`mobile shell safe-area tests missing ${snippet}`);
}
}
for (const snippet of [
'lifecyclePayloadFromAppState',
"state === 'active'",
"state === 'background'",
"? 'background'",
": 'inactive'",
'focused: state === \'active\'',
'nativeState: state',
]) {
if (!lifecycleSource.includes(snippet)) {
throw new Error(`mobile shell lifecycle mapper missing ${snippet}`);
}
}
for (const snippet of [
"describe('lifecycle'",
"test('把 React Native AppState 映射为统一 HostBridge 生命周期状态'",
"lifecyclePayloadFromAppState('active')",
"lifecyclePayloadFromAppState('background')",
"lifecyclePayloadFromAppState('inactive')",
"lifecyclePayloadFromAppState('unknown')",
"state: 'active'",
"state: 'background'",
"state: 'inactive'",
'focused: true',
'focused: false',
"nativeState: 'unknown'",
]) {
if (!lifecycleTestSource.includes(snippet)) {
throw new Error(`mobile shell lifecycle tests missing ${snippet}`);
}
}
for (const snippet of [
'type HostBridgeEventName',
'isHostBridgeEventName',
'function buildHostBridgeEventScript(event: HostBridgeEventName, payload: unknown)',
'if (!isHostBridgeEventName(event))',
'throw new Error(`unsupported HostBridge event ${event}`)',
'return buildHostBridgeMessageScript({',
'(event: HostBridgeEventName, payload: unknown)',
'buildHostBridgeEventScript(event, payload)',
]) {
if (!shellAppSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge event injection missing ${snippet}`);
}
}
for (const eventName of sharedEvents) {
if (
mobileCapabilitySet.has(eventName) &&
!shellAppSource.includes(`'${eventName}'`)
) {
throw new Error(
`mobile shell advertises HostBridge event ${eventName} but ShellApp does not inject it`,
);
}
}
for (const snippet of [
'MobileShellLoadFailureInput',
'normalizeMobileShellLoadFailure',
'shouldShowLoadFailure',
'sameDocumentUrl',
'shouldOpenInMobileShellWebView',
"input.type === 'http'",
"title: '网络不可用'",
"retryLabel: '重试'",
"url.pathname !== '/favicon.ico'",
]) {
if (!loadFailureSource.includes(snippet)) {
throw new Error(`mobile shell load failure policy missing ${snippet}`);
}
}
for (const snippet of [
"describe('loadFailure'",
"test('归一化同源 HTTP 加载失败'",
"test('忽略外域和非页面加载失败'",
"test('只展示当前主页面失败'",
"url: 'https://example.com/'",
"url: 'about:blank'",
"url: 'javascript:alert(1)'",
"url: '/favicon.ico'",
"url: 'https://app.genarrative.world/assets/main.js'",
"https://app.genarrative.world/creation/puzzle",
]) {
if (!loadFailureTestSource.includes(snippet)) {
throw new Error(`mobile shell load failure tests missing ${snippet}`);
}
}
for (const snippet of [
'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
'mobileWebViewBlockedDownloadProtocolMapScript',
'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT',
'TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT',
'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT',
'shouldBlockMobileWebViewDownloadUrl',
'shouldBlockMobileWebViewNavigationRequest',
"target.closest('a')",
'event.stopImmediatePropagation()',
'window.open = function(url)',
'HTMLAnchorElement.prototype.click',
'window.history.pushState = function(state, title, url)',
'window.history.replaceState = function(state, title, url)',
"window.addEventListener('popstate'",
'ReactNativeWebView',
'genarrative.mobile.historyState',
'__genarrativeMobileHistoryIndex',
'__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__',
"console.warn('mobile navigation state sync failed', error)",
]) {
if (!webViewPolicySource.includes(snippet)) {
throw new Error(`mobile shell WebView policy missing ${snippet}`);
}
}
if (
webViewPolicySource.includes('catch (error) {}') ||
webViewPolicySource.includes('catch (_error) {}')
) {
throw new Error('mobile shell WebView policy must not hide injected script failures');
}
if (
webViewPolicySource.includes("['blob:'") ||
webViewPolicySource.includes("'filesystem:'")
) {
throw new Error(
'mobile shell WebView blocked download protocols must come from shared contract',
);
}
for (const snippet of [
'parseMobileWebViewHistoryStateMessage',
'genarrative.mobile.historyState',
'typeof candidate.canGoBack !== \'boolean\'',
]) {
if (!webViewHistorySource.includes(snippet)) {
throw new Error(`mobile shell WebView history parser missing ${snippet}`);
}
}
if (!appSource.includes("import ShellApp from './src/shell/ShellApp';")) {
throw new Error('mobile shell App must import the shell app facade');
}
if (!appSource.includes('return <ShellApp />;')) {
throw new Error('mobile shell App must only render the shell app facade');
}
if (appSource.includes('./src/host-bridge/')) {
throw new Error('mobile shell App must not import HostBridge directly');
}
if (
shellAppSource.includes('process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL ||')
) {
throw new Error(
'mobile shell ShellApp must normalize EXPO_PUBLIC_GENARRATIVE_WEB_URL',
);
}
for (const snippet of [
'HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
'HOST_BRIDGE_PUBLIC_WEB_URL',
'DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL',
'ALLOWED_PRODUCTION_WEB_ORIGIN = HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
'LOCAL_DEVELOPMENT_WEB_HOSTS',
"'127.0.0.1'",
"'localhost'",
"'[::1]'",
'isAllowedMobileShellBaseUrl',
]) {
if (!urlSource.includes(snippet)) {
throw new Error(`mobile shell H5 URL allowlist missing ${snippet}`);
}
}
for (const localWebOriginDuplicate of [
`DEFAULT_MOBILE_SHELL_WEB_URL = '${sharedPublicWebUrl}'`,
`ALLOWED_PRODUCTION_WEB_ORIGIN = '${sharedPublicWebOrigin}'`,
]) {
if (urlSource.includes(localWebOriginDuplicate)) {
throw new Error('mobile shell public web origin must come from shared HostBridge contract');
}
}
if (!webViewPolicySource.includes('DEFAULT_MOBILE_SHELL_WEB_URL')) {
throw new Error('mobile shell WebView policy must use the shared default H5 URL');
}
if (webViewPolicySource.includes(`'${sharedPublicWebUrl}'`)) {
throw new Error('mobile shell WebView policy must not duplicate the public web URL');
}
for (const snippet of [
'normalizeHostBridgeShareOpenPayload',
'type HostBridgeRequest',
'const explicitPayload = normalizeHostBridgeShareOpenPayload(request.payload);',
'normalizeHostBridgeShareOpenPayload(currentShareTarget)',
'setMobileHostBridgeShareTarget',
'request: HostBridgeRequest',
"throw invalidRequest('target is required')",
'const normalizedTarget = normalizeHostBridgeShareOpenPayload(target);',
"'share target is invalid'",
'ok(request, true)',
'resetMobileHostBridgeShareTargetForTest',
]) {
if (!shareSource.includes(snippet)) {
throw new Error(`mobile shell share URL policy missing ${snippet}`);
}
}
if (
!dispatchSource.includes('setMobileHostBridgeShareTarget(request)') ||
!dispatchSource.includes('openShare(request)') ||
dispatchSource.includes('ok(request, await openShare') ||
dispatchSource.includes('ok(request, setMobileHostBridgeShareTarget') ||
dispatchSource.includes('let currentShareTarget') ||
dispatchSource.includes('normalizeHostBridgeShareOpenPayload(currentShareTarget)')
) {
throw new Error('mobile shell share HostBridge methods must delegate to share module');
}
for (const snippet of [
'resetMobileHostBridgeShareTargetForTest()',
'uses cached work target when share.open has no explicit payload',
'keeps the previous cached target when a new target is missing or invalid',
'does not fall back to cached target when explicit payload is unsafe',
'rejects empty share requests before opening native share sheet',
'https://app.genarrative.world/works/detail?work=PZ-00000001',
'javascript:alert(1)',
'expect(Share.share).not.toHaveBeenCalled()',
]) {
if (!shareTestSource.includes(snippet)) {
throw new Error(`mobile shell share tests missing ${snippet}`);
}
}
if (shareSource.includes("const WEB_APP_ORIGIN = 'https://app.genarrative.world'")) {
throw new Error('mobile shell share URL policy must reuse the shared web origin');
}
for (const snippet of [
'buildMobileShellUrl(',
'HOST_BRIDGE_NATIVE_APP_QUERY',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY',
'HOST_BRIDGE_VERSION.toString()',
'HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime',
'HOST_BRIDGE_NATIVE_APP_QUERY.clientType',
'HOST_BRIDGE_NATIVE_APP_QUERY.hostShellExpoMobile',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientRuntime',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientType',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostShell',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostPlatform',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostVersion',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.bridgeVersion',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostCapabilities',
]) {
if (!urlSource.includes(snippet)) {
throw new Error(`mobile shell host-context URL builder missing ${snippet}`);
}
}
assertSameList(
Object.values(sharedNativeAppQueryKey),
sharedNativeAppQueryKeys,
'shared native app query key map',
);
if (
sharedNativeAppQuery.clientRuntime !== 'native_app' ||
sharedNativeAppQuery.clientType !== 'native_app' ||
sharedNativeAppQuery.hostShellExpoMobile !== 'expo_mobile'
) {
throw new Error('shared native app mobile query values drifted');
}
for (const hardcodedHostContextSnippet of [
"url.searchParams.set('clientRuntime'",
"url.searchParams.set('clientType'",
"url.searchParams.set('hostShell'",
"url.searchParams.set('hostPlatform'",
"url.searchParams.set('hostVersion'",
"url.searchParams.set('bridgeVersion'",
"url.searchParams.set('hostCapabilities'",
]) {
if (urlSource.includes(hardcodedHostContextSnippet)) {
throw new Error(
'mobile shell host-context URL builder must use shared query key constants',
);
}
}
for (const snippet of [
'resolveMobileShellBaseWebUrl(baseWebUrl)',
'resolveTargetPath(rawUrl, webOrigin)',
'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)',
]) {
if (!deepLinkSource.includes(snippet)) {
throw new Error(`mobile shell deep link host-context flow missing ${snippet}`);
}
}
for (const snippet of [
'normalizeHostBridgeExternalUrl',
'MobileShellExternalNavigator',
'openMobileShellExternalNavigation',
'resolveMobileShellWebViewUrl',
'return normalizeHostBridgeExternalUrl(rawUrl)',
'navigator.canOpenURL(externalUrl)',
'navigator.openURL(externalUrl)',
'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)',
'new URL(rawUrl, allowedOrigin).toString()',
]) {
if (!navigationSource.includes(snippet)) {
throw new Error(`mobile shell native-page navigation policy missing ${snippet}`);
}
}
if (
navigationSource.includes('javascript:') ||
navigationSource.includes('mailto:') ||
navigationSource.includes('tel:+') ||
navigationSource.includes("protocol === '")
) {
throw new Error(
'mobile shell WebView external protocol policy must use shared HostBridge normalizer',
);
}
if (shellAppSource.includes('127.0.0.1:3000')) {
throw new Error(
'mobile shell ShellApp must not hard-code localhost as the default H5 URL',
);
}
for (const dependency of [
'expo-camera',
'expo-file-system',
'expo-document-picker',
'expo-image-picker',
'expo-network',
'expo-notifications',
'expo-sharing',
'react-native-safe-area-context',
]) {
if (!packageConfig.dependencies?.[dependency]) {
throw new Error(`mobile shell package missing ${dependency}`);
}
}
const imagePickerPlugin = appConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-image-picker' : plugin === 'expo-image-picker',
);
if (!imagePickerPlugin) {
throw new Error('mobile shell image picker plugin is missing');
}
if (Array.isArray(imagePickerPlugin)) {
const pluginOptions = imagePickerPlugin[1] ?? {};
if (
pluginOptions.photosPermission !==
'允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。'
) {
throw new Error('mobile shell image picker photo permission text must describe selected creative image import');
}
if (
pluginOptions.cameraPermission !==
'允许 Genarrative 使用相机拍摄创作素材和参考图。'
) {
throw new Error('mobile shell image picker camera permission text must describe creative image capture');
}
if (
pluginOptions.microphonePermission !==
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。'
) {
throw new Error('mobile shell image picker microphone text must describe same-origin H5 gameplay input');
}
}
const cameraPlugin = appConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-camera' : plugin === 'expo-camera',
);
if (!cameraPlugin) {
throw new Error('mobile shell camera plugin is missing');
}
if (Array.isArray(cameraPlugin)) {
const pluginOptions = cameraPlugin[1] ?? {};
if (
pluginOptions.cameraPermission !==
'允许 Genarrative 使用相机扫描二维码。'
) {
throw new Error('mobile shell camera permission text must describe QR scanning');
}
if (
pluginOptions.microphonePermission !==
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。'
) {
throw new Error('mobile shell camera microphone text must describe same-origin H5 gameplay input');
}
if (pluginOptions.recordAudioAndroid !== true) {
throw new Error('mobile shell camera plugin must enable Android record audio for same-origin H5 microphone gameplay');
}
}
const notificationsPlugin = appConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-notifications' : plugin === 'expo-notifications',
);
if (!notificationsPlugin) {
throw new Error('mobile shell notifications plugin is missing');
}
if (
appConfig.plugins?.some((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-updates' : plugin === 'expo-updates',
)
) {
throw new Error('mobile shell must not install expo-updates without a real release channel');
}
if (Array.isArray(notificationsPlugin)) {
const pluginOptions = notificationsPlugin[1] ?? {};
if (pluginOptions.enableBackgroundRemoteNotifications !== false) {
throw new Error('mobile shell must not enable background remote notifications');
}
}
for (const forbiddenNotificationSnippet of [
'getExpoPushTokenAsync',
'getDevicePushTokenAsync',
'addPushTokenListener',
'addNotificationResponseReceivedListener',
...blockedScheduledNotificationSnippets,
]) {
if (hostBridgeSource.includes(forbiddenNotificationSnippet)) {
throw new Error(
`mobile shell must not register remote or background notification flow: ${forbiddenNotificationSnippet}`,
);
}
}
for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) {
if (hostBridgeSource.includes(forbiddenHostBridgeRuntimeSnippet)) {
throw new Error(
`mobile shell HostBridge production code must not rely on ${forbiddenHostBridgeRuntimeSnippet}`,
);
}
}
if (
hostBridgeSource.includes("const LOCAL_NOTIFICATION_CHANNEL_ID = '") ||
hostBridgeSource.includes('"genarrative-local"') ||
hostBridgeSource.includes("'genarrative-local'")
) {
throw new Error(
'mobile shell local notification channel id must come from shared contract',
);
}
if (sharedMobileLocalNotificationChannelId !== 'genarrative-local') {
throw new Error('shared mobile local notification channel id drifted');
}
if (!notificationsSource.includes('HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID')) {
throw new Error('mobile shell must use the shared local notification channel id');
}
if (
!/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test(
notificationsSource,
)
) {
throw new Error('mobile shell local notifications must stay immediate and channel-only');
}
if (
!dispatchSource.includes('showMobileHostBridgeLocalNotification(request)') ||
dispatchSource.includes('ok(request, await showMobileHostBridgeLocalNotification') ||
dispatchSource.includes('normalizeHostBridgeLocalNotification') ||
dispatchSource.includes("from 'expo-notifications'") ||
dispatchSource.includes('Notifications.scheduleNotificationAsync') ||
dispatchSource.includes('Notifications.requestPermissionsAsync') ||
dispatchSource.includes('Notifications.setNotificationChannelAsync')
) {
throw new Error(
'mobile shell dispatch must delegate local notification delivery to notifications.ts',
);
}
for (const notificationSnippet of [
'showMobileHostBridgeLocalNotification',
'type HostBridgeRequest',
'request: HostBridgeRequest',
'normalizeHostBridgeLocalNotification(request.payload)',
"invalidRequest('title is required')",
'showMobileLocalNotification(notification)',
'ok(request, await showMobileLocalNotification(notification))',
'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT',
]) {
if (!notificationsSource.includes(notificationSnippet)) {
throw new Error(`mobile shell notifications module is missing ${notificationSnippet}`);
}
}
for (const snippet of [
'file.exportText',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'clipboard.readText',
'notification.showLocal',
'showMobileLocalNotification',
'network.status',
'app.reloadWebView',
'getMobileNetworkStatus',
'Notifications.scheduleNotificationAsync',
'Notifications.setNotificationChannelAsync',
'Notifications.getPermissionsAsync',
'Notifications.requestPermissionsAsync',
'Sharing.shareAsync',
'DocumentPicker.getDocumentAsync',
'Clipboard.getStringAsync',
'ImagePicker.launchImageLibraryAsync',
'ImagePicker.launchCameraAsync',
'ImagePicker.requestMediaLibraryPermissionsAsync',
'ImagePicker.requestCameraPermissionsAsync',
'scanQrCode',
'normalizeHostBridgeQrCodeValue',
'completeQrCodeScan',
'cancelQrCodeScan',
'failQrCodeScan',
'subscribeQrScannerState',
'MOBILE_DOCUMENT_PICKER_TYPES',
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
"'audio/*'",
'File(asset.uri)',
'file.base64()',
'detectImageMimeType',
'detectAudioMimeType',
'ensureImageBytesMatchMimeType',
'ensureAudioBytesMatchMimeType',
"'image bytes do not match MIME'",
"'audio bytes do not match MIME'",
'normalizeExportedImageFileName',
'normalizeHostBridgeExportFileName',
'normalizeHostBridgeClipboardText',
'normalizeHostBridgeExternalUrlPayload',
'normalizeHostBridgeHapticsImpactStyle',
'base64Data',
'isHostBridgeMethod',
'normalizeHostBridgeRequestId',
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
'completedHostBridgeResponses',
'inFlightHostBridgeResponses',
'resolveMobileHostBridgeResponse',
'rememberHostBridgeResponse',
'buildMobileShellUrl(webViewUrl, navigation.urlOptions)',
]) {
if (!hostBridgeSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge missing ${snippet}`);
}
}
for (const [functionName, pickerCall] of [
['importImageFile', 'ImagePicker.launchImageLibraryAsync({'],
['captureImageFile', 'ImagePicker.launchCameraAsync({'],
]) {
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
for (const requiredPickerOption of [
pickerCall,
'allowsEditing: false',
'base64: true',
'exif: false',
"mediaTypes: ['images']",
'quality: 1',
]) {
if (!functionBody.includes(requiredPickerOption)) {
throw new Error(
`mobile shell ${functionName} picker options missing ${requiredPickerOption}`,
);
}
}
}
assertMobileDocumentPickerBoundary(
'importTextFile',
"['text/*', 'application/json']",
);
assertMobileDocumentPickerBoundary(
'importDocumentFile',
'MOBILE_DOCUMENT_PICKER_TYPES',
);
assertMobileDocumentPickerBoundary(
'importAudioFile',
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
);
const exportImageFileBody = extractFunctionBody(filesSource, 'exportImageFile');
if (
!exportImageFileBody.includes(
'bytes <= 0 || bytes > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
)
) {
throw new Error('mobile shell image export must reject empty and oversized bytes');
}
for (const [wrapperName, fileCall] of [
['exportMobileHostBridgeTextFile', 'ok(request, await exportTextFile(request.payload))'],
['importMobileHostBridgeTextFile', 'ok(request, await importTextFile())'],
['importMobileHostBridgeDocumentFile', 'ok(request, await importDocumentFile())'],
['exportMobileHostBridgeImageFile', 'ok(request, await exportImageFile(request.payload))'],
['importMobileHostBridgeImageFile', 'ok(request, await importImageFile())'],
['captureMobileHostBridgeImageFile', 'ok(request, await captureImageFile())'],
['exportMobileHostBridgeAudioFile', 'ok(request, await exportAudioFile(request.payload))'],
['importMobileHostBridgeAudioFile', 'ok(request, await importAudioFile())'],
]) {
const wrapperBody = extractFunctionBody(filesSource, wrapperName);
if (!wrapperBody.includes(fileCall)) {
throw new Error(`mobile shell file HostBridge wrapper missing ${fileCall}`);
}
}
for (const snippet of [
'describe(\'mobile HostBridge file actions\'',
'exportTextFile({',
'exportImageFile({',
'importTextFile()',
'importDocumentFile()',
'importAudioFile()',
'importImageFile()',
'captureImageFile()',
'exportAudioFile({',
"code: 'unsupported_capability'",
"code: 'cancelled'",
"message: 'camera permission denied'",
"mediaTypes: ['images']",
"options: { encoding: 'base64' }",
]) {
if (!filesTestSource.includes(snippet)) {
throw new Error(`mobile shell file action tests missing ${snippet}`);
}
}
if (
!dispatchSource.includes('exportMobileHostBridgeTextFile(request)') ||
!dispatchSource.includes('importMobileHostBridgeTextFile(request)') ||
!dispatchSource.includes('importMobileHostBridgeDocumentFile(request)') ||
!dispatchSource.includes('exportMobileHostBridgeImageFile(request)') ||
!dispatchSource.includes('importMobileHostBridgeImageFile(request)') ||
!dispatchSource.includes('captureMobileHostBridgeImageFile(request)') ||
!dispatchSource.includes('exportMobileHostBridgeAudioFile(request)') ||
!dispatchSource.includes('importMobileHostBridgeAudioFile(request)') ||
dispatchSource.includes('ok(request, await exportTextFile(request.payload))') ||
dispatchSource.includes('ok(request, await importTextFile())') ||
dispatchSource.includes('ok(request, await importDocumentFile())') ||
dispatchSource.includes('ok(request, await exportImageFile(request.payload))') ||
dispatchSource.includes('ok(request, await importImageFile())') ||
dispatchSource.includes('ok(request, await captureImageFile())') ||
dispatchSource.includes('ok(request, await exportAudioFile(request.payload))') ||
dispatchSource.includes('ok(request, await importAudioFile())')
) {
throw new Error('mobile shell dispatch must delegate file requests to files.ts');
}
if (
!hostBridgeNavigationSource.includes(
'const externalUrlPayload = normalizeHostBridgeExternalUrlPayload(',
) ||
!hostBridgeNavigationSource.includes(
'openMobileShellExternalNavigation(Linking, externalUrlPayload.url)',
)
) {
throw new Error(
'mobile shell app.openExternalUrl must normalize payloads and use the shared external navigation helper',
);
}
for (const snippet of [
'openMobileHostBridgeExternalUrl',
'openMobileHostBridgeNativePage',
'reloadMobileHostBridgeWebView',
'Linking.canOpenURL',
'Linking.openURL',
'javascript:alert(1)',
'external URL cannot be opened',
'navigation.openNativePage unsupported in mobile shell',
'app.reloadWebView unsupported in mobile shell',
'hostCapabilities',
]) {
if (!hostBridgeNavigationTestSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge navigation test missing ${snippet}`);
}
}
if (
!clipboardSource.includes(
'const clipboardText = normalizeHostBridgeClipboardText(',
) ||
!clipboardSource.includes('Clipboard.setStringAsync(clipboardText.text)') ||
!clipboardSource.includes('await Clipboard.getStringAsync()')
) {
throw new Error(
'mobile shell clipboard bridge must normalize text with the shared HostBridge clipboard boundary',
);
}
if (
dispatchSource.includes("from 'expo-clipboard'") ||
dispatchSource.includes('Clipboard.setStringAsync') ||
dispatchSource.includes('Clipboard.getStringAsync') ||
dispatchSource.includes('ClipboardWriteTextPayload') ||
!dispatchSource.includes('writeMobileHostBridgeClipboardText(request)') ||
!dispatchSource.includes('readMobileHostBridgeClipboardText(request)') ||
dispatchSource.includes('ok(request, await writeMobileHostBridgeClipboardText') ||
dispatchSource.includes('ok(request, await readMobileHostBridgeClipboardText')
) {
throw new Error(
'mobile shell dispatch must delegate clipboard IO to clipboard.ts',
);
}
for (const snippet of [
'writeMobileHostBridgeClipboardText',
'type ClipboardWriteTextPayload',
'request: HostBridgeRequest',
'ok(request, true)',
'writeMobileClipboardText(text)',
'readMobileHostBridgeClipboardText',
'ok(request, await readMobileClipboardText())',
]) {
if (!clipboardSource.includes(snippet)) {
throw new Error(`mobile shell clipboard module is missing ${snippet}`);
}
}
if (!bridgeTestSource.includes("'猫'.repeat(100000)")) {
throw new Error('mobile shell clipboard tests must cover Unicode truncation');
}
if (
!hapticsSource.includes('normalizeHostBridgeHapticsImpactStyle(rawStyle)') ||
!hapticsSource.includes('type HapticsImpactPayload') ||
!hapticsSource.includes('type HostBridgeRequest') ||
!hapticsSource.includes('runMobileHostBridgeHapticsImpact(') ||
!hapticsSource.includes('request: HostBridgeRequest') ||
!hapticsSource.includes('(request.payload as HapticsImpactPayload | undefined)?.style') ||
!hapticsSource.includes("invalidRequest('haptics impact style must be light, medium, or heavy')") ||
!hapticsSource.includes('Haptics.ImpactFeedbackStyle.Heavy') ||
!hapticsSource.includes('Haptics.ImpactFeedbackStyle.Medium') ||
!hapticsSource.includes('Haptics.ImpactFeedbackStyle.Light') ||
!hapticsSource.includes('await Haptics.impactAsync(toExpoImpactStyle(style))') ||
!hapticsSource.includes('ok(request, true)')
) {
throw new Error(
'mobile shell haptics bridge must normalize impact style with the shared HostBridge boundary',
);
}
if (
dispatchSource.includes("from 'expo-haptics'") ||
dispatchSource.includes('Haptics.impactAsync') ||
dispatchSource.includes('Haptics.ImpactFeedbackStyle') ||
dispatchSource.includes('HapticsImpactPayload') ||
dispatchSource.includes('haptics impact style must be light, medium, or heavy') ||
dispatchSource.includes('ok(request, await runMobileHostBridgeHapticsImpact') ||
!dispatchSource.includes('runMobileHostBridgeHapticsImpact(request)')
) {
throw new Error(
'mobile shell dispatch must delegate haptics payload and IO to haptics.ts',
);
}
for (const snippet of [
'CameraView',
'Camera.requestCameraPermissionsAsync',
'type BarcodeScanningResult',
'onBarcodeScanned',
'barcodeScannerSettings',
"barcodeTypes: ['qr']",
'completeQrCodeScan(result.data)',
'failQrCodeScan',
'cancelQrCodeScan',
'subscribeQrScannerState',
'logQrScannerPermissionFailure(error)',
"console.warn('mobile QR scanner permission request failed', error)",
]) {
if (!qrScannerOverlaySource.includes(snippet)) {
throw new Error(`mobile shell QR scanner overlay missing ${snippet}`);
}
}
for (const snippet of [
'describe(\'QrScannerOverlay\'',
'scanQrCode()',
'requestCameraPermissionsAsync',
"message: 'camera permission denied'",
"message: 'camera permission unavailable'",
"message: 'qr scan cancelled'",
"test('logs and rejects the active scan when camera permission request fails'",
"'mobile QR scanner permission request failed'",
"barcodeTypes: ['qr']",
"type: 'qr'",
"value: 'https://app.genarrative.world/works/detail?work=PZ-1'",
]) {
if (!qrScannerOverlayTestSource.includes(snippet)) {
throw new Error(`mobile shell QR scanner overlay test missing ${snippet}`);
}
}
if (!shellAppSource.includes('<QrScannerOverlay />')) {
throw new Error('mobile shell ShellApp must render the QR scanner overlay');
}
for (const snippet of [
'scanner.scanQrCode',
'requestCameraPermissionsAsync',
'onBarcodeScanned',
'injectJavaScript',
'scan-request-1',
]) {
if (!shellAppTestSource.includes(snippet)) {
throw new Error(`mobile shell ShellApp QR scanner test missing ${snippet}`);
}
}
for (const snippet of [
"hostBridgeEvent('app.lifecycle')",
"hostBridgeEvent('network.statusChanged')",
"lastHostBridgeEvent('navigation.canGoBack')",
"shellHarness.appStateListeners[0]?.('background')",
'shellHarness.networkListeners[0]?.({',
"type: 'genarrative.mobile.historyState'",
'mobile host event failed for network.statusChanged',
'external WebView navigation open failure is logged instead of hidden',
'mobile shell navigation failed for external_navigation.open',
]) {
if (!shellAppTestSource.includes(snippet)) {
throw new Error(`mobile shell ShellApp HostBridge event test missing ${snippet}`);
}
}
if (
!dispatchSource.includes('scanMobileHostBridgeQrCode(request)') ||
dispatchSource.includes('ok(request, await scanQrCode())')
) {
throw new Error('mobile shell QR scanner HostBridge method must delegate to scanner module');
}
for (const scannerSnippet of [
'scanMobileHostBridgeQrCode(request: HostBridgeRequest)',
'ok(request, await scanQrCode())',
'normalizeHostBridgeQrCodeValue',
"hostBridgeError('cancelled', 'qr scan cancelled')",
]) {
if (!scannerSource.includes(scannerSnippet)) {
throw new Error(`mobile shell QR scanner module missing ${scannerSnippet}`);
}
}
for (const scannerTestSnippet of [
'subscribeQrScannerState',
'scanQrCode',
'scanMobileHostBridgeQrCode',
'completeQrCodeScan',
'cancelQrCodeScan',
'failQrCodeScan',
'qr scanner already active',
'camera permission denied',
'qr scan cancelled',
'PZ-00000001',
]) {
if (!scannerTestSource.includes(scannerTestSnippet)) {
throw new Error(
`mobile shell QR scanner helper test missing ${scannerTestSnippet}`,
);
}
}
const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES";
if (shellAppSource.includes(capabilityQuerySnippet)) {
throw new Error('mobile shell URL must resolve platform-aware capabilities');
}
if (!shellAppSource.includes('capabilities: resolveMobileHostCapabilities()')) {
throw new Error('mobile shell URL must use resolveMobileHostCapabilities()');
}
if (!shellAppSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
throw new Error('mobile shell URL must use the shared mobile shell host version');
}
if (!hostBridgeRuntimeSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
throw new Error('mobile shell runtime response must use the shared mobile shell host version');
}
for (const runtimeSnippet of [
'export function getMobileRuntimePlatform()',
"return Platform.OS === 'ios' ? 'ios' : 'android'",
'export function getMobileHostBridgeRuntime(): HostBridgeRuntimeResult',
'const platform = getMobileRuntimePlatform();',
'platform,',
'capabilities: resolveMobileHostCapabilities(platform)',
'getMobileHostBridgeRuntimeResponse(request: HostBridgeRequest)',
'ok(request, getMobileHostBridgeRuntime())',
]) {
if (!hostBridgeRuntimeSource.includes(runtimeSnippet)) {
throw new Error(`mobile shell runtime response missing ${runtimeSnippet}`);
}
}
if (
!dispatchSource.includes('getMobileHostBridgeRuntimeResponse(request)') ||
dispatchSource.includes('ok(request, getMobileHostBridgeRuntime') ||
dispatchSource.includes('MOBILE_SHELL_HOST_VERSION') ||
dispatchSource.includes('HOST_BRIDGE_VERSION') ||
dispatchSource.includes('resolveMobileHostCapabilities(') ||
dispatchSource.includes('getMobileRuntimePlatform') ||
dispatchSource.includes("shell: 'expo_mobile'")
) {
throw new Error('mobile shell dispatch must delegate host.getRuntime to runtime.ts');
}
if (hostBridgeRuntimeSource.includes('capabilities: resolveMobileHostCapabilities(),')) {
throw new Error(
'mobile shell runtime capabilities must use the same normalized platform reported in host.getRuntime',
);
}
for (const snippet of [
'reports iOS runtime with host version, bridge version and iOS capabilities',
'reports Android runtime without iOS-only app badge capability',
'wraps runtime metadata in the HostBridge response shape',
'hostVersion: MOBILE_SHELL_HOST_VERSION',
'bridgeVersion: HOST_BRIDGE_VERSION',
"not.toContain('app.setBadgeCount')",
"expect(getMobileRuntimePlatform()).toBe('android')",
]) {
if (!hostBridgeRuntimeTestSource.includes(snippet)) {
throw new Error(`mobile shell runtime tests missing ${snippet}`);
}
}
for (const snippet of [
"import appConfig from '../../app.json'",
'resolveMobileShellHostVersion(',
'appConfig.expo?.version',
'MOBILE_SHELL_HOST_VERSION_FALLBACK',
]) {
if (!runtimeSource.includes(snippet)) {
throw new Error(`mobile shell runtime version source missing ${snippet}`);
}
}
if (/export const MOBILE_SHELL_HOST_VERSION\s*=\s*'/.test(runtimeSource)) {
throw new Error('mobile shell host version must be resolved from Expo runtime config');
}
if (
shellAppSource.includes("hostVersion: '0.1.0'") ||
hostBridgeSource.includes("hostVersion: '0.1.0'")
) {
throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source');
}
if (shellAppSource.includes(`bridge: '${sharedHostBridgeProtocol}'`)) {
throw new Error('mobile shell event injection must use HOST_BRIDGE_PROTOCOL');
}
if (shellAppSource.includes(`version: ${sharedHostBridgeVersion}`)) {
throw new Error('mobile shell event injection must use HOST_BRIDGE_VERSION');
}
if (urlSource.includes(`bridgeVersion', '${sharedHostBridgeVersion}'`)) {
throw new Error('mobile shell URL builder must use HOST_BRIDGE_VERSION');
}
for (const capability of sdkBackedCapabilities) {
if (
shellAppSource.includes(`'${capability}'`) ||
shellAppSource.includes(`"${capability}"`)
) {
throw new Error(
`mobile shell URL must not advertise ${capability} without a real SDK/channel flow`,
);
}
}
for (const capability of [
'host.getRuntime',
'appearance.getColorScheme',
'share.open',
'share.setTarget',
'app.lifecycle',
'network.status',
'network.statusChanged',
'navigation.openNativePage',
'navigation.canGoBack',
'app.reloadWebView',
'app.openExternalUrl',
'clipboard.writeText',
'clipboard.readText',
'file.exportText',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
'notification.showLocal',
]) {
if (!mobileCapabilitySet.has(capability)) {
throw new Error(`mobile shell capabilities missing ${capability}`);
}
}
if (!iosMobileCapabilitySet.has('app.setBadgeCount')) {
throw new Error('iOS mobile shell capabilities missing app.setBadgeCount');
}
if (mobileCapabilitySet.has('app.setBadgeCount')) {
throw new Error('Android mobile shell base capabilities must not include app.setBadgeCount');
}
for (const snippet of [
'MOBILE_HOST_CAPABILITIES',
'IOS_MOBILE_HOST_CAPABILITIES',
'resolveMobileHostCapabilities',
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
"resolveMobileHostCapabilities('android')",
"resolveMobileHostCapabilities('ios')",
"not.toContain('app.setBadgeCount')",
"not.toContain('auth.requestLogin')",
"not.toContain('payment.request')",
]) {
if (!capabilitiesTestSource.includes(snippet)) {
throw new Error(`mobile shell capability profile test missing ${snippet}`);
}
}
if (
!dispatchSource.includes('setMobileAppBadgeCount(request)') ||
dispatchSource.includes('ok(request, setMobileAppBadgeCount') ||
dispatchSource.includes('PushNotificationIOS') ||
dispatchSource.includes('normalizeHostBridgeBadgeCount') ||
dispatchSource.includes('HOST_BRIDGE_BADGE_COUNT_MAX')
) {
throw new Error('mobile shell badge HostBridge method must delegate to badge module');
}
for (const snippet of [
'HOST_BRIDGE_BADGE_COUNT_MAX',
'request: HostBridgeRequest',
'normalizeHostBridgeBadgeCount',
'PushNotificationIOS.setApplicationIconBadgeNumber(count)',
'ok(request, true)',
'app badge count is only supported on iOS mobile shell',
]) {
if (!badgeSource.includes(snippet)) {
throw new Error(`mobile shell badge module is missing ${snippet}`);
}
}
for (const snippet of [
"test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported'",
"setPlatformOS('android')",
"request('app.setBadgeCount',",
"expect(expectFailed(unsupported).error.code).toBe('unsupported_capability')",
'expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled()',
]) {
if (!bridgeTestSource.includes(snippet)) {
throw new Error(
`mobile shell bridge tests must cover Android app.setBadgeCount unsupported semantics: ${snippet}`,
);
}
}
for (const snippet of [
'sets and clears the iOS app badge count through PushNotificationIOS',
'rejects invalid badge counts before touching the system badge',
'rejects missing badge payload before touching the system badge',
'returns unsupported on Android before validating payload or touching badge APIs',
'HOST_BRIDGE_BADGE_COUNT_MAX + 1',
"setPlatformOS('android')",
'not.toHaveBeenCalled()',
]) {
if (!badgeTestSource.includes(snippet)) {
throw new Error(`mobile shell badge tests missing ${snippet}`);
}
}
if (
!dispatchSource.includes('getMobileHostBridgeAppearanceColorScheme(request)') ||
dispatchSource.includes('ok(request, getMobileAppearanceColorScheme())') ||
dispatchSource.includes('Appearance.getColorScheme()') ||
dispatchSource.includes('normalizeHostBridgeColorScheme')
) {
throw new Error('mobile shell appearance HostBridge method must delegate to appearance module');
}
for (const snippet of [
'Appearance.getColorScheme()',
'normalizeHostBridgeColorScheme',
'getMobileAppearanceColorScheme',
'getMobileHostBridgeAppearanceColorScheme',
'request: HostBridgeRequest',
'ok(request, getMobileAppearanceColorScheme())',
]) {
if (!appearanceSource.includes(snippet)) {
throw new Error(`mobile shell appearance module is missing ${snippet}`);
}
}
for (const snippet of [
'reads the current light or dark system color scheme',
'normalizes missing or unknown native color schemes to unknown',
'wraps the system color scheme in the HostBridge response shape',
"mockReturnValue('light')",
"mockReturnValue('dark')",
'colorScheme: \'unknown\'',
]) {
if (!appearanceTestSource.includes(snippet)) {
throw new Error(`mobile shell appearance tests missing ${snippet}`);
}
}
if (
!dispatchSource.includes('openMobileHostBridgeExternalUrl(request)') ||
!dispatchSource.includes('openMobileHostBridgeNativePage(request, navigation)') ||
!dispatchSource.includes('reloadMobileHostBridgeWebView(request, navigation)') ||
dispatchSource.includes('ok(request, await openMobileHostBridgeExternalUrl') ||
dispatchSource.includes('ok(request, reloadMobileHostBridgeWebView') ||
dispatchSource.includes('ok(\\n request,\\n openMobileHostBridgeNativePage') ||
dispatchSource.includes("from 'expo-linking'") ||
dispatchSource.includes('Linking.') ||
dispatchSource.includes('openMobileShellExternalNavigation') ||
dispatchSource.includes('resolveMobileShellWebViewUrl') ||
dispatchSource.includes('normalizeHostBridgeExternalUrlPayload') ||
dispatchSource.includes('buildMobileShellUrl')
) {
throw new Error('mobile shell navigation HostBridge methods must delegate to navigation module');
}
for (const snippet of [
'openMobileHostBridgeExternalUrl',
'request: HostBridgeRequest',
"import * as Linking from 'expo-linking'",
'openMobileShellExternalNavigation(Linking, externalUrlPayload.url)',
'(request.payload as OpenExternalUrlPayload | undefined)?.url',
'normalizeHostBridgeExternalUrlPayload',
'openMobileHostBridgeNativePage',
'(request.payload as NavigateNativePagePayload | undefined)?.url',
'resolveMobileShellWebViewUrl',
'buildMobileShellUrl(webViewUrl, navigation.urlOptions)',
'reloadMobileHostBridgeWebView',
'ok(request, true)',
]) {
if (!hostBridgeNavigationSource.includes(snippet)) {
throw new Error(`mobile shell navigation module is missing ${snippet}`);
}
}
if (!hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(Linking, externalUrlPayload.url)')) {
throw new Error(
'mobile shell HostBridge external URL flow must use the shared external navigation helper',
);
}
if (
!dispatchSource.includes('getMobileHostBridgeNetworkStatus(request)') ||
dispatchSource.includes('../shell/network') ||
dispatchSource.includes('getMobileNetworkStatus()') ||
dispatchSource.includes('ok(request, await getMobileHostBridgeNetworkStatus())')
) {
throw new Error('mobile shell network HostBridge method must delegate to network module');
}
for (const snippet of [
'getMobileHostBridgeNetworkStatus',
'request: HostBridgeRequest',
'ok(request, await getMobileNetworkStatus())',
'getMobileNetworkStatus()',
]) {
if (!hostBridgeNetworkSource.includes(snippet)) {
throw new Error(`mobile shell network module is missing ${snippet}`);
}
}
for (const snippet of [
'wraps Expo Network status in the HostBridge response shape',
'normalizes disconnected network state before wrapping response',
'lets protocol layer convert native network failures to host errors',
'connectionType: \'cellular\'',
'connectionType: \'none\'',
'network query failed',
]) {
if (!hostBridgeNetworkTestSource.includes(snippet)) {
throw new Error(`mobile shell network tests missing ${snippet}`);
}
}
if (!shellAppSource.includes('openMobileShellExternalNavigation(Linking, request.url)')) {
throw new Error(
'mobile shell WebView external navigation must use the tested external navigation helper',
);
}
if (
shellAppSource.includes('Linking.canOpenURL(externalUrl)') ||
shellAppSource.includes('Linking.openURL(externalUrl)')
) {
throw new Error(
'mobile shell ShellApp must not inline external navigation Link handling',
);
}