diff --git a/CHANGELOG.md b/CHANGELOG.md index e07b8953e3..2fcfeeaaba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa)) - [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys)) - [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo)) +- [build-tools] Add the `eas/install_mitmproxy` step and start serve-sim with `--network-capture` when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index 6ba0d71e89..cc6f539950 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -19,6 +19,7 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration'; import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials'; import { createInstallMaestroBuildFunction } from './functions/installMaestro'; +import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy'; import { createInstallBuildFunction } from './functions/installBuild'; import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules'; import { createInstallPodsBuildFunction } from './functions/installPods'; @@ -110,6 +111,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { createStartServeSimMetricsBuildFunction(), createCollectServeSimMetricsBuildFunction(ctx), createInstallMaestroBuildFunction(), + createInstallMitmproxyBuildFunction(), createInstallPodsBuildFunction(), createSendSlackMessageFunction(), diff --git a/packages/build-tools/src/steps/functions/__tests__/installMitmproxy.test.ts b/packages/build-tools/src/steps/functions/__tests__/installMitmproxy.test.ts new file mode 100644 index 0000000000..48c26ebcfc --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/installMitmproxy.test.ts @@ -0,0 +1,124 @@ +import { BuildRuntimePlatform } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { decompressTarAsync } from '../../../utils/files'; +import { createInstallMitmproxyBuildFunction } from '../installMitmproxy'; + +jest.mock('@expo/turtle-spawn', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../../utils/files', () => ({ + decompressTarAsync: jest.fn(), +})); + +const mockedSpawn = jest.mocked(spawn); +const mockedDecompressTarAsync = jest.mocked(decompressTarAsync); + +function spawnResolved(): ReturnType { + return Promise.resolve({}) as unknown as ReturnType; +} + +function spawnRejected(): ReturnType { + return Promise.reject(new Error('not found')) as unknown as ReturnType; +} + +describe('createInstallMitmproxyBuildFunction', () => { + let homeDirectory: string; + + beforeEach(async () => { + jest.clearAllMocks(); + mockedDecompressTarAsync.mockResolvedValue(undefined); + homeDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-mitmproxy-test')); + }); + + afterEach(async () => { + await fs.promises.rm(homeDirectory, { force: true, recursive: true }); + }); + + function createStep( + env: Record + ): ReturnType< + ReturnType['createBuildStepFromFunctionCall'] + > { + const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }); + globalCtx.updateEnv({ ...globalCtx.env, HOME: homeDirectory, ...env }); + return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: {}, + }); + } + + it('does not download when mitmdump is already on PATH', async () => { + mockedSpawn.mockReturnValueOnce(spawnResolved()); + + await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync(); + + expect(mockedSpawn).toHaveBeenCalledTimes(1); + expect(mockedDecompressTarAsync).not.toHaveBeenCalled(); + }); + + it('does not touch the machine outside EAS Build VMs', async () => { + mockedSpawn.mockReturnValueOnce(spawnRejected()); + + await createStep({}).executeAsync(); + + expect(mockedSpawn).toHaveBeenCalledTimes(1); + expect(mockedDecompressTarAsync).not.toHaveBeenCalled(); + }); + + it('downloads the pinned artifact from the turtle-v2 bucket and extracts it', async () => { + mockedSpawn + .mockReturnValueOnce(spawnRejected()) + .mockReturnValueOnce(spawnResolved()) + .mockReturnValueOnce(spawnResolved()); + + await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync(); + + expect(mockedSpawn).toHaveBeenNthCalledWith( + 2, + 'curl', + [ + '--fail', + '--location', + '--output', + expect.stringContaining('mitmproxy.tar.gz'), + 'https://storage.googleapis.com/turtle-v2/mitmproxy-12.2.3-macos-arm64.tar.gz', + ], + expect.anything() + ); + expect(mockedDecompressTarAsync).toHaveBeenCalledWith({ + archivePath: expect.stringContaining('mitmproxy.tar.gz'), + destinationDirectory: path.join(homeDirectory, '.eas-mitmproxy'), + }); + }); + + it('puts the extracted binaries on PATH for later steps', async () => { + mockedSpawn + .mockReturnValueOnce(spawnRejected()) + .mockReturnValueOnce(spawnResolved()) + .mockReturnValueOnce(spawnResolved()); + + const step = createStep({ EAS_BUILD_RUNNER: 'eas-build' }); + await step.executeAsync(); + + expect(step.ctx.global.env.PATH).toContain( + path.join(homeDirectory, '.eas-mitmproxy', 'mitmproxy.app', 'Contents', 'MacOS') + ); + }); + + it('throws when mitmdump is still not runnable after the install', async () => { + mockedSpawn + .mockReturnValueOnce(spawnRejected()) + .mockReturnValueOnce(spawnResolved()) + .mockReturnValueOnce(spawnRejected()); + + await expect(createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()).rejects.toThrow( + /mitmdump is still not runnable/ + ); + }); +}); diff --git a/packages/build-tools/src/steps/functions/installMitmproxy.ts b/packages/build-tools/src/steps/functions/installMitmproxy.ts new file mode 100644 index 0000000000..3d8c8c193b --- /dev/null +++ b/packages/build-tools/src/steps/functions/installMitmproxy.ts @@ -0,0 +1,95 @@ +import { bunyan } from '@expo/logger'; +import { asyncResult } from '@expo/results'; +import { + BuildFunction, + BuildRuntimePlatform, + BuildStepEnv, + BuildStepGlobalContext, +} from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { decompressTarAsync } from '../../utils/files'; + +const MITMPROXY_VERSION = '12.2.3'; +const MITMPROXY_DOWNLOAD_URL = `https://storage.googleapis.com/turtle-v2/mitmproxy-${MITMPROXY_VERSION}-macos-arm64.tar.gz`; + +export function createInstallMitmproxyBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'install_mitmproxy', + name: 'Install mitmproxy', + supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN], + fn: async ({ logger, global }, { env }) => { + if (await isMitmproxyAvailableAsync(env)) { + logger.info('mitmproxy is already installed.'); + return; + } + + if (env.EAS_BUILD_RUNNER !== 'eas-build') { + logger.warn( + 'mitmproxy is not installed and network capture needs it. Install it with `brew install mitmproxy` and rerun the job.' + ); + return; + } + + await installMitmproxyFromGcsAsync({ logger, env, global }); + + if (!(await isMitmproxyAvailableAsync(env))) { + throw new Error( + `Installed mitmproxy ${MITMPROXY_VERSION} but mitmdump is still not runnable. The worker image may not match the artifact's macOS version or architecture; check the download and extract logs above.` + ); + } + + logger.info(`Installed mitmproxy ${MITMPROXY_VERSION}.`); + }, + }); +} + +async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise { + return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).ok; +} + +async function installMitmproxyFromGcsAsync({ + logger, + env, + global, +}: { + logger: bunyan; + env: BuildStepEnv; + global: BuildStepGlobalContext; +}): Promise { + assert( + env.HOME, + 'Failed to infer directory to install mitmproxy in: $HOME environment variable is empty.' + ); + const installDirectory = path.join(env.HOME, '.eas-mitmproxy'); + const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install_mitmproxy')); + const archivePath = path.join(tempDirectory, 'mitmproxy.tar.gz'); + + try { + logger.info(`Downloading mitmproxy ${MITMPROXY_VERSION}`); + await spawn('curl', ['--fail', '--location', '--output', archivePath, MITMPROXY_DOWNLOAD_URL], { + env, + logger, + }); + + await fs.promises.rm(installDirectory, { force: true, recursive: true }); + await fs.promises.mkdir(installDirectory, { recursive: true }); + logger.info('Extracting mitmproxy'); + await decompressTarAsync({ archivePath, destinationDirectory: installDirectory }); + } finally { + await fs.promises.rm(tempDirectory, { force: true, recursive: true }); + } + + const mitmproxyBinDir = path.join(installDirectory, 'mitmproxy.app', 'Contents', 'MacOS'); + global.updateEnv({ + ...global.env, + PATH: `${global.env.PATH}:${mitmproxyBinDir}`, + }); + env.PATH = `${env.PATH}:${mitmproxyBinDir}`; + process.env.PATH = `${process.env.PATH}:${mitmproxyBinDir}`; +} diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index dbdd7b960e..bca49dfe41 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -55,6 +55,11 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( required: false, allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), + BuildStepInput.createProvider({ + id: 'network_capture', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }), BuildStepInput.createProvider({ id: 'max_idle_time_minutes', required: false, @@ -76,6 +81,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env); const packageVersion = inputs.package_version.value as string | undefined; + const networkCapture = inputs.network_capture?.value as boolean | undefined; // A missing or non-positive value disables the idle timeout (opt-in feature). const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined; const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined; @@ -120,6 +126,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( env, logger, timeoutMs: STARTUP_TIMEOUT_MS, + networkCapture, }); logger.info(`Web preview URL: ${serveSim.previewUrl}`); } diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index f43ad2b12a..e6411260fb 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -56,6 +56,11 @@ export function createStartAppiumRemoteSessionBuildFunction( required: false, allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), + BuildStepInput.createProvider({ + id: 'network_capture', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }), BuildStepInput.createProvider({ id: 'max_idle_time_minutes', required: false, @@ -67,6 +72,7 @@ export function createStartAppiumRemoteSessionBuildFunction( const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env); const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env); const packageVersion = inputs.package_version.value as string | undefined; + const networkCapture = inputs.network_capture?.value as boolean | undefined; const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined; const { runtimePlatform } = global; const versionSpec = resolveAppium3VersionSpec(packageVersion); @@ -135,6 +141,7 @@ export function createStartAppiumRemoteSessionBuildFunction( env, logger, timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, + networkCapture, }); break; case BuildRuntimePlatform.LINUX: diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index eced65a17b..614bbe16ad 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -68,6 +68,11 @@ export function createStartArgentRemoteSessionBuildFunction( required: false, allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), + BuildStepInput.createProvider({ + id: 'network_capture', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }), BuildStepInput.createProvider({ id: 'max_idle_time_minutes', required: false, @@ -89,6 +94,7 @@ export function createStartArgentRemoteSessionBuildFunction( const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env); const packageVersion = inputs.package_version.value as string | undefined; + const networkCapture = inputs.network_capture?.value as boolean | undefined; // A missing or non-positive value disables the idle timeout (opt-in feature). const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined; const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined; @@ -207,6 +213,7 @@ export function createStartArgentRemoteSessionBuildFunction( env, logger, timeoutMs: STARTUP_TIMEOUT_MS, + networkCapture, }); webPreviewUrl = serveSim.previewUrl; logger.info(`Web preview URL: ${webPreviewUrl}`); diff --git a/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts b/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts index f9ed85ca26..cc039b375f 100644 --- a/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts @@ -32,6 +32,11 @@ export function createStartServeSimRemoteSessionBuildFunction( required: false, allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), + BuildStepInput.createProvider({ + id: 'network_capture', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }), BuildStepInput.createProvider({ id: 'max_duration_seconds', required: false, @@ -43,6 +48,7 @@ export function createStartServeSimRemoteSessionBuildFunction( const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env); const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined; const packageVersion = inputs.package_version?.value as string | undefined; + const networkCapture = inputs.network_capture?.value as boolean | undefined; logger.info('Starting serve-sim remote session.'); @@ -54,6 +60,7 @@ export function createStartServeSimRemoteSessionBuildFunction( logger, timeoutMs: STARTUP_TIMEOUT_MS, packageVersion, + networkCapture, }); logger.info(`Preview URL: ${serveSim.previewUrl}`); diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 9e1e3be0c9..40d62f3a84 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -113,6 +113,14 @@ function createEnvMock(): BuildStepEnv { return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv; } +function spawnResolved(): ReturnType { + return Promise.resolve({}) as unknown as ReturnType; +} + +function spawnRejected(): ReturnType { + return Promise.reject(new Error('boom')) as unknown as ReturnType; +} + describe(createServeSimArgs, () => { it('uses the latest Expo package and applies the EAS streaming policy', () => { expect( @@ -171,6 +179,17 @@ describe(createServeSimArgs, () => { '@expo/serve-sim@next', ]); }); + + it('omits --network-capture by default', () => { + expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture'); + expect(createServeSimArgs({ port: 4321, networkCapture: false })).not.toContain( + '--network-capture' + ); + }); + + it('appends --network-capture when enabled', () => { + expect(createServeSimArgs({ port: 4321, networkCapture: true })).toContain('--network-capture'); + }); }); describe(metricsCorsOriginToServeSimArgs, () => { @@ -631,14 +650,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => { describe(ensureFfmpegInstalledAsync, () => { const spawnMock = jest.mocked(spawn); - function spawnResolved(): ReturnType { - return Promise.resolve({}) as unknown as ReturnType; - } - - function spawnRejected(): ReturnType { - return Promise.reject(new Error('boom')) as unknown as ReturnType; - } - beforeEach(() => { spawnMock.mockReset(); jest.mocked(Sentry).capture.mockReset(); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 176299e78f..e84cb41477 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -605,11 +605,13 @@ export function createServeSimArgs({ turnArgs = [], metricsCorsArgs = [], packageVersion, + networkCapture = false, }: { port: number; turnArgs?: string[]; metricsCorsArgs?: string[]; packageVersion?: string; + networkCapture?: boolean; }): string[] { return [ '--yes', @@ -632,6 +634,7 @@ export function createServeSimArgs({ SERVE_SIM_VIDEO_FPS, ...turnArgs, ...metricsCorsArgs, + ...(networkCapture ? ['--network-capture'] : []), ]; } @@ -707,12 +710,14 @@ export async function startServeSimWithTunnelAsync( logger, timeoutMs, packageVersion, + networkCapture = false, }: { baseDomain: string; env: BuildStepEnv; logger: bunyan; timeoutMs: number; packageVersion?: string; + networkCapture?: boolean; } ): Promise { const port = await findAvailablePortAsync(); @@ -723,7 +728,7 @@ export async function startServeSimWithTunnelAsync( const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env); const serveSim = spawnDetached({ command: 'npx', - args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }), + args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion, networkCapture }), env, });