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 ;')) { 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('')) { 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', ); }