diff --git a/packages/build-tools/src/utils/__tests__/appConfig.test.ts b/packages/build-tools/src/utils/__tests__/appConfig.test.ts index d04a1de3ec..bf508da5bf 100644 --- a/packages/build-tools/src/utils/__tests__/appConfig.test.ts +++ b/packages/build-tools/src/utils/__tests__/appConfig.test.ts @@ -1,5 +1,7 @@ import { bunyan } from '@expo/logger'; +import spawnAsync from '@expo/turtle-spawn'; +import { Datadog } from '../../datadog'; import { readAppConfig } from '../appConfig'; jest.mock('@expo/env', () => ({ @@ -13,6 +15,13 @@ jest.mock('@expo/config', () => ({ })); jest.mock('../expoCli'); +jest.mock('@expo/turtle-spawn'); + +jest.mock('../../datadog', () => ({ + Datadog: { + log: jest.fn(), + }, +})); const { expoCommandAsync } = jest.requireMock('../expoCli') as { expoCommandAsync: jest.Mock; @@ -26,6 +35,9 @@ const { load: loadEnv } = jest.requireMock('@expo/env') as { load: jest.Mock; }; +const datadogLogMock = jest.mocked(Datadog.log); +const spawnAsyncMock = jest.mocked(spawnAsync); + const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() } as unknown as bunyan; const baseParams = { @@ -34,7 +46,29 @@ const baseParams = { logger, }; +function getCloudEnv(buildId: string) { + return { + NODE_ENV: 'development', + EAS_BUILD_RUNNER: 'eas-build', + EAS_BUILD_ID: buildId, + }; +} + +function getEnvWorkerResult(env: Record = {}) { + return { + stdout: JSON.stringify(env), + } as never; +} + describe(readAppConfig, () => { + beforeEach(() => { + jest.resetAllMocks(); + loadEnv.mockReturnValue({ FROM_DOTENV: 'true' }); + getConfig.mockReturnValue({ + exp: { name: 'fallback-app', slug: 'fallback-app' }, + }); + }); + it('returns config from expo CLI when it succeeds', async () => { const config = { exp: { name: 'test-app', slug: 'test-app' } }; expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) }); @@ -105,4 +139,193 @@ describe(readAppConfig, () => { expect(loadEnv).not.toHaveBeenCalled(); }); + + it("doesn't compare app config for local builds", async () => { + const config = { exp: { name: 'test-app', slug: 'test-app' } }; + expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) }); + + await readAppConfig({ + ...baseParams, + env: { + NODE_ENV: 'development', + EAS_BUILD_RUNNER: 'local-build-plugin', + EAS_BUILD_ID: 'local-build', + }, + }); + + expect(expoCommandAsync).toHaveBeenCalledTimes(1); + expect(spawnAsyncMock).not.toHaveBeenCalled(); + expect(datadogLogMock).not.toHaveBeenCalled(); + }); + + it('logs a match when app config is the same in production mode', async () => { + const config = { exp: { name: 'test-app', slug: 'test-app' } }; + const env = getCloudEnv('matching-build'); + expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) }); + + const result = await readAppConfig({ ...baseParams, env }); + + expect(result).toEqual(config); + expect(expoCommandAsync).toHaveBeenNthCalledWith( + 2, + '/project', + ['config', '--json', '--full', '--type', 'public'], + { + env: { + NODE_ENV: 'production', + EAS_BUILD_RUNNER: 'eas-build', + EAS_BUILD_ID: 'matching-build', + __EXPO_CONFIG_MODE: 'production', + }, + } + ); + expect(datadogLogMock).toHaveBeenCalledWith('App config production mode comparison match', { + event: 'app_config_production_mode_comparison', + status: 'match', + current_source: 'expo-cli', + production_source: 'expo-cli', + }); + }); + + it('keeps the current app config and logs a production mode mismatch', async () => { + const currentConfig = { exp: { name: 'current-app', slug: 'test-app' } }; + const productionConfig = { exp: { name: 'production-app', slug: 'test-app' } }; + const env = { + ...getCloudEnv('mismatching-build'), + FROM_BUILD: 'true', + }; + loadEnv.mockReturnValue({ FROM_CURRENT_DOTENV: 'true' }); + spawnAsyncMock.mockResolvedValue( + getEnvWorkerResult({ + FROM_BUILD: 'from-dotenv', + FROM_PRODUCTION_DOTENV: 'true', + }) + ); + expoCommandAsync + .mockResolvedValueOnce({ stdout: JSON.stringify(currentConfig) }) + .mockResolvedValueOnce({ stdout: JSON.stringify(productionConfig) }); + + const result = await readAppConfig({ + ...baseParams, + env, + sdkVersion: '49.0.0', + }); + + expect(result).toEqual(currentConfig); + expect(env).toEqual({ + NODE_ENV: 'development', + EAS_BUILD_RUNNER: 'eas-build', + EAS_BUILD_ID: 'mismatching-build', + FROM_BUILD: 'true', + }); + expect(spawnAsyncMock).toHaveBeenCalledWith( + process.execPath, + [expect.stringMatching(/appConfigEnvWorker\.js$/), '/project'], + { + cwd: '/project', + env: { + NODE_ENV: 'production', + EAS_BUILD_RUNNER: 'eas-build', + EAS_BUILD_ID: 'mismatching-build', + FROM_BUILD: 'true', + __EXPO_CONFIG_MODE: 'production', + }, + stdio: 'pipe', + } + ); + expect(expoCommandAsync).toHaveBeenNthCalledWith( + 2, + '/project', + ['config', '--json', '--full', '--type', 'public'], + { + env: { + NODE_ENV: 'production', + EAS_BUILD_RUNNER: 'eas-build', + EAS_BUILD_ID: 'mismatching-build', + FROM_BUILD: 'true', + FROM_PRODUCTION_DOTENV: 'true', + __EXPO_CONFIG_MODE: 'production', + }, + } + ); + expect(datadogLogMock).toHaveBeenCalledWith('App config production mode comparison mismatch', { + event: 'app_config_production_mode_comparison', + status: 'mismatch', + current_source: 'expo-cli', + production_source: 'expo-cli', + }); + expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('current-app'); + expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('production-app'); + }); + + it("doesn't compare app config when the current read uses bundled @expo/config", async () => { + const config = { exp: { name: 'test-app', slug: 'test-app' } }; + expoCommandAsync.mockRejectedValue(new Error('expo not found')); + getConfig.mockReturnValue(config); + + const result = await readAppConfig({ + ...baseParams, + env: getCloudEnv('bundled-config-build'), + }); + + expect(result).toEqual(config); + expect(spawnAsyncMock).not.toHaveBeenCalled(); + expect(datadogLogMock).toHaveBeenCalledWith('App config production mode comparison error', { + event: 'app_config_production_mode_comparison', + status: 'error', + current_source: 'bundled-config', + reason: 'current_read_used_fallback', + }); + }); + + it('keeps the current app config when the production read fails', async () => { + const currentConfig = { exp: { name: 'current-app', slug: 'test-app' } }; + expoCommandAsync + .mockResolvedValueOnce({ stdout: JSON.stringify(currentConfig) }) + .mockRejectedValueOnce(new Error('production config failed')); + + const result = await readAppConfig({ + ...baseParams, + env: getCloudEnv('failed-production-build'), + }); + + expect(result).toEqual(currentConfig); + expect(logger.warn).not.toHaveBeenCalled(); + expect(datadogLogMock).toHaveBeenCalledWith('App config production mode comparison error', { + event: 'app_config_production_mode_comparison', + status: 'error', + current_source: 'expo-cli', + reason: 'production_read_failed', + }); + }); + + it("doesn't compare app config more than once for the same build", async () => { + const config = { exp: { name: 'test-app', slug: 'test-app' } }; + const params = { + ...baseParams, + env: getCloudEnv('repeated-build'), + }; + expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) }); + + await readAppConfig(params); + await readAppConfig(params); + + expect(expoCommandAsync).toHaveBeenCalledTimes(3); + expect(datadogLogMock).toHaveBeenCalledTimes(1); + }); + + it("doesn't fail the build when Datadog logging fails", async () => { + const config = { exp: { name: 'test-app', slug: 'test-app' } }; + expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) }); + datadogLogMock.mockImplementation(() => { + throw new Error('Datadog failed'); + }); + + await expect( + readAppConfig({ + ...baseParams, + env: getCloudEnv('datadog-failure-build'), + }) + ).resolves.toEqual(config); + }); }); diff --git a/packages/build-tools/src/utils/__tests__/appConfigEnvWorker.test.ts b/packages/build-tools/src/utils/__tests__/appConfigEnvWorker.test.ts new file mode 100644 index 0000000000..8c9850d408 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/appConfigEnvWorker.test.ts @@ -0,0 +1,33 @@ +import { get } from '@expo/env'; + +import { runAppConfigEnvWorker } from '../appConfigEnvWorker'; + +jest.mock('@expo/env', () => ({ + get: jest.fn(), +})); + +const getEnvMock = jest.mocked(get); + +describe(runAppConfigEnvWorker, () => { + it('writes the production dotenv vars as JSON', () => { + const stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + getEnvMock.mockReturnValue({ + env: { FROM_PRODUCTION_DOTENV: 'true' }, + files: ['/project/.env.production'], + }); + + try { + runAppConfigEnvWorker('/project'); + + expect(getEnvMock).toHaveBeenCalledWith('/project', { + force: true, + silent: true, + }); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + JSON.stringify({ FROM_PRODUCTION_DOTENV: 'true' }) + ); + } finally { + stdoutWriteSpy.mockRestore(); + } + }); +}); diff --git a/packages/build-tools/src/utils/appConfig.ts b/packages/build-tools/src/utils/appConfig.ts index fd256315ce..84efcec666 100644 --- a/packages/build-tools/src/utils/appConfig.ts +++ b/packages/build-tools/src/utils/appConfig.ts @@ -2,7 +2,12 @@ import { ProjectConfig, getConfig } from '@expo/config'; import { Env } from '@expo/eas-build-job'; import { load } from '@expo/env'; import { LoggerLevel, bunyan } from '@expo/logger'; +import spawnAsync from '@expo/turtle-spawn'; +import isEqual from 'lodash/isEqual'; +import path from 'path'; import semver from 'semver'; + +import { Datadog } from '../datadog'; import { expoCommandAsync } from './expoCli'; interface ReadAppConfigParams { @@ -12,7 +17,31 @@ interface ReadAppConfigParams { sdkVersion?: string; } +type AppConfigSource = 'expo-cli' | 'bundled-config'; + +interface AppConfigReadResult { + appConfig: ProjectConfig; + source: AppConfigSource; +} + +interface AppConfigComparisonDetails { + productionSource?: AppConfigSource; + reason?: 'current_read_used_fallback' | 'production_read_failed'; +} + +const comparedBuildIds = new Set(); + export async function readAppConfig(params: ReadAppConfigParams): Promise { + const currentResult = await readAppConfigWithSource(params); + + if (markBuildForAppConfigComparison(params.env)) { + await compareAppConfigWithProductionModeAsync(params, currentResult); + } + + return currentResult.appConfig; +} + +async function readAppConfigWithSource(params: ReadAppConfigParams): Promise { const shouldLoadEnvVarsFromDotenvFile = params.sdkVersion && semver.satisfies(params.sdkVersion, '>=49'); if (shouldLoadEnvVarsFromDotenvFile) { @@ -23,7 +52,10 @@ export async function readAppConfig(params: ReadAppConfigParams): Promise { + if (currentResult.source !== 'expo-cli') { + logAppConfigComparison('error', currentResult.source, { + reason: 'current_read_used_fallback', + }); + return; + } + + try { + const productionAppConfig = await readAppConfigWithProductionModeAsync(params); + const status = isEqual(currentResult.appConfig.exp, productionAppConfig.exp) + ? 'match' + : 'mismatch'; + logAppConfigComparison(status, currentResult.source, { + productionSource: 'expo-cli', + }); + } catch { + logAppConfigComparison('error', currentResult.source, { + reason: 'production_read_failed', + }); + } +} + +function logAppConfigComparison( + status: 'match' | 'mismatch' | 'error', + currentSource: AppConfigSource, + { productionSource, reason }: AppConfigComparisonDetails = {} +): void { + try { + Datadog.log(`App config production mode comparison ${status}`, { + event: 'app_config_production_mode_comparison', + status, + current_source: currentSource, + ...(productionSource ? { production_source: productionSource } : {}), + ...(reason ? { reason } : {}), + }); + } catch { + // Keep Datadog errors from failing the build. + } +} + +async function readAppConfigWithProductionModeAsync( + params: ReadAppConfigParams +): Promise { + let env = getProductionAppConfigEnv(params.env); + const shouldLoadEnvVarsFromDotenvFile = + params.sdkVersion && semver.satisfies(params.sdkVersion, '>=49'); + if (shouldLoadEnvVarsFromDotenvFile) { + const dotenvEnv = await readProductionDotenvEnvAsync(params.projectDir, env); + env = { ...dotenvEnv, ...env }; + } + + return getAppConfigFromExpo({ ...params, env }); +} + +async function readProductionDotenvEnvAsync(projectDir: string, env: Env): Promise { + const result = await spawnAsync( + process.execPath, + [path.join(__dirname, 'appConfigEnvWorker.js'), projectDir], + { + cwd: projectDir, + env, + stdio: 'pipe', + } + ); + + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + throw new Error('Failed to parse the production dotenv worker output.'); + } + + if ( + !parsed || + Array.isArray(parsed) || + typeof parsed !== 'object' || + Object.values(parsed).some(value => typeof value !== 'string') + ) { + throw new Error('The production dotenv worker returned invalid env vars.'); + } + + return parsed as Env; +} + +function getProductionAppConfigEnv(env: Env): Env { + return { + ...env, + NODE_ENV: 'production', + __EXPO_CONFIG_MODE: 'production', + }; } async function getAppConfigFromExpo({ diff --git a/packages/build-tools/src/utils/appConfigEnvWorker.ts b/packages/build-tools/src/utils/appConfigEnvWorker.ts new file mode 100644 index 0000000000..aa9f94530f --- /dev/null +++ b/packages/build-tools/src/utils/appConfigEnvWorker.ts @@ -0,0 +1,21 @@ +import { get } from '@expo/env'; + +export function runAppConfigEnvWorker(projectDir: string): void { + const { env } = get(projectDir, { force: true, silent: true }); + process.stdout.write(JSON.stringify(env)); +} + +if (require.main === module) { + const projectDir = process.argv[2]; + if (!projectDir) { + process.stderr.write('The project directory is required to load production dotenv files.\n'); + process.exitCode = 1; + } else { + try { + runAppConfigEnvWorker(projectDir); + } catch { + process.stderr.write('Failed to load the production dotenv files.\n'); + process.exitCode = 1; + } + } +}