import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; const appConfigPath = new URL('../app.json', import.meta.url); const packagePath = new URL('../package.json', import.meta.url); const shellRoot = new URL('../', import.meta.url); const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo; const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const result = spawnSync( npmCommand, ['exec', 'expo', 'config', '--', '--type', 'public', '--json'], { cwd: shellRoot, encoding: 'utf8', }, ); if (result.error) { throw new Error(`failed to start Expo config smoke: ${result.error.message}`); } if (result.signal) { throw new Error(`Expo config smoke was terminated by signal ${result.signal}`); } if ((result.status ?? 0) !== 0) { process.stdout.write(result.stdout ?? ''); process.stderr.write(result.stderr ?? ''); process.exit(result.status ?? 1); } function parseExpoConfigOutput(output) { const trimmed = output.trim(); if (trimmed.startsWith('{')) { return JSON.parse(trimmed); } const jsonStart = output.indexOf('{'); if (jsonStart === -1) { throw new Error('Expo config smoke did not print JSON output'); } return JSON.parse(output.slice(jsonStart)); } const expoConfig = parseExpoConfigOutput(result.stdout ?? ''); function assertEqual(actual, expected, label) { if (actual !== expected) { throw new Error( `Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`, ); } } function assertIncludes(list, expected, label) { if (!Array.isArray(list) || !list.includes(expected)) { throw new Error(`Expo config ${label} missing ${expected}`); } } function assertSameList(actual, expected, label) { if ( !Array.isArray(actual) || actual.length !== expected.length || actual.some((value, index) => value !== expected[index]) ) { throw new Error( `Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`, ); } } function findPlugin(name) { return expoConfig.plugins?.find((plugin) => Array.isArray(plugin) ? plugin[0] === name : plugin === name, ); } assertEqual(expoConfig.name, 'Genarrative', 'name'); assertEqual(expoConfig.slug, 'genarrative-mobile-shell', 'slug'); assertEqual(expoConfig.scheme, 'genarrative', 'scheme'); assertEqual(expoConfig.version, packageConfig.version, 'version'); assertEqual(expoConfig.version, appConfig.version, 'app version'); assertEqual(expoConfig.orientation, 'default', 'orientation'); assertEqual(expoConfig.userInterfaceStyle, 'automatic', 'userInterfaceStyle'); assertSameList(expoConfig.assetBundlePatterns, ['**/*'], 'asset bundle patterns'); assertEqual(expoConfig.icon, './assets/icon.png', 'icon'); assertEqual(expoConfig.splash?.image, './assets/icon.png', 'splash image'); assertEqual(expoConfig.splash?.resizeMode, 'contain', 'splash resize mode'); assertEqual(expoConfig.splash?.backgroundColor, '#fffdf9', 'splash background'); assertEqual(expoConfig.updates?.enabled, false, 'OTA updates enabled flag'); if (Object.keys(expoConfig.updates ?? {}).some((key) => key !== 'enabled')) { throw new Error('Expo config OTA update metadata must stay empty'); } if ('runtimeVersion' in expoConfig) { throw new Error('Expo config runtimeVersion must not be set without a real OTA release channel'); } if ('releaseChannel' in expoConfig || 'channel' in expoConfig) { throw new Error('Expo config release channel must not be set without a real release process'); } assertEqual( expoConfig.extra?.genarrativeHostBridgeVersion, 1, 'HostBridge version', ); assertEqual( expoConfig.ios?.bundleIdentifier, 'world.genarrative.mobile', 'iOS bundle identifier', ); assertEqual(expoConfig.ios?.buildNumber, '1', 'iOS build number'); assertSameList( expoConfig.ios?.associatedDomains, ['applinks:app.genarrative.world'], 'iOS associated domains', ); assertEqual( expoConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption, false, 'iOS encryption export flag', ); assertEqual( expoConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads, false, 'iOS ATS arbitrary loads', ); assertEqual( expoConfig.android?.package, 'world.genarrative.mobile', 'Android package', ); assertEqual(expoConfig.android?.versionCode, 1, 'Android versionCode'); assertEqual( expoConfig.android?.usesCleartextTraffic, false, 'Android cleartext traffic', ); assertEqual(expoConfig.android?.allowBackup, false, 'Android backup flag'); assertEqual( expoConfig.android?.softwareKeyboardLayoutMode, 'resize', 'Android software keyboard layout mode', ); assertSameList(expoConfig.android?.permissions ?? [], [], 'Android explicit permissions'); assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.MANAGE_EXTERNAL_STORAGE', 'Android external download blocked permissions', ); assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.READ_EXTERNAL_STORAGE', 'Android external download blocked permissions', ); assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.RECORD_AUDIO', 'Android blocked permissions', ); assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.REQUEST_INSTALL_PACKAGES', 'Android external install blocked permissions', ); assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.WRITE_EXTERNAL_STORAGE', 'Android external download blocked permissions', ); for (const permission of [ 'android.permission.RECEIVE_BOOT_COMPLETED', 'android.permission.SCHEDULE_EXACT_ALARM', 'android.permission.USE_EXACT_ALARM', ]) { assertIncludes( expoConfig.android?.blockedPermissions, permission, 'Android background notification blocked permissions', ); } assertEqual( expoConfig.android?.adaptiveIcon?.foregroundImage, './assets/icon.png', 'Android adaptive icon foreground', ); assertEqual( expoConfig.android?.adaptiveIcon?.backgroundColor, '#fffdf9', 'Android adaptive icon background', ); const appLinkFilters = expoConfig.android?.intentFilters ?? []; if (appLinkFilters.length !== 1) { throw new Error('Expo config Android app link filter must be the only intent filter'); } const [appLinkFilter] = appLinkFilters; assertEqual(appLinkFilter.action, 'VIEW', 'Android app link action'); assertEqual(appLinkFilter.autoVerify, true, 'Android app link autoVerify'); assertSameList( appLinkFilter.category, ['BROWSABLE', 'DEFAULT'], 'Android app link categories', ); if ( !Array.isArray(appLinkFilter.data) || appLinkFilter.data.length !== 1 || appLinkFilter.data[0]?.scheme !== 'https' || appLinkFilter.data[0]?.host !== 'app.genarrative.world' || Object.keys(appLinkFilter.data[0] ?? {}).some( (key) => key !== 'scheme' && key !== 'host', ) ) { throw new Error('Expo config Android app link data must only bind https://app.genarrative.world'); } const imagePickerPlugin = findPlugin('expo-image-picker'); if (!Array.isArray(imagePickerPlugin)) { throw new Error('Expo config image picker plugin is missing options'); } assertEqual( typeof imagePickerPlugin[1]?.photosPermission, 'string', 'image picker photo permission text type', ); assertEqual( typeof imagePickerPlugin[1]?.cameraPermission, 'string', 'image picker camera permission text type', ); assertEqual( imagePickerPlugin[1]?.microphonePermission, false, 'image picker microphone permission', ); const notificationsPlugin = findPlugin('expo-notifications'); if (!Array.isArray(notificationsPlugin)) { throw new Error('Expo config notifications plugin is missing options'); } assertEqual( notificationsPlugin[1]?.enableBackgroundRemoteNotifications, false, 'background remote notifications', ); if (findPlugin('expo-updates')) { throw new Error('Expo config must not include expo-updates without a real release channel'); } console.log('[mobile-shell:expo-config] OK');