Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/build-tools/src/steps/easFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -110,6 +111,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartServeSimMetricsBuildFunction(),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),

createInstallPodsBuildFunction(),
createSendSlackMessageFunction(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof spawn> {
return Promise.resolve({}) as unknown as ReturnType<typeof spawn>;
}

function spawnRejected(): ReturnType<typeof spawn> {
return Promise.reject(new Error('not found')) as unknown as ReturnType<typeof spawn>;
}

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<string, string>
): ReturnType<
ReturnType<typeof createInstallMitmproxyBuildFunction>['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/
);
});
});
95 changes: 95 additions & 0 deletions packages/build-tools/src/steps/functions/installMitmproxy.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).ok;
}

async function installMitmproxyFromGcsAsync({
logger,
env,
global,
}: {
logger: bunyan;
env: BuildStepEnv;
global: BuildStepGlobalContext;
}): Promise<void> {
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}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -120,6 +126,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
networkCapture,
});
logger.info(`Web preview URL: ${serveSim.previewUrl}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -135,6 +141,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
networkCapture,
});
break;
case BuildRuntimePlatform.LINUX:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -207,6 +213,7 @@ export function createStartArgentRemoteSessionBuildFunction(
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
networkCapture,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.');

Expand All @@ -54,6 +60,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
networkCapture,
});
logger.info(`Preview URL: ${serveSim.previewUrl}`);

Expand Down
Loading