8bd8363ffa
将原生壳公开主站统一为 www.genarrative.world 调整 Tauri release 直接加载线上主站并移除 frontendDist 打包 补齐创作入口配置开发代理与回归测试 同步原生壳方案文档和共享记忆
3293 lines
115 KiB
JavaScript
3293 lines
115 KiB
JavaScript
import fs from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
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 expoExportSmokePath = new URL(
|
|
'../scripts/check-expo-export.mjs',
|
|
import.meta.url,
|
|
);
|
|
const expoExportSmokeSource = fs.readFileSync(expoExportSmokePath, 'utf8');
|
|
const buildArtifactsSmokePath = new URL(
|
|
'../scripts/check-build-artifacts.mjs',
|
|
import.meta.url,
|
|
);
|
|
const buildArtifactsSmokeSource = fs.readFileSync(
|
|
buildArtifactsSmokePath,
|
|
'utf8',
|
|
);
|
|
const nativeShellCheckPath = new URL(
|
|
'../../../scripts/check-native-shells.mjs',
|
|
import.meta.url,
|
|
);
|
|
const nativeShellCheckSource = fs.readFileSync(nativeShellCheckPath, '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 capabilitiesPath = new URL(
|
|
'../src/host-bridge/capabilities.ts',
|
|
import.meta.url,
|
|
);
|
|
const capabilitiesSource = fs.readFileSync(capabilitiesPath, '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 clipboardTestPath = new URL(
|
|
'../src/host-bridge/clipboard.test.ts',
|
|
import.meta.url,
|
|
);
|
|
const clipboardTestSource = fs.readFileSync(clipboardTestPath, 'utf8');
|
|
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
|
|
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
|
|
const dispatchTestPath = new URL(
|
|
'../src/host-bridge/dispatch.test.ts',
|
|
import.meta.url,
|
|
);
|
|
const dispatchTestSource = fs.readFileSync(dispatchTestPath, '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 hapticsTestPath = new URL(
|
|
'../src/host-bridge/haptics.test.ts',
|
|
import.meta.url,
|
|
);
|
|
const hapticsTestSource = fs.readFileSync(hapticsTestPath, '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 notificationsTestPath = new URL(
|
|
'../src/host-bridge/notifications.test.ts',
|
|
import.meta.url,
|
|
);
|
|
const notificationsTestSource = fs.readFileSync(notificationsTestPath, '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');
|
|
|
|
function assertLabelOnlyMobileHostBridgeDiagnostics(source, label, message) {
|
|
if (source.includes(`console.warn(\`${message} \${label}\`, error)`)) {
|
|
throw new Error(`${label} diagnostics must not log native error objects`);
|
|
}
|
|
if (!source.includes(`console.warn(\`${message} \${label}\`)`)) {
|
|
throw new Error(`${label} diagnostics must use stable label-only logging`);
|
|
}
|
|
}
|
|
|
|
for (const [source, label, message] of [
|
|
[badgeSource, 'mobile app badge', 'mobile app badge failed for'],
|
|
[clipboardSource, 'mobile clipboard', 'mobile clipboard failed for'],
|
|
[hapticsSource, 'mobile haptics', 'mobile haptics failed for'],
|
|
[
|
|
hostBridgeNavigationSource,
|
|
'mobile HostBridge navigation',
|
|
'mobile HostBridge navigation failed for',
|
|
],
|
|
[hostBridgeNetworkSource, 'mobile network', 'mobile network failed for'],
|
|
[notificationsSource, 'mobile notification', 'mobile notification failed for'],
|
|
[shareSource, 'mobile share', 'mobile share failed for'],
|
|
]) {
|
|
assertLabelOnlyMobileHostBridgeDiagnostics(source, label, message);
|
|
}
|
|
|
|
function assertSourceDoesNotInclude(source, snippet, message) {
|
|
if (source.includes(snippet)) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
const urlPath = new URL('../src/shell/url.ts', import.meta.url);
|
|
const urlSource = fs.readFileSync(urlPath, 'utf8');
|
|
const urlTestPath = new URL('../src/shell/url.test.ts', import.meta.url);
|
|
const urlTestSource = fs.readFileSync(urlTestPath, 'utf8');
|
|
const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url);
|
|
const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8');
|
|
const deepLinkTestPath = new URL('../src/shell/deepLink.test.ts', import.meta.url);
|
|
const deepLinkTestSource = fs.readFileSync(deepLinkTestPath, 'utf8');
|
|
const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url);
|
|
const navigationSource = fs.readFileSync(navigationPath, 'utf8');
|
|
const navigationTestPath = new URL('../src/shell/navigation.test.ts', import.meta.url);
|
|
const navigationTestSource = fs.readFileSync(navigationTestPath, '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');
|
|
|
|
for (const [source, snippet, message] of [
|
|
[
|
|
shellAppSource,
|
|
'console.warn(`mobile host event failed for ${label}`, error)',
|
|
'mobile shell host event diagnostics must not log native error objects',
|
|
],
|
|
[
|
|
shellAppSource,
|
|
"console.warn('mobile HostBridge message injection failed', error)",
|
|
'mobile shell HostBridge injection diagnostics must not log native error objects',
|
|
],
|
|
[
|
|
shellAppSource,
|
|
'console.warn(`mobile shell navigation failed for ${label}`, error)',
|
|
'mobile shell navigation diagnostics must not log native error objects',
|
|
],
|
|
[
|
|
shellAppSource,
|
|
"console.warn('mobile shell blocked WebView file download', event)",
|
|
'mobile shell blocked download diagnostics must not log WebView event objects',
|
|
],
|
|
[
|
|
shellAppSource,
|
|
'console.warn(`mobile shell deep link failed for ${label}`, error)',
|
|
'mobile shell deep link diagnostics must not log native error objects',
|
|
],
|
|
[
|
|
qrScannerOverlaySource,
|
|
"console.warn('mobile QR scanner permission request failed', error)",
|
|
'mobile QR scanner diagnostics must not log native error objects',
|
|
],
|
|
[
|
|
webViewPolicySource,
|
|
"console.warn('mobile navigation state sync failed', error)",
|
|
'mobile WebView history diagnostics must not log native error objects',
|
|
],
|
|
]) {
|
|
assertSourceDoesNotInclude(source, snippet, message);
|
|
}
|
|
|
|
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 androidBuildOutputPath =
|
|
'../../build/native/mobile/genarrative-mobile-android.apk';
|
|
const iosSimulatorBuildOutputPath =
|
|
'../../build/native/mobile/genarrative-mobile-ios-simulator.tar.gz';
|
|
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-build-artifacts.mjs',
|
|
'check-config.mjs',
|
|
'check-eas-build-config.mjs',
|
|
'check-expo-config.mjs',
|
|
'check-expo-export.mjs',
|
|
];
|
|
const productionSourceExcludedDirectories = new Set([
|
|
'node_modules',
|
|
'test-utils',
|
|
]);
|
|
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',
|
|
];
|
|
const generatedMobilePaths = [
|
|
'apps/mobile-shell/.expo',
|
|
'apps/mobile-shell/.expo-export-smoke',
|
|
];
|
|
|
|
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 extractMobileBridgeUnsupportedMethods(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 unsupported methods');
|
|
}
|
|
|
|
const unsupportedMethods = new Set();
|
|
const casePattern =
|
|
/case '([^']+)':([\s\S]*?)(?=\n case '|\n default:|\n \})/g;
|
|
for (const entry of match[1].matchAll(casePattern)) {
|
|
if (entry[2].includes('unsupported(request.method)')) {
|
|
unsupportedMethods.add(entry[1]);
|
|
}
|
|
}
|
|
|
|
return [...unsupportedMethods];
|
|
}
|
|
|
|
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('pickMobileDocumentFile(')) {
|
|
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 })
|
|
.filter(
|
|
(child) =>
|
|
!child.isDirectory() ||
|
|
!productionSourceExcludedDirectories.has(child.name),
|
|
)
|
|
.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.')) {
|
|
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 assertNoTrackedMobileGeneratedFiles() {
|
|
const result = spawnSync('git', ['ls-files', ...generatedMobilePaths], {
|
|
cwd: new URL('../../..', import.meta.url),
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(
|
|
`unable to check mobile generated files: ${result.error.message}`,
|
|
);
|
|
}
|
|
if ((result.status ?? 0) !== 0) {
|
|
throw new Error(
|
|
`unable to check mobile generated files: ${result.stderr.trim()}`,
|
|
);
|
|
}
|
|
|
|
const trackedGeneratedFiles = result.stdout
|
|
.split('\n')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
if (trackedGeneratedFiles.length > 0) {
|
|
throw new Error(
|
|
`mobile generated files must stay untracked: ${trackedGeneratedFiles.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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())
|
|
.map((entry) => entry.name)
|
|
.sort(),
|
|
requiredMobileShellScriptModules,
|
|
'mobile shell smoke scripts',
|
|
);
|
|
|
|
for (const snippet of [
|
|
'const requiredNativeHostContextTokens = [',
|
|
"'native_app'",
|
|
"'expo_mobile'",
|
|
"'hostCapabilities'",
|
|
"'hostVersion'",
|
|
"'bridgeVersion'",
|
|
'production bundle must include native host context token',
|
|
'metadata.version !== 0',
|
|
'Object.keys(fileMetadata)',
|
|
'metadata must only include its platform',
|
|
'metadata must include an assets array',
|
|
'!bundlePath.startsWith(`_expo/static/js/${platform}/AppEntry-`)',
|
|
"!bundlePath.endsWith('.hbc')",
|
|
]) {
|
|
if (!expoExportSmokeSource.includes(snippet)) {
|
|
throw new Error(`mobile shell Expo export smoke must verify host context token ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'genarrative-mobile-android.apk',
|
|
'genarrative-mobile-ios-simulator.tar.gz',
|
|
'AndroidManifest.xml',
|
|
'assets/index.android.bundle',
|
|
'.app/Info.plist',
|
|
'.app/Genarrative',
|
|
'.app/main.jsbundle',
|
|
'[mobile-shell:build-artifacts] OK',
|
|
]) {
|
|
if (!buildArtifactsSmokeSource.includes(snippet)) {
|
|
throw new Error(`mobile shell build artifact smoke must verify ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
"label: 'mobile-shell-eas-build-config-smoke'",
|
|
"args: ['run', 'mobile-shell:build-config']",
|
|
"label: 'mobile-shell-expo-config-smoke'",
|
|
"args: ['run', 'mobile-shell:config']",
|
|
"label: 'mobile-shell-expo-export-smoke'",
|
|
"args: ['run', 'mobile-shell:export']",
|
|
]) {
|
|
if (!nativeShellCheckSource.includes(snippet)) {
|
|
throw new Error(`root native shell gate must keep mobile distribution smoke ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const excludedDirectory of productionSourceExcludedDirectories) {
|
|
if (!nativeShellCheckSource.includes(`'${excludedDirectory}'`)) {
|
|
throw new Error(
|
|
`root native shell check is missing mobile source exclusion: ${excludedDirectory}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
assertNoDevScaffoldTerms(
|
|
productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)),
|
|
);
|
|
assertNoTrackedMobileGeneratedFiles();
|
|
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 --output ${androidBuildOutputPath}`,
|
|
'build:ios': `eas build --local --profile production-simulator --platform ios --output ${iosSimulatorBuildOutputPath}`,
|
|
'build-artifacts:smoke': 'node scripts/check-build-artifacts.mjs',
|
|
'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:build-artifacts': 'npm --prefix apps/mobile-shell run build-artifacts:smoke',
|
|
'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({
|
|
'eas-cli': '^20.3.0',
|
|
typescript: '~5.8.2',
|
|
vitest: '^0.34.6',
|
|
})) {
|
|
assertPackageDependencyVersion(
|
|
packageConfig,
|
|
'mobile shell package',
|
|
'devDependencies',
|
|
dependency,
|
|
expected,
|
|
);
|
|
assertPackageDependencyVersion(
|
|
rootPackageConfig,
|
|
'root package',
|
|
'devDependencies',
|
|
dependency,
|
|
expected,
|
|
);
|
|
}
|
|
|
|
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 unsupportedMobileMethods = extractMobileBridgeUnsupportedMethods(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}`,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
protocolSource.includes('error instanceof Error') ||
|
|
protocolSource.includes('? error.message') ||
|
|
protocolSource.includes('error.message\n :')
|
|
) {
|
|
throw new Error('mobile shell protocol must not expose unknown native Error.message values to H5');
|
|
}
|
|
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', 'readMobileTextFile(file,'],
|
|
['importDocumentFile', 'readMobileBase64File(file,'],
|
|
['importAudioFile', 'readMobileBase64File(file,'],
|
|
]) {
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'export const MOBILE_HOST_CAPABILITIES =\n HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES;',
|
|
'export const IOS_MOBILE_HOST_CAPABILITIES =\n HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES;',
|
|
]) {
|
|
if (!capabilitiesSource.includes(snippet)) {
|
|
throw new Error(`mobile shell capability profile must stay directly shared: ${snippet}`);
|
|
}
|
|
}
|
|
|
|
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)',
|
|
'dispatchMobileHostBridgeRequest(request(method))',
|
|
"expect(response.error.code).toBe('unsupported_method')",
|
|
]) {
|
|
if (!dispatchTestSource.includes(snippet)) {
|
|
throw new Error(
|
|
`mobile shell dispatch 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 unsupportedMobileCapabilities = iosMobileCapabilities.filter(
|
|
(capability) =>
|
|
sharedMethods.includes(capability) &&
|
|
unsupportedMobileMethods.includes(capability),
|
|
);
|
|
if (unsupportedMobileCapabilities.length > 0) {
|
|
throw new Error(
|
|
`mobile shell declares request capabilities backed only by unsupported responses: ${unsupportedMobileCapabilities.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');
|
|
}
|
|
|
|
for (const [label, outputPath, extension] of [
|
|
['Android', androidBuildOutputPath, '.apk'],
|
|
['iOS simulator', iosSimulatorBuildOutputPath, '.tar.gz'],
|
|
]) {
|
|
if (!outputPath.startsWith('../../build/native/mobile/')) {
|
|
throw new Error(`mobile shell ${label} local build output must stay under root build/native/mobile`);
|
|
}
|
|
if (!outputPath.endsWith(extension)) {
|
|
throw new Error(`mobile shell ${label} local build output must end with ${extension}`);
|
|
}
|
|
}
|
|
|
|
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 !== 2 ||
|
|
appConfig.android.permissions[0] !== 'android.permission.POST_NOTIFICATIONS' ||
|
|
appConfig.android.permissions[1] !== 'android.permission.RECORD_AUDIO'
|
|
) {
|
|
throw new Error('mobile shell Android package must request only POST_NOTIFICATIONS and RECORD_AUDIO for real local notifications and 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?.blockedPermissions?.includes('android.permission.POST_NOTIFICATIONS')) {
|
|
throw new Error('mobile shell Android package must not block POST_NOTIFICATIONS needed by local notification.showLocal delivery');
|
|
}
|
|
|
|
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'",
|
|
'resolveMobileShellUrlFromDeepLink',
|
|
'logMobileShellDeepLinkFailure',
|
|
"'initial_url.read'",
|
|
"logMobileShellDeepLinkFailure(`${source}.rejected`, url)",
|
|
'configureMobileHostBridgeNavigation',
|
|
'HOST_BRIDGE_PROTOCOL',
|
|
'HOST_BRIDGE_VERSION',
|
|
'shouldAcceptMobileShellHostBridgeMessage',
|
|
'webViewRef.current?.reload()',
|
|
'const reloadCurrentWebView = useCallback(() => {',
|
|
'reloadWebView: reloadCurrentWebView',
|
|
'MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS',
|
|
'MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT',
|
|
'handleWebViewProcessFailure',
|
|
'mobile WebView process failed for',
|
|
"handleWebViewProcessFailure('content_process_terminated')",
|
|
"handleWebViewProcessFailure('render_process_gone')",
|
|
'normalizeMobileShellLoadFailure',
|
|
'handleWebViewLoadError',
|
|
'handleWebViewHttpError',
|
|
'handleRetryLoadFailure',
|
|
'onError={handleWebViewLoadError}',
|
|
'onHttpError={handleWebViewHttpError}',
|
|
'loadFailurePanel',
|
|
'loadFailureButton',
|
|
'AppState.addEventListener',
|
|
'app.lifecycle',
|
|
'network.statusChanged',
|
|
'getMobileNetworkStatus',
|
|
'subscribeMobileNetworkStatus',
|
|
'nativeCanGoBackRef',
|
|
'h5CanGoBackRef',
|
|
'syncNavigationCanGoBack',
|
|
'resetNavigationCanGoBack',
|
|
'isShellMountedRef',
|
|
'injectHostBridgeMessage',
|
|
'injectHostBridgeEvent',
|
|
'injectLifecycleEvent',
|
|
'injectNetworkStatusEvent',
|
|
'logMobileHostEventFailure',
|
|
'logMobileHostBridgeMessageFailure',
|
|
'try {',
|
|
'if (!isShellMountedRef.current)',
|
|
'logMobileHostEventFailure(event, error)',
|
|
'injectHostBridgeMessage(response, logMobileHostBridgeMessageFailure)',
|
|
"console.warn('mobile HostBridge message injection failed')",
|
|
"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',
|
|
'logMobileShellDownloadBlocked',
|
|
"console.warn('mobile shell blocked WebView file download')",
|
|
'SafeAreaProvider',
|
|
'SafeAreaView',
|
|
'MOBILE_SHELL_SAFE_AREA_EDGES',
|
|
'resolveMobileShellBaseWebUrl',
|
|
'originWhitelist={[allowedWebOrigin]}',
|
|
'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',
|
|
'logMobileShellDownloadBlocked(event)',
|
|
'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');
|
|
}
|
|
|
|
if (shellAppSource.includes("console.warn('mobile WebView process failed', {")) {
|
|
throw new Error('mobile shell WebView process diagnostics must not log detail objects');
|
|
}
|
|
|
|
if (!shellAppTestSource.includes('blocked WebView file downloads are logged for host diagnostics')) {
|
|
throw new Error('mobile shell tests must cover blocked WebView file download diagnostics');
|
|
}
|
|
|
|
if (!shellAppTestSource.includes('WebView origin whitelist is limited to the resolved H5 origin')) {
|
|
throw new Error('mobile shell tests must cover WebView origin whitelist boundaries');
|
|
}
|
|
|
|
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',
|
|
'const injectHostBridgeMessage = useCallback(',
|
|
'buildHostBridgeMessageScript(message)',
|
|
'onError(error)',
|
|
'(event: HostBridgeEventName, payload: unknown)',
|
|
'injectHostBridgeMessage(',
|
|
'bridge: HOST_BRIDGE_PROTOCOL',
|
|
'version: HOST_BRIDGE_VERSION',
|
|
'event',
|
|
'payload',
|
|
'(error) => logMobileHostEventFailure(event, error)',
|
|
'injectHostBridgeMessage(response, logMobileHostBridgeMessageFailure)',
|
|
]) {
|
|
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',
|
|
'sanitizeLoadFailureUrl',
|
|
'shouldOpenInMobileShellWebView',
|
|
"input.type === 'http'",
|
|
"input.type === 'process'",
|
|
"title: '页面已停止'",
|
|
"title: '网络不可用'",
|
|
"retryLabel: '重试'",
|
|
"url.pathname !== '/favicon.ico'",
|
|
]) {
|
|
if (!loadFailureSource.includes(snippet)) {
|
|
throw new Error(`mobile shell load failure policy missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
loadFailureSource.includes('new URL(input.url, allowedOrigin).toString()') ||
|
|
loadFailureSource.includes('description ??') ||
|
|
loadFailureSource.includes('normalizeDescription')
|
|
) {
|
|
throw new Error(
|
|
'mobile shell load failure panel must not expose full URLs or native descriptions',
|
|
);
|
|
}
|
|
|
|
for (const snippet of [
|
|
"describe('loadFailure'",
|
|
"test('归一化同源 HTTP 加载失败'",
|
|
"test('归一化同源原生加载失败并隐藏系统错误描述'",
|
|
"test('忽略外域和非页面加载失败'",
|
|
"test('只展示当前主页面失败'",
|
|
"test('连续 WebView 进程恢复失败时展示同源页面兜底'",
|
|
"type: 'process'",
|
|
"title: '页面已停止'",
|
|
"detail: '服务器暂时没有返回可用页面'",
|
|
"detail: '当前页面没有加载成功'",
|
|
"detail: '当前页面连续恢复失败'",
|
|
"url: 'https://example.com/'",
|
|
"url: 'about:blank'",
|
|
"url: 'javascript:alert(1)'",
|
|
"url: '/favicon.ico'",
|
|
"url: 'https://www.genarrative.world/assets/main.js'",
|
|
"https://www.genarrative.world/creation/puzzle?sessionId=private#recover",
|
|
"url: 'https://www.genarrative.world/works/detail'",
|
|
]) {
|
|
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')",
|
|
]) {
|
|
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)',
|
|
'type ShareOpenPayload',
|
|
'let currentShareTarget: ShareOpenPayload | null = null;',
|
|
'currentShareTarget = normalizedTarget.payload;',
|
|
'setMobileHostBridgeShareTarget',
|
|
'logMobileShareFailure',
|
|
'mobile share failed for',
|
|
'request: HostBridgeRequest',
|
|
"throw invalidRequest('target is required')",
|
|
'const normalizedTarget = normalizeHostBridgeShareOpenPayload(target);',
|
|
"'share target is invalid'",
|
|
"logMobileShareFailure('open.share', error)",
|
|
"message: 'share unavailable'",
|
|
'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',
|
|
'stores only the normalized cached share payload',
|
|
'keeps the previous cached target when a new target is missing or invalid',
|
|
'maps native share sheet failures to stable host errors',
|
|
"vi.spyOn(console, 'warn')",
|
|
'mobile share failed for open.share',
|
|
'does not fall back to cached target when explicit payload is unsafe',
|
|
'rejects empty share requests before opening native share sheet',
|
|
"message: 'share unavailable'",
|
|
'https://www.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://www.genarrative.world'")) {
|
|
throw new Error('mobile shell share URL policy must reuse the shared web origin');
|
|
}
|
|
|
|
for (const snippet of [
|
|
'buildMobileShellUrl(',
|
|
'MobileShellBaseWebUrlOptions',
|
|
'allowLocalDevelopment?: boolean',
|
|
'Boolean(options.allowLocalDevelopment)',
|
|
'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 [
|
|
'附加宿主上下文前会清理旧移动壳 query',
|
|
'clientRuntime=browser&hostShell=old_shell&hostCapabilities=old',
|
|
"expect(builtUrl.match(/clientRuntime=/g)).toHaveLength(1)",
|
|
"expect(builtUrl.match(/hostShell=/g)).toHaveLength(1)",
|
|
"expect(builtUrl.match(/hostCapabilities=/g)).toHaveLength(1)",
|
|
]) {
|
|
if (!urlTestSource.includes(snippet)) {
|
|
throw new Error(`mobile shell URL tests must cover stale host-context cleanup: ${snippet}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
!shellAppSource.includes('allowLocalDevelopment: __DEV__') ||
|
|
!shellAppSource.includes('baseWebUrlOptions') ||
|
|
!deepLinkSource.includes('baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}') ||
|
|
!deepLinkSource.includes('resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,')
|
|
) {
|
|
throw new Error('mobile shell must only allow local H5 URLs through explicit development mode');
|
|
}
|
|
|
|
for (const snippet of [
|
|
'移动壳基准 URL 默认只接受生产主站',
|
|
"expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe(",
|
|
'移动壳基准 URL 只在开发模式接受本机入口',
|
|
'移动壳 URL 构建默认不会接受本机入口',
|
|
'移动壳 URL 构建只在显式开发模式接受本机入口',
|
|
'基准 H5 URL 只在显式开发模式保留本机 deep link 入口',
|
|
'allowLocalDevelopment: true',
|
|
'production shell ignores local H5 URL env before opening WebView',
|
|
'development shell allows explicit local H5 URL env',
|
|
]) {
|
|
if (
|
|
!urlTestSource.includes(snippet) &&
|
|
!deepLinkTestSource.includes(snippet) &&
|
|
!shellAppTestSource.includes(snippet)
|
|
) {
|
|
throw new Error(`mobile shell local H5 URL tests must include ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'MobileShellDeepLinkResolution',
|
|
"status: 'default'",
|
|
"status: 'mapped'",
|
|
"status: 'rejected'",
|
|
'resolveMobileShellUrlFromDeepLink',
|
|
'resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,',
|
|
'resolveTargetPath(rawUrl, webOrigin)',
|
|
'buildMobileShellUrl(\n new URL(targetPath, webOrigin).toString(),\n options,\n baseWebUrlOptions,',
|
|
]) {
|
|
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)',
|
|
'return false;',
|
|
'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)',
|
|
'new URL(rawUrl, allowedOrigin).toString()',
|
|
]) {
|
|
if (!navigationSource.includes(snippet)) {
|
|
throw new Error(`mobile shell native-page navigation policy missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
!navigationTestSource.includes('WebView 外链原生探测或打开失败时抛给壳层记录') ||
|
|
!navigationTestSource.includes('native canOpenURL failed') ||
|
|
!navigationTestSource.includes('native openURL failed') ||
|
|
!navigationTestSource.includes(").rejects.toThrow('native canOpenURL failed')") ||
|
|
!navigationTestSource.includes(").rejects.toThrow('native openURL failed')")
|
|
) {
|
|
throw new Error('mobile shell navigation tests must cover native external open failures');
|
|
}
|
|
|
|
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')",
|
|
"message: 'notification permission unavailable'",
|
|
"message: 'notification delivery unavailable'",
|
|
'logMobileNotificationFailure',
|
|
'mobile notification failed for',
|
|
"logMobileNotificationFailure('permission.current', error)",
|
|
"logMobileNotificationFailure('permission.request', error)",
|
|
"logMobileNotificationFailure('delivery.schedule', error)",
|
|
'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 [
|
|
'rejects delivery when current permission lookup is unavailable',
|
|
'rejects delivery when permission request is unavailable',
|
|
'maps Android channel setup failures to stable delivery errors',
|
|
'maps native notification schedule failures to stable delivery errors',
|
|
"message: 'notification permission unavailable'",
|
|
"message: 'notification delivery unavailable'",
|
|
'mobile notification failed for permission.current',
|
|
'mobile notification failed for permission.request',
|
|
'mobile notification failed for delivery.schedule',
|
|
'expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled()',
|
|
'expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()',
|
|
]) {
|
|
if (!notificationsTestSource.includes(snippet)) {
|
|
throw new Error(`mobile shell notification tests missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
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',
|
|
'assertMobileFileSharingAvailable',
|
|
'shareMobileFile',
|
|
'logMobileHostBridgeFileFailure',
|
|
'mobile HostBridge file failed for',
|
|
"logMobileHostBridgeFileFailure('sharing.available', error)",
|
|
"logMobileHostBridgeFileFailure('sharing.open', error)",
|
|
"logMobileHostBridgeFileFailure('document_picker.open', error)",
|
|
"logMobileHostBridgeFileFailure('file.read_text', error)",
|
|
"logMobileHostBridgeFileFailure('file.read_base64', error)",
|
|
"logMobileHostBridgeFileFailure('image.library_permission', error)",
|
|
"logMobileHostBridgeFileFailure('image.library_open', error)",
|
|
"logMobileHostBridgeFileFailure('image.camera_permission', error)",
|
|
"logMobileHostBridgeFileFailure('image.camera_open', error)",
|
|
'pickMobileDocumentFile',
|
|
'readMobileTextFile',
|
|
'readMobileBase64File',
|
|
'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(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,',
|
|
]) {
|
|
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 [functionName, mimeCheckSnippet, label] of [
|
|
[
|
|
'exportTextFile',
|
|
'HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)',
|
|
'text',
|
|
],
|
|
[
|
|
'exportImageFile',
|
|
'HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType as HostBridgeImageMimeType)',
|
|
'image',
|
|
],
|
|
[
|
|
'exportAudioFile',
|
|
'HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType as HostBridgeAudioMimeType)',
|
|
'audio',
|
|
],
|
|
]) {
|
|
const functionBody = extractFunctionBody(filesSource, functionName);
|
|
const mimeValidationIndex = functionBody.indexOf(mimeCheckSnippet);
|
|
const sharingAvailabilityIndex = functionBody.indexOf(
|
|
'await assertMobileFileSharingAvailable()',
|
|
);
|
|
if (
|
|
mimeValidationIndex < 0 ||
|
|
sharingAvailabilityIndex < 0 ||
|
|
mimeValidationIndex > sharingAvailabilityIndex
|
|
) {
|
|
throw new Error(
|
|
`mobile shell ${label} export must validate MIME before native sharing checks`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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({',
|
|
'rejects every export before cache writes when system sharing is unavailable',
|
|
'rejects invalid text export MIME before touching native sharing',
|
|
'const exportCases = [',
|
|
'expect(writtenFiles).toHaveLength(0)',
|
|
'importTextFile()',
|
|
'importDocumentFile()',
|
|
'importAudioFile()',
|
|
'importImageFile()',
|
|
'captureImageFile()',
|
|
'exportAudioFile({',
|
|
"code: 'unsupported_capability'",
|
|
"code: 'cancelled'",
|
|
'maps native sharing availability failures to stable export errors',
|
|
'maps native sharing sheet failures to stable export errors',
|
|
'maps native document picker failures to stable import errors',
|
|
'maps native text file read failures to stable import errors',
|
|
'maps native binary file read failures to stable import errors',
|
|
'maps native audio file read failures to stable import errors',
|
|
"message: 'file sharing unavailable'",
|
|
"message: 'file sharing failed'",
|
|
"message: 'text file picker unavailable'",
|
|
"message: 'text file unavailable'",
|
|
"message: 'document file unavailable'",
|
|
"message: 'audio file unavailable'",
|
|
'rejects image import before picker launch when library permission is denied',
|
|
'rejects image import before picker launch when library permission request fails',
|
|
'maps native image library launch failures to stable import errors',
|
|
'rejects image capture before camera launch when camera permission request fails',
|
|
'maps native camera launch failures to stable capture errors',
|
|
'mobile HostBridge file failed for sharing.available',
|
|
'mobile HostBridge file failed for sharing.open',
|
|
'mobile HostBridge file failed for document_picker.open',
|
|
'mobile HostBridge file failed for file.read_text',
|
|
'mobile HostBridge file failed for file.read_base64',
|
|
'mobile HostBridge file failed for image.library_permission',
|
|
'mobile HostBridge file failed for image.library_open',
|
|
'mobile HostBridge file failed for image.camera_permission',
|
|
'mobile HostBridge file failed for image.camera_open',
|
|
"message: 'photo library permission denied'",
|
|
"message: 'photo library permission unavailable'",
|
|
"message: 'photo library unavailable'",
|
|
`expect(imageLibrary${'Mo'}${'ck'}).not.toHaveBeenCalled()`,
|
|
"message: 'camera permission denied'",
|
|
"message: 'camera permission unavailable'",
|
|
"message: 'camera unavailable'",
|
|
`expect(camera${'Mo'}${'ck'}).not.toHaveBeenCalled()`,
|
|
"mediaTypes: ['images']",
|
|
"options: { encoding: 'base64' }",
|
|
]) {
|
|
if (!filesTestSource.includes(snippet)) {
|
|
throw new Error(`mobile shell file action tests missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
if (filesSource.includes('console.warn(`mobile HostBridge file failed for ${label}`, error)')) {
|
|
throw new Error('mobile shell file diagnostics must not log native error objects');
|
|
}
|
|
|
|
if (!filesSource.includes('console.warn(`mobile HostBridge file failed for ${label}`)')) {
|
|
throw new Error('mobile shell file diagnostics must use stable label-only logging');
|
|
}
|
|
|
|
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(') ||
|
|
!hostBridgeNavigationSource.includes('externalUrlPayload.url') ||
|
|
!hostBridgeNavigationSource.includes('logMobileHostBridgeNavigationFailure')
|
|
) {
|
|
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',
|
|
'mobile HostBridge navigation failed for external.open',
|
|
'expect(warnSpy).toHaveBeenCalledTimes(2)',
|
|
'converts native external open exceptions to stable host_error',
|
|
'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('rawText = 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())',
|
|
'logMobileClipboardFailure',
|
|
'mobile clipboard failed for',
|
|
"logMobileClipboardFailure('write.set_string', error)",
|
|
"logMobileClipboardFailure('read.get_string', error)",
|
|
"message: 'clipboard write unavailable'",
|
|
"message: 'clipboard read unavailable'",
|
|
]) {
|
|
if (!clipboardSource.includes(snippet)) {
|
|
throw new Error(`mobile shell clipboard module is missing ${snippet}`);
|
|
}
|
|
}
|
|
if (clipboardSource.includes('console.warn(`mobile clipboard failed for ${label}`, error)')) {
|
|
throw new Error('mobile shell clipboard diagnostics must not log native error objects');
|
|
}
|
|
if (!clipboardSource.includes('console.warn(`mobile clipboard failed for ${label}`)')) {
|
|
throw new Error('mobile shell clipboard diagnostics must use stable label-only logging');
|
|
}
|
|
if (!clipboardTestSource.includes("'猫'.repeat(100000)")) {
|
|
throw new Error('mobile shell clipboard tests must cover Unicode truncation');
|
|
}
|
|
for (const snippet of [
|
|
'maps native clipboard write failures to stable host errors',
|
|
'mobile clipboard failed for write.set_string',
|
|
"message: 'clipboard write unavailable'",
|
|
'maps native clipboard read failures to stable host errors',
|
|
'mobile clipboard failed for read.get_string',
|
|
"message: 'clipboard read unavailable'",
|
|
'rejects unavailable clipboard text without reporting a successful empty value',
|
|
`Clipboard.getStringAsync).${'mo'}${'ck'}ResolvedValue(undefined as never)`,
|
|
"code: 'host_error'",
|
|
"message: 'clipboard text unavailable'",
|
|
]) {
|
|
if (!clipboardTestSource.includes(snippet)) {
|
|
throw new Error(`mobile shell clipboard tests must cover unavailable reads: ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'try {',
|
|
'return ok(request, await getMobileNetworkStatus())',
|
|
'logMobileNetworkFailure',
|
|
'mobile network failed for',
|
|
"logMobileNetworkFailure('status.query', error)",
|
|
'return failure(request, {',
|
|
"code: 'host_error'",
|
|
"message: 'network status unavailable'",
|
|
]) {
|
|
if (!hostBridgeNetworkSource.includes(snippet)) {
|
|
throw new Error(`mobile shell network HostBridge must stabilize native failures: ${snippet}`);
|
|
}
|
|
}
|
|
for (const snippet of [
|
|
'converts native network failures to a stable host_error response',
|
|
"new Error('network query failed')",
|
|
'mobile network failed for status.query',
|
|
"message: 'network status unavailable'",
|
|
]) {
|
|
if (!hostBridgeNetworkTestSource.includes(snippet)) {
|
|
throw new Error(`mobile shell network tests must cover stable native failure responses: ${snippet}`);
|
|
}
|
|
}
|
|
|
|
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('logMobileHapticsFailure') ||
|
|
!hapticsSource.includes('mobile haptics failed for') ||
|
|
!hapticsSource.includes("logMobileHapticsFailure('impact.dispatch', error)") ||
|
|
!hapticsSource.includes("message: 'haptics impact unavailable'") ||
|
|
!hapticsSource.includes('ok(request, true)')
|
|
) {
|
|
throw new Error(
|
|
'mobile shell haptics bridge must normalize impact style with the shared HostBridge boundary',
|
|
);
|
|
}
|
|
|
|
for (const snippet of [
|
|
'reports unavailable native feedback with a stable HostBridge error',
|
|
'mobile haptics failed for impact.dispatch',
|
|
"message: 'haptics impact unavailable'",
|
|
]) {
|
|
if (!hapticsTestSource.includes(snippet)) {
|
|
throw new Error(`mobile shell haptics tests missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
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')",
|
|
]) {
|
|
if (!qrScannerOverlaySource.includes(snippet)) {
|
|
throw new Error(`mobile shell QR scanner overlay missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'describe(\'QrScannerOverlay\'',
|
|
'scanQrCode()',
|
|
'requestCameraPermissionsAsync',
|
|
"message: 'qr scanner unavailable'",
|
|
"message: 'qr scan cancelled'",
|
|
"test('logs and rejects the active scan when camera permission request fails'",
|
|
"test('ignores late camera permission after the scan is cancelled'",
|
|
"'mobile QR scanner permission request failed'",
|
|
"barcodeTypes: ['qr']",
|
|
"type: 'qr'",
|
|
"value: 'https://www.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]?.({',
|
|
'injectJavaScriptError',
|
|
"test('host event injection failures are logged without crashing the shell'",
|
|
"new Error('webview injection failed')",
|
|
"test('logs HostBridge response injection failures without crashing the shell'",
|
|
"new Error('response injection failed')",
|
|
'mobile HostBridge message injection failed',
|
|
"test('drops delayed HostBridge responses after shell unmount'",
|
|
"id: 'network-request-1'",
|
|
'expectInjectedHostBridgeMessageSource',
|
|
"expect(script).toContain('origin: window.location.origin')",
|
|
"expect(script).toContain('source: window')",
|
|
"type: 'genarrative.mobile.historyState'",
|
|
'mobile host event failed for network.statusChanged',
|
|
'external WebView navigation native failures stay outside the WebView',
|
|
"expect(Linking.openURL).toHaveBeenCalledWith(",
|
|
"'mobile shell navigation failed for external_navigation.open'",
|
|
'initial deep link read failures are logged without replacing the current WebView URL',
|
|
"'mobile shell deep link failed for initial_url.read'",
|
|
'runtime deep link rejections are logged and fall back to a safe WebView URL',
|
|
"'mobile shell deep link failed for runtime_url.rejected'",
|
|
'first WebView process failure reloads the current page once',
|
|
'mobile WebView process failed for content_process_terminated',
|
|
'repeated WebView process failures show the load failure panel and retry clears the failure window',
|
|
"expect(screen.getByText('页面已停止')).toBeTruthy()",
|
|
'mobile WebView process failed for render_process_gone',
|
|
]) {
|
|
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 [
|
|
'HOST_BRIDGE_SCANNER_TIMEOUT_MS',
|
|
'scanMobileHostBridgeQrCode(request: HostBridgeRequest)',
|
|
'ok(request, await scanQrCode())',
|
|
'normalizeHostBridgeQrCodeValue',
|
|
"hostBridgeError('timeout', 'qr scan timed out')",
|
|
"hostBridgeError('cancelled', 'qr scan cancelled')",
|
|
"hostBridgeError('host_error', 'qr scanner unavailable')",
|
|
'clearTimeout(pendingQrScan.timeout)',
|
|
]) {
|
|
if (!scannerSource.includes(scannerSnippet)) {
|
|
throw new Error(`mobile shell QR scanner module missing ${scannerSnippet}`);
|
|
}
|
|
}
|
|
for (const scannerTestSnippet of [
|
|
'HOST_BRIDGE_SCANNER_TIMEOUT_MS',
|
|
'subscribeQrScannerState',
|
|
'scanQrCode',
|
|
'scanMobileHostBridgeQrCode',
|
|
'completeQrCodeScan',
|
|
'cancelQrCodeScan',
|
|
'failQrCodeScan',
|
|
'times out and clears the pending scan with the shared scanner timeout',
|
|
'qr scanner already active',
|
|
'qr scanner unavailable',
|
|
'qr scan timed out',
|
|
'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',
|
|
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
|
|
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
|
|
'hostVersion: MOBILE_SHELL_HOST_VERSION',
|
|
'bridgeVersion: HOST_BRIDGE_VERSION',
|
|
'expect(runtime.capabilities).toBe(HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES)',
|
|
'expect(runtime.capabilities).toBe(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES)',
|
|
"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',
|
|
'Notifications.getPermissionsAsync',
|
|
'Notifications.requestPermissionsAsync',
|
|
'Notifications.setBadgeCountAsync(count)',
|
|
'allowBadge: true',
|
|
'app badge permission denied',
|
|
'app badge update unavailable',
|
|
'app badge count is only supported on iOS mobile shell',
|
|
'logMobileBadgeFailure',
|
|
'mobile app badge failed for',
|
|
"logMobileBadgeFailure('permission.current', error)",
|
|
"logMobileBadgeFailure('permission.request', error)",
|
|
"logMobileBadgeFailure('update.set_count', error)",
|
|
"logMobileBadgeFailure(",
|
|
"'update.rejected'",
|
|
]) {
|
|
if (!badgeSource.includes(snippet)) {
|
|
throw new Error(`mobile shell badge module is missing ${snippet}`);
|
|
}
|
|
}
|
|
for (const snippet of [
|
|
"test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported'",
|
|
"test('app.setBadgeCount 在 iOS 角标权限拒绝时不返回成功'",
|
|
"setPlatformOS('android')",
|
|
"request('app.setBadgeCount',",
|
|
"expect(expectFailed(unsupported).error.code).toBe('unsupported_capability')",
|
|
'expect(Notifications.setBadgeCountAsync).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 Expo Notifications',
|
|
'requests iOS badge permission before updating when it is missing',
|
|
'rejects denied iOS badge permission before touching the system badge',
|
|
'maps native badge update false results to a stable HostBridge error',
|
|
'maps native badge update rejections to a stable HostBridge error',
|
|
'maps current badge permission lookup failures to a stable HostBridge error',
|
|
'maps badge permission request failures to a stable HostBridge error',
|
|
'mobile app badge failed for permission.current',
|
|
'mobile app badge failed for permission.request',
|
|
'mobile app badge failed for update.set_count',
|
|
'mobile app badge failed for update.rejected',
|
|
'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',
|
|
'allowBadge: true',
|
|
'app badge permission denied',
|
|
'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',
|
|
`${'mo'}${'ck'}ReturnValue('light')`,
|
|
`${'mo'}${'ck'}ReturnValue('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(',
|
|
'externalUrlPayload.url',
|
|
"} catch (error) {",
|
|
"logMobileHostBridgeNavigationFailure('external.open', error)",
|
|
'opened = false;',
|
|
'(request.payload as OpenExternalUrlPayload | undefined)?.url',
|
|
'normalizeHostBridgeExternalUrlPayload',
|
|
"message: 'external URL cannot be opened'",
|
|
'openMobileHostBridgeNativePage',
|
|
'(request.payload as NavigateNativePagePayload | undefined)?.url',
|
|
'resolveMobileShellWebViewUrl',
|
|
'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,',
|
|
'reloadMobileHostBridgeWebView',
|
|
'ok(request, true)',
|
|
]) {
|
|
if (!hostBridgeNavigationSource.includes(snippet)) {
|
|
throw new Error(`mobile shell navigation module is missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
!hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(') ||
|
|
!hostBridgeNavigationSource.includes('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',
|
|
'return ok(request, await getMobileNetworkStatus())',
|
|
'return failure(request, {',
|
|
'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',
|
|
'converts native network failures to a stable host_error response',
|
|
'connectionType: \'cellular\'',
|
|
'connectionType: \'none\'',
|
|
'network query failed',
|
|
'network status unavailable',
|
|
]) {
|
|
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',
|
|
);
|
|
}
|