mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
329ddccd61 | ||
|
|
4e95a29797 | ||
|
|
66d8e3d6b8 | ||
|
|
5e4a88094f | ||
|
|
7a4c81f4e3 | ||
|
|
c9cdfd1eb5 | ||
|
|
8115dc8766 | ||
|
|
113467c91d | ||
|
|
be967f4b9a | ||
|
|
660303b0c3 | ||
|
|
5514289648 | ||
|
|
7924e33e96 | ||
|
|
1db7281e8b | ||
|
|
1e8a0acabe | ||
|
|
6bfb8af57f | ||
|
|
231f29ecf1 |
No files matched your search
@@ -8,12 +8,19 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
|
||||
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
@@ -14,8 +14,12 @@ import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreGradleCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
@@ -81,6 +85,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
|
||||
@@ -93,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: ctx.appConfig,
|
||||
appConfig: await ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -196,6 +206,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -103,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: ctx.appConfig,
|
||||
appConfig: await ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
|
||||
const appConfig = ctx.appConfig;
|
||||
const appConfig = await ctx.appConfig;
|
||||
ctx.logger.info('Using app configuration:');
|
||||
ctx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
await validateAppConfigAsync(ctx, appConfig);
|
||||
@@ -121,12 +121,13 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
|
||||
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
|
||||
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
|
||||
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
const appConfig = await ctx.appConfig;
|
||||
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
throw new Error(
|
||||
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
|
||||
);
|
||||
}
|
||||
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
throw new Error(
|
||||
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private buildPhase?: BuildPhase;
|
||||
private buildPhaseSkipped = false;
|
||||
private buildPhaseHasWarnings = false;
|
||||
private _appConfig?: ExpoConfig;
|
||||
private _appConfig?: Promise<ExpoConfig>;
|
||||
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
public readonly graphqlClient: Client;
|
||||
|
||||
@@ -175,14 +175,14 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
public get packageManager(): PackageManager {
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory());
|
||||
}
|
||||
public get appConfig(): ExpoConfig {
|
||||
public get appConfig(): Promise<ExpoConfig> {
|
||||
if (!this._appConfig) {
|
||||
this._appConfig = readAppConfig({
|
||||
projectDir: this.getReactNativeProjectDirectory(),
|
||||
env: this.env,
|
||||
logger: this.logger,
|
||||
sdkVersion: this.metadata?.sdkVersion,
|
||||
}).exp;
|
||||
}).then(config => config.exp);
|
||||
}
|
||||
return this._appConfig;
|
||||
}
|
||||
|
||||
@@ -19,5 +19,12 @@ export { PackageManager } from './utils/packageManager';
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
export {
|
||||
maybeStartPreparingPrecompiledModules,
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
shouldUsePrecompiledDependencies,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} from './utils/precompiledModules';
|
||||
|
||||
export * from './generic';
|
||||
@@ -0,0 +1,127 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import {
|
||||
startPreparingPrecompiledDependencies,
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
} from '../../utils/precompiledModules';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(installPods.name, () => {
|
||||
const extract = jest.fn();
|
||||
const close = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(remove).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(move).mockImplementation(async () => undefined as any);
|
||||
let mkdtempCallCount = 0;
|
||||
jest.mocked(mkdtemp).mockImplementation(async () => {
|
||||
mkdtempCallCount += 1;
|
||||
return mkdtempCallCount === 1
|
||||
? ('/tmp/precompiled-modules-archive' as any)
|
||||
: ('/tmp/precompiled-modules-staging' as any);
|
||||
});
|
||||
extract.mockReset().mockResolvedValue(undefined);
|
||||
close.mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(StreamZip.async).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
extract,
|
||||
close,
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for precompiled dependencies before running pod install', async () => {
|
||||
let resolvePreparation: (() => void) | undefined;
|
||||
const preparationPromise = new Promise<void>(resolve => {
|
||||
resolvePreparation = resolve;
|
||||
});
|
||||
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
|
||||
const ctx = new BuildContext(
|
||||
createTestIosJob({
|
||||
buildCredentials: undefined,
|
||||
}),
|
||||
{
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
startPreparingPrecompiledDependencies(ctx, [
|
||||
'https://example.com/precompiled-modules-0.zip',
|
||||
'https://example.com/precompiled-modules-1.zip',
|
||||
]);
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
const installPodsPromise = installPods(ctx, {});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
|
||||
resolvePreparation?.();
|
||||
await installPodsPromise;
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: '/workingdir/build/ios',
|
||||
})
|
||||
);
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('continues with pod install when precompiled dependencies preparation fails', async () => {
|
||||
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
|
||||
const ctx = new BuildContext(
|
||||
createTestIosJob({
|
||||
buildCredentials: undefined,
|
||||
}),
|
||||
{
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']);
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
await expect(installPods(ctx, {})).resolves.toEqual({
|
||||
spawnPromise: expect.any(Promise),
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: '/workingdir/build/ios',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,21 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ infoCallbackFn }: SpawnOptions
|
||||
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
|
||||
try {
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{ err },
|
||||
'Precompiled dependencies were not prepared successfully, continuing with pod install'
|
||||
);
|
||||
}
|
||||
|
||||
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
|
||||
import { createInstallPodsBuildFunction } from '../installPods';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/precompiledModules', () => ({
|
||||
...jest.requireActual('../../../utils/precompiledModules'),
|
||||
waitForPrecompiledModulesPreparationAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe(createInstallPodsBuildFunction, () => {
|
||||
it('waits for precompiled dependencies before running pod install', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
|
||||
|
||||
const installPods = createInstallPodsBuildFunction();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: globalContext.defaultWorkingDirectory,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('continues with pod install when precompiled dependencies preparation fails', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
jest
|
||||
.mocked(waitForPrecompiledModulesPreparationAsync)
|
||||
.mockRejectedValue(new Error('precompiled dependencies failed'));
|
||||
|
||||
const installPods = createInstallPodsBuildFunction();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: globalContext.defaultWorkingDirectory,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
cloneAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableWindowAndTransitionAnimationsAsync: jest.fn(),
|
||||
deleteAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -34,14 +35,15 @@ const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs,
|
||||
});
|
||||
return Object.assign(step, { logger });
|
||||
}
|
||||
|
||||
function createStartResult(serialId: string) {
|
||||
@@ -57,7 +59,9 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
@@ -97,6 +101,12 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-2222',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-1111',
|
||||
@@ -147,6 +157,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-base',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-1-attempt-2',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-2-attempt-1',
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -174,4 +202,14 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('skips animation scale adjustments when opt out env var is disabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -34,18 +34,20 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs, outputs }) => {
|
||||
const appConfig = readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
}).exp;
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
|
||||
const platform =
|
||||
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
|
||||
|
||||
@@ -42,18 +42,20 @@ export function configureEASUpdateIfInstalledFunction(): BuildFunction {
|
||||
assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.');
|
||||
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
|
||||
|
||||
const appConfig = readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
}).exp;
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
|
||||
const channelInput = inputs.channel.value as string | undefined;
|
||||
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BuildFunction } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
|
||||
|
||||
export function createInstallPodsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -8,6 +10,14 @@ export function createInstallPodsBuildFunction(): BuildFunction {
|
||||
name: 'Install Pods',
|
||||
__metricsId: 'eas/install_pods',
|
||||
fn: async (stepsCtx, { env }) => {
|
||||
try {
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
} catch (err) {
|
||||
stepsCtx.logger.warn(
|
||||
{ err },
|
||||
'Precompiled dependencies were not prepared successfully, continuing with pod install'
|
||||
);
|
||||
}
|
||||
stepsCtx.logger.info('Installing pods');
|
||||
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag =
|
||||
|
||||
@@ -23,6 +23,7 @@ import resolveFrom from 'resolve-from';
|
||||
|
||||
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
|
||||
import IosCredentialsManager from '../utils/ios/credentials/manager';
|
||||
import { resolvePackageVersionAsync } from '../../utils/packageManager';
|
||||
|
||||
export function createRepackBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -108,12 +109,18 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
: undefined;
|
||||
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
|
||||
|
||||
const resolvedRepackVersion =
|
||||
(await resolvePackageVersionAsync({
|
||||
logger: stepsCtx.logger,
|
||||
packageName: inputs.repack_package.value as string,
|
||||
distTag: inputs.repack_version.value as string,
|
||||
})) ?? (inputs.repack_version.value as string);
|
||||
stepsCtx.logger.info(
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value}`
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
|
||||
);
|
||||
const repackApp = await installAndImportRepackAsync({
|
||||
packageName: inputs.repack_package.value as string,
|
||||
version: inputs.repack_version.value as string,
|
||||
version: resolvedRepackVersion,
|
||||
});
|
||||
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import nullthrows from 'nullthrows';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { sendCcacheStatsAsync } from './ccacheStats';
|
||||
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError } from '../../utils/turtleFetch';
|
||||
|
||||
export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
@@ -50,6 +54,15 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -176,6 +189,106 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
|
||||
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
|
||||
await fs.promises.writeFile(
|
||||
gradlePropertiesPath,
|
||||
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
|
||||
);
|
||||
|
||||
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
|
||||
// org.gradle.cache.cleanup property was removed in Gradle 9)
|
||||
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
|
||||
await fs.promises.mkdir(initScriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
|
||||
[
|
||||
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
|
||||
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
|
||||
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
|
||||
'',
|
||||
'beforeSettings { settings ->',
|
||||
' try {',
|
||||
' settings.caches {',
|
||||
' cleanup = Cleanup.ALWAYS',
|
||||
' buildCache {',
|
||||
' setRemoveUnusedEntriesAfterDays(3)',
|
||||
' }',
|
||||
' }',
|
||||
' println "[EAS] Configured Gradle cache cleanup via init script"',
|
||||
' } catch (Exception e) {',
|
||||
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'gradle.buildFinished {',
|
||||
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
|
||||
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
|
||||
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [buildCachePath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No Gradle cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore Gradle cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheStatsAsync({
|
||||
logger,
|
||||
env,
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
import fs from 'fs';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -46,6 +51,15 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -135,3 +149,66 @@ export async function saveCcacheAsync({
|
||||
logger.error({ err }, 'Failed to save cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
|
||||
try {
|
||||
await fs.promises.access(buildCachePath);
|
||||
} catch {
|
||||
logger.warn('No Gradle build cache found, skipping save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
|
||||
logger.info(`Saving Gradle cache key: ${cacheKey}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing Gradle build cache...');
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCachePath],
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
});
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key: cacheKey,
|
||||
paths: [buildCachePath],
|
||||
size,
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save Gradle cache');
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,15 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
|
||||
if (!shouldAdjustAnimationScale) {
|
||||
logger.info(
|
||||
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Making sure system image is installed');
|
||||
await retryAsync(
|
||||
@@ -107,6 +116,13 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: attemptSerialId,
|
||||
});
|
||||
}
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
@@ -191,6 +207,13 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: cloneSerialId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`${cloneIdentifier} is ready.`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -391,6 +391,38 @@ export namespace AndroidEmulatorUtils {
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableWindowAndTransitionAnimationsAsync({
|
||||
serialId,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
logger.info('Disabling Android emulator window animations.');
|
||||
try {
|
||||
await spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'settings', 'put', 'global', 'window_animation_scale', '0'],
|
||||
{ env }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable Android emulator window animations.');
|
||||
}
|
||||
|
||||
logger.info('Disabling Android emulator transition animations.');
|
||||
try {
|
||||
await spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'settings', 'put', 'global', 'transition_animation_scale', '0'],
|
||||
{ env }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable Android emulator transition animations.');
|
||||
}
|
||||
}
|
||||
|
||||
async function hasNetworkConnectionAsync({
|
||||
serialId,
|
||||
env,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
import fs from 'fs-extra';
|
||||
import nock from 'nock';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('precompiledModules integration', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('merges the extracted debug and release xcframework trees', async () => {
|
||||
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = homeDirectory;
|
||||
|
||||
try {
|
||||
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
|
||||
const debugArchive = await fs.readFile(
|
||||
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
|
||||
);
|
||||
const releaseArchive = await fs.readFile(
|
||||
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
|
||||
);
|
||||
|
||||
nock('https://fixtures.expo.test')
|
||||
.get('/xcframeworks-Debug.zip')
|
||||
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
|
||||
.get('/xcframeworks-Release.zip')
|
||||
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('os', () => ({
|
||||
...jest.requireActual('os'),
|
||||
homedir: () => homeDirectory,
|
||||
}));
|
||||
const {
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} = await import('../precompiledModules');
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
{
|
||||
logger: createMockLogger(),
|
||||
env: {},
|
||||
} as any,
|
||||
[
|
||||
'https://fixtures.expo.test/xcframeworks-Debug.zip',
|
||||
'https://fixtures.expo.test/xcframeworks-Release.zip',
|
||||
]
|
||||
);
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(PRECOMPILED_MODULES_PATH).toBe(
|
||||
path.join(homeDirectory, '.expo', 'precompiled-modules')
|
||||
);
|
||||
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('debug expo-modules-core fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('release expo-modules-core fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('debug expo-camera fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('release expo-application fixture\n');
|
||||
} finally {
|
||||
jest.dontMock('os');
|
||||
jest.resetModules();
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.remove(homeDirectory);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
@@ -110,6 +111,87 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync, () => {
|
||||
it('sets window and transition animation scales to zero', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Disabling Android emulator window animations.'
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'-s',
|
||||
'emulator-5554',
|
||||
'shell',
|
||||
'settings',
|
||||
'put',
|
||||
'global',
|
||||
'window_animation_scale',
|
||||
'0',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Disabling Android emulator transition animations.'
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'-s',
|
||||
'emulator-5554',
|
||||
'shell',
|
||||
'settings',
|
||||
'put',
|
||||
'global',
|
||||
'transition_animation_scale',
|
||||
'0',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and swallows failures when disabling animations', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('window failed'))
|
||||
.mockRejectedValueOnce(new Error('transition failed'));
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'window failed' }),
|
||||
}),
|
||||
'Failed to disable Android emulator window animations.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'transition failed' }),
|
||||
}),
|
||||
'Failed to disable Android emulator transition animations.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.stopAsync, () => {
|
||||
it('kills emulator and waits for it to detach', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { readAppConfig } from '../appConfig';
|
||||
|
||||
jest.mock('@expo/env', () => ({
|
||||
load: jest.fn(() => ({ FROM_DOTENV: 'true' })),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
getConfig: jest.fn(() => ({
|
||||
exp: { name: 'fallback-app', slug: 'fallback-app' },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../expoCli');
|
||||
|
||||
const { expoCommandAsync } = jest.requireMock('../expoCli') as {
|
||||
expoCommandAsync: jest.Mock;
|
||||
};
|
||||
|
||||
const { getConfig } = jest.requireMock('@expo/config') as {
|
||||
getConfig: jest.Mock;
|
||||
};
|
||||
|
||||
const { load: loadEnv } = jest.requireMock('@expo/env') as {
|
||||
load: jest.Mock;
|
||||
};
|
||||
|
||||
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
|
||||
const baseParams = {
|
||||
projectDir: '/project',
|
||||
env: { NODE_ENV: 'production' },
|
||||
logger,
|
||||
};
|
||||
|
||||
describe(readAppConfig, () => {
|
||||
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) });
|
||||
|
||||
const result = await readAppConfig(baseParams);
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
['config', '--json', '--full', '--type', 'public'],
|
||||
{ env: baseParams.env }
|
||||
);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to @expo/config when expo CLI fails', async () => {
|
||||
expoCommandAsync.mockRejectedValue(new Error('expo not found'));
|
||||
|
||||
const result = await readAppConfig(baseParams);
|
||||
|
||||
expect(result).toEqual({ exp: { name: 'fallback-app', slug: 'fallback-app' } });
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('expo not found'));
|
||||
expect(getConfig).toHaveBeenCalledWith('/project', {
|
||||
skipSDKVersionRequirement: true,
|
||||
isPublicConfig: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when expo CLI returns invalid JSON and @expo/config also fails', async () => {
|
||||
expoCommandAsync.mockResolvedValue({ stdout: 'not json' });
|
||||
getConfig.mockImplementation(() => {
|
||||
throw new Error('@expo/config failed');
|
||||
});
|
||||
|
||||
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to parse JSON output from 'expo config'")
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when expo CLI output is missing exp field and @expo/config also fails', async () => {
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify({ other: 'data' }) });
|
||||
getConfig.mockImplementation(() => {
|
||||
throw new Error('@expo/config failed');
|
||||
});
|
||||
|
||||
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("missing 'exp' field"));
|
||||
});
|
||||
|
||||
it('loads env vars from dotenv for SDK >= 49', async () => {
|
||||
const config = { exp: { name: 'test-app', slug: 'test-app' } };
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
|
||||
|
||||
await readAppConfig({ ...baseParams, sdkVersion: '49.0.0' });
|
||||
|
||||
expect(loadEnv).toHaveBeenCalledWith('/project');
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith('/project', expect.any(Array), {
|
||||
env: { NODE_ENV: 'production', FROM_DOTENV: 'true' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not load env vars from dotenv for SDK < 49', async () => {
|
||||
const config = { exp: { name: 'test-app', slug: 'test-app' } };
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
|
||||
|
||||
await readAppConfig({ ...baseParams, sdkVersion: '48.0.0' });
|
||||
|
||||
expect(loadEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import resolveFrom from 'resolve-from';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
|
||||
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
|
||||
|
||||
jest.mock('resolve-from', () => ({
|
||||
__esModule: true,
|
||||
default: Object.assign(jest.fn(), {
|
||||
silent: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
}));
|
||||
|
||||
const mockedResolveFrom = resolveFrom as jest.MockedFunction<typeof resolveFrom> & {
|
||||
silent: jest.MockedFunction<typeof resolveFrom.silent>;
|
||||
};
|
||||
|
||||
describe(expoCommandAsync, () => {
|
||||
it('resolves expo CLI and spawns with correct args', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
|
||||
|
||||
await expoCommandAsync('/project', ['config', '--json'], { env: { FOO: 'bar' } });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
'/project/node_modules/expo/bin/cli',
|
||||
['config', '--json'],
|
||||
expect.objectContaining({
|
||||
cwd: '/project',
|
||||
stdio: 'pipe',
|
||||
env: { FOO: 'bar', EXPO_DEBUG: '0' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to expo/bin/cli.js when silent resolve returns null', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue(undefined);
|
||||
mockedResolveFrom.mockReturnValue('/project/node_modules/expo/bin/cli.js');
|
||||
|
||||
await expoCommandAsync('/project', ['config'], { env: {} });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
'/project/node_modules/expo/bin/cli.js',
|
||||
['config'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ExpoCLIModuleNotFoundError when expo is not installed', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue(undefined);
|
||||
const moduleError = new Error('Cannot find module');
|
||||
(moduleError as any).code = 'MODULE_NOT_FOUND';
|
||||
mockedResolveFrom.mockImplementation(() => {
|
||||
throw moduleError;
|
||||
});
|
||||
|
||||
await expect(expoCommandAsync('/project', ['config'], { env: {} })).rejects.toThrow(
|
||||
ExpoCLIModuleNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides EXPO_DEBUG to 0', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
|
||||
|
||||
await expoCommandAsync('/project', ['config'], { env: { EXPO_DEBUG: '1' } });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ EXPO_DEBUG: '0' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import {
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
resolvePackageManager,
|
||||
resolvePackageVersionAsync,
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../packageManager';
|
||||
|
||||
@@ -68,6 +71,33 @@ describe(resolvePackageManager, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageVersionAsync, () => {
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
it('should resolve version from package name and dist tag', async () => {
|
||||
const version = await resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName: '@expo/repack-app',
|
||||
distTag: 'latest',
|
||||
});
|
||||
expect(semver.valid(version)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return null if version cannot be resolved', async () => {
|
||||
const version = await resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName: '@expo/repack-app',
|
||||
distTag: 'nonexistent-dist-tag',
|
||||
});
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(findPackagerRootDir, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import {
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} from '../precompiledModules';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('precompiledModules', () => {
|
||||
const extract = jest.fn();
|
||||
const close = jest.fn();
|
||||
const getMkdtempPath = jest.fn();
|
||||
|
||||
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
|
||||
({
|
||||
logger: createMockLogger(),
|
||||
env,
|
||||
}) as unknown as BuildContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(remove).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(move).mockImplementation(async () => undefined as any);
|
||||
getMkdtempPath.mockReset();
|
||||
getMkdtempPath
|
||||
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
|
||||
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
|
||||
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
|
||||
jest.mocked(downloadFile).mockReset();
|
||||
extract.mockReset().mockResolvedValue(undefined);
|
||||
close.mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(StreamZip.async).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
extract,
|
||||
close,
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('downloads through CocoaPods proxy when enabled', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
|
||||
[
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]
|
||||
);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
|
||||
jest
|
||||
.mocked(downloadFile)
|
||||
.mockRejectedValueOnce(new Error('proxy failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
|
||||
[
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]
|
||||
);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts all configured archives into the well-known destination path', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
|
||||
]);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
|
||||
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
|
||||
expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('leaves the destination empty when preparation fails', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
extract.mockRejectedValueOnce(new Error('extract failed'));
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]);
|
||||
|
||||
await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed');
|
||||
|
||||
expect(move).not.toHaveBeenCalled();
|
||||
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
|
||||
expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('throws when precompiled dependencies are still not ready after 30 seconds', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => {}) as any);
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://example.com/xcframeworks-Debug.zip',
|
||||
]);
|
||||
|
||||
const waitPromise = waitForPrecompiledModulesPreparationAsync();
|
||||
const waitExpectation = expect(waitPromise).rejects.toThrow(
|
||||
'Timed out waiting for precompiled dependencies after 30 seconds'
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
await waitExpectation;
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -38,7 +38,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -52,7 +52,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.PNPM);
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -66,7 +66,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.BUN);
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
|
||||
@@ -3,18 +3,70 @@ import { Env } from '@expo/eas-build-job';
|
||||
import { load } from '@expo/env';
|
||||
import { LoggerLevel, bunyan } from '@expo/logger';
|
||||
import semver from 'semver';
|
||||
import { expoCommandAsync } from './expoCli';
|
||||
|
||||
export function readAppConfig({
|
||||
projectDir,
|
||||
env,
|
||||
logger,
|
||||
sdkVersion,
|
||||
}: {
|
||||
interface ReadAppConfigParams {
|
||||
projectDir: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
sdkVersion?: string;
|
||||
}): ProjectConfig {
|
||||
}
|
||||
|
||||
export async function readAppConfig(params: ReadAppConfigParams): Promise<ProjectConfig> {
|
||||
const shouldLoadEnvVarsFromDotenvFile =
|
||||
params.sdkVersion && semver.satisfies(params.sdkVersion, '>=49');
|
||||
if (shouldLoadEnvVarsFromDotenvFile) {
|
||||
const envVarsFromDotenvFile = load(params.projectDir) as Env;
|
||||
const env = { ...params.env, ...envVarsFromDotenvFile };
|
||||
params = { ...params, env };
|
||||
}
|
||||
|
||||
// Reading the app config is done in two steps/attempts. We first attempt to run `expo config` as a CLI,
|
||||
try {
|
||||
return await getAppConfigFromExpo(params);
|
||||
} catch (error: any) {
|
||||
params.logger.warn(
|
||||
'Failed to read the app config file with `expo config` command:\n' +
|
||||
`${error?.message || error}`
|
||||
);
|
||||
}
|
||||
|
||||
// If this fails, we fall back to directly using `@expo/config`
|
||||
// This can fail, since it's tied to a specific SDK version, so reading for older SDKs isn't guaranteed to work
|
||||
return getAppConfigFromExpoConfig(params);
|
||||
}
|
||||
|
||||
async function getAppConfigFromExpo({
|
||||
projectDir,
|
||||
env,
|
||||
}: ReadAppConfigParams): Promise<ProjectConfig> {
|
||||
const result = await expoCommandAsync(
|
||||
projectDir,
|
||||
['config', '--json', '--full', '--type', 'public'],
|
||||
{ env }
|
||||
);
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse JSON output from 'expo config'.\nOutput: ${result.stdout.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!('exp' in parsed)) {
|
||||
throw new Error(`Unexpected output from 'expo config': missing 'exp' field.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getAppConfigFromExpoConfig({
|
||||
projectDir,
|
||||
env,
|
||||
logger,
|
||||
}: ReadAppConfigParams): ProjectConfig {
|
||||
const originalProcessExit = process.exit;
|
||||
const originalProcessCwd = process.cwd;
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
@@ -22,11 +74,8 @@ export function readAppConfig({
|
||||
const originalProcessEnv = process.env;
|
||||
|
||||
const stdoutStore: { text: string; level: LoggerLevel }[] = [];
|
||||
const shouldLoadEnvVarsFromDotenvFile = sdkVersion && semver.satisfies(sdkVersion, '>=49');
|
||||
const envVarsFromDotenvFile = shouldLoadEnvVarsFromDotenvFile ? load(projectDir) : {};
|
||||
const newEnvsToUse = { ...env, ...envVarsFromDotenvFile };
|
||||
try {
|
||||
process.env = newEnvsToUse;
|
||||
process.env = env;
|
||||
process.exit = () => {
|
||||
throw new Error('Failed to evaluate app config file');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import resolveFrom from 'resolve-from';
|
||||
import spawnAsync, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
|
||||
export class ExpoCLIModuleNotFoundError extends Error {}
|
||||
|
||||
function resolveExpoCLI(projectRoot: string): string {
|
||||
try {
|
||||
return (
|
||||
resolveFrom.silent(projectRoot, 'expo/bin/cli') ?? resolveFrom(projectRoot, 'expo/bin/cli.js')
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e.code === 'MODULE_NOT_FOUND') {
|
||||
throw new ExpoCLIModuleNotFoundError(`The \`expo\` package was not found.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function expoCommandAsync(
|
||||
projectDir: string,
|
||||
args: string[],
|
||||
options: Omit<SpawnOptions, 'cwd'>
|
||||
) {
|
||||
const expoCliPath = resolveExpoCLI(projectDir);
|
||||
return spawnAsync(expoCliPath, args, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
...options,
|
||||
env: {
|
||||
...options.env,
|
||||
// NOTE: If we're reading user configs, if a user has set this, it might cause excessive output
|
||||
// that can stop the command from being readable
|
||||
EXPO_DEBUG: '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -120,7 +120,7 @@ This would cause any updates published on the local machine to not be compatible
|
||||
);
|
||||
}
|
||||
|
||||
if (isEASUpdateConfigured(ctx)) {
|
||||
if (await isEASUpdateConfigured(ctx)) {
|
||||
if (ctx.job.updates?.channel !== undefined) {
|
||||
await configureEASExpoUpdatesAsync(ctx);
|
||||
} else {
|
||||
@@ -134,7 +134,7 @@ This would cause any updates published on the local machine to not be compatible
|
||||
} else if (isDevelopmentClient) {
|
||||
// NO-OP: Development clients don't need to have a channel set
|
||||
} else {
|
||||
const easUpdateUrl = ctx.appConfig.updates?.url ?? null;
|
||||
const easUpdateUrl = (await ctx.appConfig).updates?.url ?? null;
|
||||
const jobProfile = ctx.job.buildProfile ?? null;
|
||||
ctx.logger.warn(
|
||||
`This build has an invalid EAS Update configuration: update.url is set to "${easUpdateUrl}" in app config, but a channel is not specified${
|
||||
@@ -220,8 +220,8 @@ export async function getRuntimeVersionAsync(ctx: BuildContext<Job>): Promise<st
|
||||
}
|
||||
}
|
||||
|
||||
export function isEASUpdateConfigured(ctx: BuildContext<Job>): boolean {
|
||||
const rawUrl = ctx.appConfig.updates?.url;
|
||||
export async function isEASUpdateConfigured(ctx: BuildContext<Job>): Promise<boolean> {
|
||||
const rawUrl = (await ctx.appConfig).updates?.url;
|
||||
if (!rawUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import { hashFiles } from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { findPackagerRootDir } from './packageManager';
|
||||
|
||||
export const GRADLE_CACHE_KEY_PREFIX = 'android-gradle-cache-';
|
||||
|
||||
export async function generateGradleCacheKeyAsync(workingDirectory: string): Promise<string> {
|
||||
const packagerRunDir = findPackagerRootDir(workingDirectory);
|
||||
const manager = PackageManagerUtils.createForProject(packagerRunDir);
|
||||
const lockPath = path.join(packagerRunDir, manager.lockFile);
|
||||
|
||||
try {
|
||||
return `${GRADLE_CACHE_KEY_PREFIX}${hashFiles([lockPath])}`;
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
`Failed to read lockfile for Gradle cache key generation: ${err instanceof Error ? err.message : err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
import semver from 'semver';
|
||||
@@ -27,6 +28,34 @@ export function resolvePackageManager(directory: string): PackageManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version of a package from the dist-tags.
|
||||
* Returns null if the version cannot be resolved.
|
||||
*/
|
||||
export async function resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName,
|
||||
distTag,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
packageName: string;
|
||||
distTag: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const distTags = JSON.parse(stdout);
|
||||
if (distTag in distTags) {
|
||||
return distTags[distTag];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findPackagerRootDir(currentDir: string): string {
|
||||
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
let precompiledModulesPreparationPromise: Promise<void> | null = null;
|
||||
const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000;
|
||||
export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules');
|
||||
|
||||
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
|
||||
return env.EAS_USE_PRECOMPILED_MODULES === '1';
|
||||
}
|
||||
|
||||
export function maybeStartPreparingPrecompiledModules(
|
||||
ctx: BuildContext,
|
||||
config: { precompiledModulesUrls: string[] }
|
||||
): void {
|
||||
if (!shouldUsePrecompiledDependencies(ctx.env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrls);
|
||||
}
|
||||
|
||||
export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: string[]): void {
|
||||
precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({
|
||||
logger: ctx.logger,
|
||||
urls,
|
||||
destinationDirectory: PRECOMPILED_MODULES_PATH,
|
||||
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForPrecompiledModulesPreparationAsync(): Promise<void> {
|
||||
if (!precompiledModulesPreparationPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
precompiledModulesPreparationPromise,
|
||||
new Promise<void>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`
|
||||
)
|
||||
);
|
||||
}, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
|
||||
timeoutHandle.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePrecompiledDependenciesAsync({
|
||||
logger,
|
||||
urls,
|
||||
destinationDirectory,
|
||||
cocoapodsProxyUrl,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
urls: string[];
|
||||
destinationDirectory: string;
|
||||
cocoapodsProxyUrl?: string;
|
||||
}): Promise<void> {
|
||||
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
|
||||
const destinationParentDirectory = path.dirname(destinationDirectory);
|
||||
await fs.mkdirp(destinationParentDirectory);
|
||||
const stagingDirectory = await fs.mkdtemp(
|
||||
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
|
||||
);
|
||||
|
||||
logger.info('');
|
||||
logger.info(
|
||||
{
|
||||
destinationDirectory,
|
||||
urls,
|
||||
},
|
||||
'Starting precompiled dependencies download'
|
||||
);
|
||||
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.mkdirp(destinationDirectory);
|
||||
|
||||
try {
|
||||
const archives = urls.map((url, index) => ({
|
||||
archiveName: path.basename(new URL(url).pathname),
|
||||
url,
|
||||
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
archives.map(async ({ url, archivePath }) => {
|
||||
await downloadPrecompiledModulesAsync({
|
||||
url,
|
||||
archivePath,
|
||||
cocoapodsProxyUrl,
|
||||
logger,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
for (const { archiveName, url, archivePath } of archives) {
|
||||
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
|
||||
await extractZipAsync(archivePath, stagingDirectory);
|
||||
logger.info(
|
||||
{ destinationDirectory: stagingDirectory },
|
||||
`Extracted ${archiveName} into staging precompiled dependencies directory`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.move(stagingDirectory, destinationDirectory);
|
||||
logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`);
|
||||
} catch (error) {
|
||||
await fs.remove(stagingDirectory);
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.mkdirp(destinationDirectory);
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.remove(archiveDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPrecompiledModulesAsync({
|
||||
url,
|
||||
archivePath,
|
||||
cocoapodsProxyUrl,
|
||||
logger,
|
||||
}: {
|
||||
url: string;
|
||||
archivePath: string;
|
||||
cocoapodsProxyUrl?: string;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const proxiedUrl = cocoapodsProxyUrl
|
||||
? (() => {
|
||||
const parsedUrl = new URL(url);
|
||||
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||
})()
|
||||
: null;
|
||||
if (proxiedUrl) {
|
||||
try {
|
||||
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, proxiedUrl },
|
||||
'Failed to download through CocoaPods proxy, retrying directly'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await downloadFile(url, archivePath, { retry: 3 });
|
||||
}
|
||||
|
||||
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
|
||||
const zip = new StreamZip.async({ file: archivePath });
|
||||
try {
|
||||
await zip.extract(null, destinationDirectory);
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.13",
|
||||
"@expo/apple-utils": "2.1.15",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getMockEmptyWorkflowRunsFragment,
|
||||
getMockWorkflowCustomJobFragment,
|
||||
getMockWorkflowRunWithBuildJobsFragment,
|
||||
getMockWorkflowRunWithJobsFragment,
|
||||
getMockWorkflowRunsFragment,
|
||||
mockCommandContext,
|
||||
@@ -124,7 +125,8 @@ describe(WorkflowLogView, () => {
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
|
||||
let promptCalls = 0;
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
let stepPrompt: any;
|
||||
jest.mocked(promptAsync).mockImplementation(async prompt => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
@@ -132,13 +134,14 @@ describe(WorkflowLogView, () => {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
return { selectedStep: 'step1' };
|
||||
stepPrompt = prompt;
|
||||
return { selectedStep: 'step-id-1' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install dependencies","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
@@ -148,6 +151,14 @@ describe(WorkflowLogView, () => {
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(stepPrompt.choices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Install dependencies - success',
|
||||
value: 'step-id-1',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
|
||||
});
|
||||
@@ -159,6 +170,9 @@ describe(WorkflowLogView, () => {
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withBuildJobs: 1 }));
|
||||
jest
|
||||
.mocked(WorkflowRunQuery.withJobsByIdAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunWithBuildJobsFragment());
|
||||
jest.mocked(BuildQuery.byIdAsync).mockResolvedValue({
|
||||
id: 'build1',
|
||||
status: BuildStatus.Finished,
|
||||
@@ -181,7 +195,8 @@ describe(WorkflowLogView, () => {
|
||||
logFiles: ['https://example.com/log1'],
|
||||
});
|
||||
let promptCalls = 0;
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
let stepPrompt: any;
|
||||
jest.mocked(promptAsync).mockImplementation(async prompt => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
@@ -189,13 +204,14 @@ describe(WorkflowLogView, () => {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
return { selectedStep: 'step1' };
|
||||
stepPrompt = prompt;
|
||||
return { selectedStep: 'step-id-1' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForBuildJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"step1","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
@@ -205,9 +221,65 @@ describe(WorkflowLogView, () => {
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(stepPrompt.choices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'step1 - success',
|
||||
value: 'step-id-1',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
|
||||
});
|
||||
test('view logs keeps duplicate display names separated by step id', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
|
||||
let promptCalls = 0;
|
||||
let stepPrompt: any;
|
||||
jest.mocked(promptAsync).mockImplementation(async prompt => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
} else if (promptCalls === 1) {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
stepPrompt = prompt;
|
||||
return { selectedStep: 'step-id-2' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mockResolvedValue(
|
||||
[
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"first"}',
|
||||
'{"result":"fail","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-2","time":"2022-01-01T00:00:01.000Z","msg":"second"}',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
await cmd.run();
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(stepPrompt.choices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Install - success',
|
||||
value: 'step-id-1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: 'Install - fail',
|
||||
value: 'step-id-2',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:01.000Z second');
|
||||
});
|
||||
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
|
||||
import { fetchRawLogsForCustomJobAsync } from '../fetchLogs';
|
||||
import { infoForActiveWorkflowRunAsync } from '../utils';
|
||||
import { WorkflowJobStatus } from '../../../graphql/generated';
|
||||
|
||||
jest.mock('../fetchLogs');
|
||||
|
||||
describe('workflow utils', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('shows the display name for the current step while keying logs by step id', async () => {
|
||||
const workflowRun = getMockWorkflowRunWithJobsFragment();
|
||||
workflowRun.jobs = workflowRun.jobs.map(job => ({
|
||||
...job,
|
||||
status: WorkflowJobStatus.InProgress,
|
||||
}));
|
||||
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mockResolvedValue(
|
||||
[
|
||||
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
|
||||
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const output = await infoForActiveWorkflowRunAsync({} as any, workflowRun);
|
||||
|
||||
expect(output).toContain('Current step');
|
||||
expect(output).toContain('Install dependencies');
|
||||
expect(output).not.toContain('step-id-1');
|
||||
});
|
||||
});
|
||||
@@ -50,4 +50,11 @@ export type WorkflowLogLine = {
|
||||
err?: any;
|
||||
};
|
||||
|
||||
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
|
||||
export type WorkflowLogs = Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
label: string;
|
||||
logLines: WorkflowLogLine[];
|
||||
}
|
||||
>;
|
||||
@@ -84,18 +84,17 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
|
||||
export function choicesFromWorkflowLogs(
|
||||
logs: WorkflowLogs
|
||||
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
|
||||
return Array.from(logs.keys())
|
||||
.map(step => {
|
||||
const logLines = logs.get(step);
|
||||
return Array.from(logs.values())
|
||||
.map(({ key, label, logLines }) => {
|
||||
const stepStatus =
|
||||
logLines?.filter(
|
||||
(line: WorkflowLogLine) => line.marker === 'end-step' || line.marker === 'END_PHASE'
|
||||
)[0]?.result ?? '';
|
||||
return {
|
||||
title: `${step} - ${stepStatus}`,
|
||||
name: step,
|
||||
title: `${label} - ${stepStatus}`,
|
||||
name: label,
|
||||
status: stepStatus,
|
||||
value: step,
|
||||
value: key,
|
||||
logLines,
|
||||
};
|
||||
})
|
||||
@@ -141,20 +140,27 @@ export async function fetchAndProcessLogsFromJobAsync(
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const logs: WorkflowLogs = new Map();
|
||||
const logKeys = new Set<string>();
|
||||
rawLogs.split('\n').forEach((line, index) => {
|
||||
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
|
||||
try {
|
||||
const parsedLine = JSON.parse(line);
|
||||
const { buildStepDisplayName, buildStepInternalId, phase, time, msg, result, marker, err } =
|
||||
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } =
|
||||
parsedLine;
|
||||
const stepId = buildStepDisplayName ?? buildStepInternalId ?? phase;
|
||||
if (stepId) {
|
||||
if (!logKeys.has(stepId)) {
|
||||
logKeys.add(stepId);
|
||||
logs.set(stepId, []);
|
||||
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
|
||||
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
|
||||
if (stepKey && stepLabel) {
|
||||
if (!logs.has(stepKey)) {
|
||||
logs.set(stepKey, {
|
||||
key: stepKey,
|
||||
label: stepLabel,
|
||||
logLines: [],
|
||||
});
|
||||
}
|
||||
logs.get(stepId)?.push({ time, msg, result, marker, err });
|
||||
const logGroup = logs.get(stepKey)!;
|
||||
if (buildStepDisplayName) {
|
||||
logGroup.label = buildStepDisplayName;
|
||||
}
|
||||
logGroup.logLines.push({ time, msg, result, marker, err });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ export default class MetadataPull extends EasCommand {
|
||||
description:
|
||||
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -34,18 +38,18 @@ export default class MetadataPull extends EasCommand {
|
||||
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
|
||||
|
||||
const { flags } = await this.parse(MetadataPull);
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(MetadataPull, {
|
||||
nonInteractive: false,
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
// this command is interactive (all nonInteractive flags passed to utility functions are false)
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive, vcsClient });
|
||||
|
||||
const submitProfiles = await getProfilesAsync({
|
||||
type: 'submit',
|
||||
@@ -66,7 +70,7 @@ export default class MetadataPull extends EasCommand {
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
nonInteractive: false,
|
||||
nonInteractive,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
@@ -77,6 +81,9 @@ export default class MetadataPull extends EasCommand {
|
||||
credentialsCtx,
|
||||
projectDir,
|
||||
profile: submitProfile,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
const relativePath = path.relative(process.cwd(), filePath);
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export default class MetadataPush extends EasCommand {
|
||||
description:
|
||||
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -32,18 +36,18 @@ export default class MetadataPush extends EasCommand {
|
||||
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
|
||||
|
||||
const { flags } = await this.parse(MetadataPush);
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(MetadataPush, {
|
||||
nonInteractive: false,
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
// this command is interactive (all nonInteractive flags passed to utility functions are false)
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive, vcsClient });
|
||||
|
||||
const submitProfiles = await getProfilesAsync({
|
||||
type: 'submit',
|
||||
@@ -64,7 +68,7 @@ export default class MetadataPush extends EasCommand {
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
nonInteractive: false,
|
||||
nonInteractive,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
@@ -75,6 +79,9 @@ export default class MetadataPush extends EasCommand {
|
||||
credentialsCtx,
|
||||
projectDir,
|
||||
profile: submitProfile,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -12,14 +12,14 @@ import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
function printLogsForAllSteps(logs: WorkflowLogs): void {
|
||||
[...logs.keys()].forEach(step => {
|
||||
const logLines = logs.get(step);
|
||||
if (logLines) {
|
||||
Log.log(`Step: ${step}`);
|
||||
logLines.forEach(line => {
|
||||
Log.log(` ${line.time} ${line.msg}`);
|
||||
});
|
||||
[...logs.values()].forEach(({ label, logLines }) => {
|
||||
if (logLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
Log.log(`Step: ${label}`);
|
||||
logLines.forEach(line => {
|
||||
Log.log(` ${line.time} ${line.msg}`);
|
||||
});
|
||||
Log.addNewLineIfNone();
|
||||
});
|
||||
}
|
||||
@@ -89,21 +89,25 @@ export default class WorkflowLogView extends EasCommand {
|
||||
if (allSteps) {
|
||||
if (logs) {
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(Object.fromEntries(logs));
|
||||
printJsonOnlyOutput(
|
||||
Object.fromEntries(
|
||||
Array.from(logs.entries()).map(([key, value]) => [key, value.logLines])
|
||||
)
|
||||
);
|
||||
} else {
|
||||
printLogsForAllSteps(logs);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const selectedStep = finalSelectionState?.step as unknown as string;
|
||||
const logLines = logs?.get(selectedStep);
|
||||
if (logLines) {
|
||||
const logGroup = logs?.get(selectedStep);
|
||||
if (logGroup) {
|
||||
if (flags.json) {
|
||||
const output: { [key: string]: WorkflowLogLine[] | null } = {};
|
||||
output[selectedStep] = logLines ?? null;
|
||||
output[selectedStep] = logGroup.logLines;
|
||||
printJsonOnlyOutput(output);
|
||||
} else {
|
||||
logLines.forEach(line => {
|
||||
logGroup.logLines.forEach(line => {
|
||||
Log.log(` ${line.time} ${line.msg}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { App } from '@expo/apple-utils';
|
||||
import { SubmitProfile } from '@expo/eas-json';
|
||||
|
||||
import { getAppStoreAuthAsync } from '../auth';
|
||||
import { AuthenticationMode } from '../../credentials/ios/appstore/authenticateTypes';
|
||||
|
||||
// Mock all external dependencies
|
||||
jest.mock('@expo/apple-utils', () => ({
|
||||
App: { findAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../../credentials/ios/appstore/authenticate', () => ({
|
||||
getRequestContext: jest.fn(() => ({ token: 'mock-token' })),
|
||||
}));
|
||||
|
||||
jest.mock('../../credentials/ios/appstore/resolveCredentials', () => ({
|
||||
hasAscEnvVars: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('../../project/ios/bundleIdentifier', () => ({
|
||||
getBundleIdentifierAsync: jest.fn(() => 'com.example.app'),
|
||||
}));
|
||||
|
||||
jest.mock('../../project/projectUtils', () => ({
|
||||
getOwnerAccountForProjectIdAsync: jest.fn(() => ({
|
||||
id: 'account-id',
|
||||
name: 'testaccount',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../credentials/ios/api/GraphqlClient', () => ({
|
||||
getAscApiKeyForAppSubmissionsAsync: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
|
||||
AppStoreConnectApiKeyQuery: {
|
||||
getByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
readFile: jest.fn(() => 'mock-key-p8-content'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
const { hasAscEnvVars } =
|
||||
require('../../credentials/ios/appstore/resolveCredentials') as jest.Mocked<
|
||||
typeof import('../../credentials/ios/appstore/resolveCredentials')
|
||||
>;
|
||||
const { getAscApiKeyForAppSubmissionsAsync } =
|
||||
require('../../credentials/ios/api/GraphqlClient') as jest.Mocked<
|
||||
typeof import('../../credentials/ios/api/GraphqlClient')
|
||||
>;
|
||||
const { AppStoreConnectApiKeyQuery } =
|
||||
require('../../graphql/queries/AppStoreConnectApiKeyQuery') as jest.Mocked<
|
||||
typeof import('../../graphql/queries/AppStoreConnectApiKeyQuery')
|
||||
>;
|
||||
|
||||
const mockApp = { id: '123', bundleId: 'com.example.app' };
|
||||
|
||||
function createMockCredentialsCtx(ensureAuthResult?: any) {
|
||||
return {
|
||||
appStore: {
|
||||
ensureAuthenticatedAsync: jest.fn(() => ({
|
||||
authState: { context: { token: 'mock-token' } },
|
||||
...ensureAuthResult,
|
||||
})),
|
||||
},
|
||||
vcsClient: {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createBaseArgs(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { bundleIdentifier: 'com.example.app' } as SubmitProfile,
|
||||
exp: { slug: 'my-app', name: 'My App' } as any,
|
||||
credentialsCtx: createMockCredentialsCtx(),
|
||||
nonInteractive: false,
|
||||
graphqlClient: {} as any,
|
||||
projectId: 'test-project-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(getAppStoreAuthAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(App.findAsync as jest.Mock).mockResolvedValue(mockApp);
|
||||
hasAscEnvVars.mockReturnValue(false);
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('uses API key auth when ASC env vars are set', async () => {
|
||||
hasAscEnvVars.mockReturnValue(true);
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: AuthenticationMode.API_KEY })
|
||||
);
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('uses API key from submit profile when ascApiKeyPath/Id/IssuerId are set', async () => {
|
||||
const profile = {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
ascApiKeyPath: '/path/to/key.p8',
|
||||
ascApiKeyId: 'KEY123',
|
||||
ascApiKeyIssuerId: 'ISSUER456',
|
||||
} as any;
|
||||
const args = createBaseArgs({ profile });
|
||||
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: {
|
||||
keyP8: 'mock-key-p8-content',
|
||||
keyId: 'KEY123',
|
||||
issuerId: 'ISSUER456',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('uses API key from EAS credentials service when available', async () => {
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
|
||||
id: 'asc-key-id',
|
||||
keyIdentifier: 'KEY789',
|
||||
issuerIdentifier: 'ISSUER012',
|
||||
appleTeam: {
|
||||
appleTeamIdentifier: 'TEAM123',
|
||||
appleTeamName: 'Test Team',
|
||||
},
|
||||
});
|
||||
(AppStoreConnectApiKeyQuery.getByIdAsync as jest.Mock).mockResolvedValue({
|
||||
keyP8: 'stored-key-p8',
|
||||
keyIdentifier: 'KEY789',
|
||||
issuerIdentifier: 'ISSUER012',
|
||||
});
|
||||
|
||||
const args = createBaseArgs();
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(AppStoreConnectApiKeyQuery.getByIdAsync).toHaveBeenCalledWith(
|
||||
args.graphqlClient,
|
||||
'asc-key-id'
|
||||
);
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: {
|
||||
keyP8: 'stored-key-p8',
|
||||
keyId: 'KEY789',
|
||||
issuerId: 'ISSUER012',
|
||||
},
|
||||
teamId: 'TEAM123',
|
||||
teamName: 'Test Team',
|
||||
})
|
||||
);
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('falls back to interactive cookie auth when no API key and interactive mode', async () => {
|
||||
const args = createBaseArgs({ nonInteractive: false });
|
||||
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
// Should call ensureAuthenticatedAsync without API key options
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith();
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when no API key is available', async () => {
|
||||
const args = createBaseArgs({ nonInteractive: true });
|
||||
|
||||
await expect(getAppStoreAuthAsync(args)).rejects.toThrow('No App Store Connect API Key found');
|
||||
});
|
||||
|
||||
it('falls back gracefully when credentials service lookup fails', async () => {
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
// Interactive mode: should fall back to cookie auth
|
||||
const args = createBaseArgs({ nonInteractive: false });
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith();
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('falls back gracefully when credentials service fails in non-interactive mode', async () => {
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const args = createBaseArgs({ nonInteractive: true });
|
||||
|
||||
await expect(getAppStoreAuthAsync(args)).rejects.toThrow('No App Store Connect API Key found');
|
||||
});
|
||||
|
||||
it('prefers submit profile key over credentials service', async () => {
|
||||
const profile = {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
ascApiKeyPath: '/path/to/key.p8',
|
||||
ascApiKeyId: 'PROFILE_KEY',
|
||||
ascApiKeyIssuerId: 'PROFILE_ISSUER',
|
||||
} as any;
|
||||
// Also set up a stored key that should NOT be used
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
|
||||
id: 'stored-key-id',
|
||||
keyIdentifier: 'STORED_KEY',
|
||||
issuerIdentifier: 'STORED_ISSUER',
|
||||
});
|
||||
|
||||
const args = createBaseArgs({ profile });
|
||||
await getAppStoreAuthAsync(args);
|
||||
|
||||
// Should use profile key, not the stored one
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ascApiKey: expect.objectContaining({ keyId: 'PROFILE_KEY' }),
|
||||
})
|
||||
);
|
||||
// Should NOT have called getByIdAsync since profile key was found first
|
||||
expect(AppStoreConnectApiKeyQuery.getByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { downloadMetadataAsync } from '../download';
|
||||
import { MetadataValidationError } from '../errors';
|
||||
|
||||
jest.mock('../auth', () => ({
|
||||
getAppStoreAuthAsync: jest.fn(() => ({
|
||||
app: { id: '123' },
|
||||
auth: { context: { token: 'mock' } },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../apple/tasks', () => ({
|
||||
createAppleTasks: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
jest.mock('../config/resolve', () => ({
|
||||
createAppleWriter: jest.fn(() => ({
|
||||
toSchema: jest.fn(() => ({ configVersion: 0 })),
|
||||
})),
|
||||
getStaticConfigFilePath: jest.fn(() => '/app/store.config.json'),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/telemetry', () => ({
|
||||
subscribeTelemetryAsync: jest.fn(() => ({
|
||||
unsubscribeTelemetry: jest.fn(),
|
||||
executionId: 'exec-123',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('fs-extra', () => ({
|
||||
pathExists: jest.fn(() => false),
|
||||
writeJSON: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
jest.mock('../../prompts', () => ({
|
||||
confirmAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
|
||||
|
||||
function createArgs(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { bundleIdentifier: 'com.example.app' } as any,
|
||||
exp: { slug: 'my-app', name: 'My App' } as any,
|
||||
analytics: {} as any,
|
||||
credentialsCtx: { appStore: { ensureAuthenticatedAsync: jest.fn() }, vcsClient: {} } as any,
|
||||
nonInteractive: false,
|
||||
graphqlClient: {} as any,
|
||||
projectId: 'test-project-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(downloadMetadataAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('skips overwrite prompt and auto-overwrites in non-interactive mode', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
await downloadMetadataAsync(createArgs({ nonInteractive: true }));
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeJSON).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts for overwrite in interactive mode when file exists', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
(confirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
await downloadMetadataAsync(createArgs({ nonInteractive: false }));
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('overwrite') })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws MetadataValidationError when user declines overwrite in interactive mode', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
(confirmAsync as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(downloadMetadataAsync(createArgs({ nonInteractive: false }))).rejects.toThrow(
|
||||
MetadataValidationError
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prompt when file does not exist', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await downloadMetadataAsync(createArgs({ nonInteractive: false }));
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { uploadMetadataAsync } from '../upload';
|
||||
import { MetadataValidationError } from '../errors';
|
||||
|
||||
jest.mock('../auth', () => ({
|
||||
getAppStoreAuthAsync: jest.fn(() => ({
|
||||
app: { id: '123' },
|
||||
auth: { context: { token: 'mock' } },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../apple/tasks', () => ({
|
||||
createAppleTasks: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
jest.mock('../config/resolve', () => ({
|
||||
createAppleReader: jest.fn(() => ({
|
||||
getVersion: jest.fn(() => ({ versionString: '1.0.0' })),
|
||||
})),
|
||||
loadConfigAsync: jest.fn(() => ({ configVersion: 0 })),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/telemetry', () => ({
|
||||
subscribeTelemetryAsync: jest.fn(() => ({
|
||||
unsubscribeTelemetry: jest.fn(),
|
||||
executionId: 'exec-123',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
jest.mock('../../prompts', () => ({
|
||||
confirmAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const { loadConfigAsync } = require('../config/resolve') as jest.Mocked<
|
||||
typeof import('../config/resolve')
|
||||
>;
|
||||
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
|
||||
|
||||
function createArgs(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { metadataPath: 'store.config.json', bundleIdentifier: 'com.example.app' } as any,
|
||||
exp: { slug: 'my-app', name: 'My App' } as any,
|
||||
analytics: {} as any,
|
||||
credentialsCtx: { appStore: { ensureAuthenticatedAsync: jest.fn() }, vcsClient: {} } as any,
|
||||
nonInteractive: false,
|
||||
graphqlClient: {} as any,
|
||||
projectId: 'test-project-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(uploadMetadataAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(loadConfigAsync as jest.Mock).mockResolvedValue({ configVersion: 0 });
|
||||
});
|
||||
|
||||
it('throws MetadataValidationError directly in non-interactive mode without prompting', async () => {
|
||||
(loadConfigAsync as jest.Mock).mockRejectedValue(
|
||||
new MetadataValidationError('validation errors found')
|
||||
);
|
||||
|
||||
await expect(uploadMetadataAsync(createArgs({ nonInteractive: true }))).rejects.toThrow(
|
||||
MetadataValidationError
|
||||
);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts user about validation errors in interactive mode', async () => {
|
||||
(loadConfigAsync as jest.Mock).mockRejectedValueOnce(
|
||||
new MetadataValidationError('validation errors found')
|
||||
);
|
||||
(confirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
// Second call (with skipValidation) succeeds
|
||||
(loadConfigAsync as jest.Mock).mockResolvedValueOnce({ configVersion: 0 });
|
||||
|
||||
await uploadMetadataAsync(createArgs({ nonInteractive: false }));
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('still want to push') })
|
||||
);
|
||||
});
|
||||
|
||||
it('re-throws validation error when user declines to continue in interactive mode', async () => {
|
||||
(loadConfigAsync as jest.Mock).mockRejectedValue(
|
||||
new MetadataValidationError('validation errors found')
|
||||
);
|
||||
(confirmAsync as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(uploadMetadataAsync(createArgs({ nonInteractive: false }))).rejects.toThrow(
|
||||
MetadataValidationError
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads successfully when config is valid', async () => {
|
||||
const result = await uploadMetadataAsync(createArgs());
|
||||
|
||||
expect(result).toHaveProperty('appleLink');
|
||||
expect(result.appleLink).toContain('appstoreconnect.apple.com');
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,22 @@ import { App, Session } from '@expo/apple-utils';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { SubmitProfile } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { getRequestContext } from '../credentials/ios/appstore/authenticate';
|
||||
import { AppLookupParams } from '../credentials/ios/api/graphql/types/AppLookupParams';
|
||||
import {
|
||||
Options as AuthOptions,
|
||||
getRequestContext,
|
||||
} from '../credentials/ios/appstore/authenticate';
|
||||
import { AppleTeamType, AuthenticationMode } from '../credentials/ios/appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../credentials/ios/appstore/resolveCredentials';
|
||||
import { MinimalAscApiKey } from '../credentials/ios/credentials';
|
||||
import { AppStoreConnectApiKeyQuery } from '../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log from '../log';
|
||||
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
export type MetadataAppStoreAuthentication = {
|
||||
@@ -32,20 +44,107 @@ async function resolveAppStoreBundleIdentifierAsync(
|
||||
return await getBundleIdentifierAsync(projectDir, exp, vcsClient);
|
||||
}
|
||||
|
||||
type ResolvedAscApiKey = {
|
||||
ascApiKey: MinimalAscApiKey;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to resolve an ASC API key from the submit profile or EAS credentials service.
|
||||
* Returns null if no key is available from these sources.
|
||||
*/
|
||||
async function tryResolveAscApiKeyAsync({
|
||||
profile,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
exp,
|
||||
bundleId,
|
||||
}: {
|
||||
profile: SubmitProfile;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
bundleId: string;
|
||||
}): Promise<ResolvedAscApiKey | null> {
|
||||
// 1. Check submit profile for ASC API key fields
|
||||
if ('ascApiKeyPath' in profile && 'ascApiKeyIssuerId' in profile && 'ascApiKeyId' in profile) {
|
||||
const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = profile;
|
||||
if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) {
|
||||
const keyP8 = await fs.promises.readFile(ascApiKeyPath, 'utf-8');
|
||||
// Also try to get teamId from the profile if available
|
||||
const teamId = 'appleTeamId' in profile ? (profile as any).appleTeamId : undefined;
|
||||
return {
|
||||
ascApiKey: { keyP8, keyId: ascApiKeyId, issuerId: ascApiKeyIssuerId },
|
||||
teamId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Look up stored credentials via EAS credentials service
|
||||
try {
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const appLookupParams: AppLookupParams = {
|
||||
account,
|
||||
projectName: exp.slug,
|
||||
bundleIdentifier: bundleId,
|
||||
};
|
||||
|
||||
// Import dynamically to avoid circular dependency issues
|
||||
const { getAscApiKeyForAppSubmissionsAsync } =
|
||||
await import('../credentials/ios/api/GraphqlClient');
|
||||
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, appLookupParams);
|
||||
|
||||
if (ascKeyFragment) {
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(
|
||||
graphqlClient,
|
||||
ascKeyFragment.id
|
||||
);
|
||||
return {
|
||||
ascApiKey: {
|
||||
keyP8: fullKey.keyP8,
|
||||
keyId: fullKey.keyIdentifier,
|
||||
issuerId: fullKey.issuerIdentifier,
|
||||
},
|
||||
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
|
||||
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If we can't look up credentials, that's fine — we'll fall back
|
||||
Log.warn(`Could not look up stored ASC API key: ${error.message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* To start syncing ASC entities, we need access to the apple utils App instance.
|
||||
* This resolves both the authentication and that App instance.
|
||||
*
|
||||
* Resolution order for authentication:
|
||||
* 1. ASC API key from environment variables (EXPO_ASC_API_KEY_PATH, etc.)
|
||||
* 2. ASC API key from submit profile (ascApiKeyPath, etc. in eas.json)
|
||||
* 3. ASC API key from EAS credentials service
|
||||
* 4. Interactive cookie auth (only when not in non-interactive mode)
|
||||
*/
|
||||
export async function getAppStoreAuthAsync({
|
||||
projectDir,
|
||||
profile,
|
||||
exp,
|
||||
credentialsCtx,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profile: SubmitProfile;
|
||||
exp: ExpoConfig;
|
||||
credentialsCtx: CredentialsContext;
|
||||
nonInteractive: boolean;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
}): Promise<MetadataAppStoreAuthentication> {
|
||||
const bundleId = await resolveAppStoreBundleIdentifierAsync(
|
||||
projectDir,
|
||||
@@ -54,10 +153,49 @@ export async function getAppStoreAuthAsync({
|
||||
credentialsCtx.vcsClient
|
||||
);
|
||||
|
||||
// Try to resolve an ASC API key from profile or credentials service
|
||||
const resolvedKey = await tryResolveAscApiKeyAsync({
|
||||
profile,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
exp,
|
||||
bundleId,
|
||||
});
|
||||
|
||||
if (resolvedKey || hasAscEnvVars()) {
|
||||
const authOptions: AuthOptions = {
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
...(resolvedKey
|
||||
? {
|
||||
ascApiKey: resolvedKey.ascApiKey,
|
||||
teamId: resolvedKey.teamId,
|
||||
teamName: resolvedKey.teamName,
|
||||
// Default to COMPANY_OR_ORGANIZATION to avoid prompting for team type
|
||||
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const authCtx = await credentialsCtx.appStore.ensureAuthenticatedAsync(authOptions);
|
||||
assert(authCtx.authState, 'Failed to authenticate with App Store Connect');
|
||||
|
||||
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
|
||||
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
|
||||
return { app, auth: authCtx.authState };
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'No App Store Connect API Key found. In non-interactive mode, provide one via:\n' +
|
||||
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
|
||||
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
|
||||
' - EAS credentials service: run `eas credentials` to set up an API key'
|
||||
);
|
||||
}
|
||||
|
||||
// Fall back to interactive cookie auth
|
||||
const authCtx = await credentialsCtx.appStore.ensureAuthenticatedAsync();
|
||||
assert(authCtx.authState, 'Failed to authenticate with App Store Connect');
|
||||
|
||||
// TODO: improve error handling by mentioning possible configuration errors
|
||||
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
|
||||
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createAppleWriter, getStaticConfigFilePath } from './config/resolve';
|
||||
import { MetadataDownloadError, MetadataValidationError } from './errors';
|
||||
import { subscribeTelemetryAsync } from './utils/telemetry';
|
||||
import { Analytics, MetadataEvent } from '../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import Log from '../log';
|
||||
import { confirmAsync } from '../prompts';
|
||||
@@ -24,23 +25,33 @@ export async function downloadMetadataAsync({
|
||||
exp,
|
||||
analytics,
|
||||
credentialsCtx,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profile: SubmitProfile;
|
||||
exp: ExpoConfig;
|
||||
analytics: Analytics;
|
||||
credentialsCtx: CredentialsContext;
|
||||
nonInteractive: boolean;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
}): Promise<string> {
|
||||
const filePath = getStaticConfigFilePath({ projectDir, profile });
|
||||
|
||||
const fileExists = await fs.pathExists(filePath);
|
||||
if (fileExists) {
|
||||
const filePathRelative = path.relative(projectDir, filePath);
|
||||
const overwrite = await confirmAsync({
|
||||
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
|
||||
});
|
||||
if (!overwrite) {
|
||||
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
|
||||
if (nonInteractive) {
|
||||
Log.log(`Overwriting existing store config at "${path.relative(projectDir, filePath)}".`);
|
||||
} else {
|
||||
const filePathRelative = path.relative(projectDir, filePath);
|
||||
const overwrite = await confirmAsync({
|
||||
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
|
||||
});
|
||||
if (!overwrite) {
|
||||
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +60,9 @@ export async function downloadMetadataAsync({
|
||||
credentialsCtx,
|
||||
projectDir,
|
||||
profile,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const { unsubscribeTelemetry, executionId } = await subscribeTelemetryAsync(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MetadataConfig } from './config/schema';
|
||||
import { MetadataUploadError, MetadataValidationError, logMetadataValidationError } from './errors';
|
||||
import { subscribeTelemetryAsync } from './utils/telemetry';
|
||||
import { Analytics, MetadataEvent } from '../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import Log from '../log';
|
||||
import { confirmAsync } from '../prompts';
|
||||
@@ -23,19 +24,32 @@ export async function uploadMetadataAsync({
|
||||
exp,
|
||||
analytics,
|
||||
credentialsCtx,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profile: SubmitProfile;
|
||||
exp: ExpoConfig;
|
||||
analytics: Analytics;
|
||||
credentialsCtx: CredentialsContext;
|
||||
nonInteractive: boolean;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
}): Promise<{ appleLink: string }> {
|
||||
const storeConfig = await loadConfigWithValidationPromptAsync(projectDir, profile);
|
||||
const storeConfig = await loadConfigWithValidationPromptAsync(
|
||||
projectDir,
|
||||
profile,
|
||||
nonInteractive
|
||||
);
|
||||
const { app, auth } = await getAppStoreAuthAsync({
|
||||
exp,
|
||||
credentialsCtx,
|
||||
projectDir,
|
||||
profile,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const { unsubscribeTelemetry, executionId } = await subscribeTelemetryAsync(
|
||||
@@ -84,12 +98,18 @@ export async function uploadMetadataAsync({
|
||||
|
||||
async function loadConfigWithValidationPromptAsync(
|
||||
projectDir: string,
|
||||
profile: SubmitProfile
|
||||
profile: SubmitProfile,
|
||||
nonInteractive: boolean
|
||||
): Promise<MetadataConfig> {
|
||||
try {
|
||||
return await loadConfigAsync({ projectDir, profile });
|
||||
} catch (error) {
|
||||
if (error instanceof MetadataValidationError) {
|
||||
if (nonInteractive) {
|
||||
logMetadataValidationError(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
logMetadataValidationError(error);
|
||||
Log.newLine();
|
||||
Log.warn(
|
||||
|
||||
@@ -137,21 +137,81 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
|
||||
})
|
||||
).rejects.toThrowError(/we can't update this file programmatically/);
|
||||
});
|
||||
it('throws an error in non-interactive mode', async () => {
|
||||
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp' } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.jester.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
|
||||
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
|
||||
expect(appJson).toMatchObject({
|
||||
expo: { android: { package: 'com.jester.myapp' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "blah": {} }',
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.example.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
await expect(
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '',
|
||||
exp: {} as any,
|
||||
projectId: '1234',
|
||||
exp: { slug: '' } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
})
|
||||
|
||||
@@ -148,15 +148,8 @@ async function configureApplicationIdAsync({
|
||||
exp: ExpoConfig;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<string> {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#package'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
|
||||
// we can't automatically update app.config.js
|
||||
if (paths.dynamicConfigPath) {
|
||||
throw new Error(
|
||||
@@ -166,25 +159,50 @@ async function configureApplicationIdAsync({
|
||||
|
||||
assert(paths.staticConfigPath, 'app.json must exist');
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/android-package')
|
||||
)}`
|
||||
);
|
||||
let packageName: string;
|
||||
|
||||
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
const { packageName } = await promptAsync({
|
||||
name: 'packageName',
|
||||
type: 'text',
|
||||
message: `What would you like your Android application id to be?`,
|
||||
initial: suggestedAndroidApplicationId,
|
||||
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
|
||||
});
|
||||
if (nonInteractive) {
|
||||
let suggestedAndroidApplicationId: string | undefined;
|
||||
try {
|
||||
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
} catch {
|
||||
// If we can't get a suggestion, we'll throw the non-interactive error below
|
||||
}
|
||||
if (!suggestedAndroidApplicationId) {
|
||||
throw new Error(
|
||||
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#package'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
packageName = suggestedAndroidApplicationId;
|
||||
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
|
||||
} else {
|
||||
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/android-package')
|
||||
)}`
|
||||
);
|
||||
|
||||
const result = await promptAsync({
|
||||
name: 'packageName',
|
||||
type: 'text',
|
||||
message: `What would you like your Android application id to be?`,
|
||||
initial: suggestedAndroidApplicationId,
|
||||
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
|
||||
});
|
||||
packageName = result.packageName;
|
||||
}
|
||||
|
||||
const rawStaticConfig = readAppJson(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
|
||||
@@ -115,20 +115,81 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
|
||||
})
|
||||
).rejects.toThrowError(/we can't update this file programmatically/);
|
||||
});
|
||||
it('throws an error in non-interactive mode', async () => {
|
||||
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "blah": {} }',
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp' } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.jester.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
|
||||
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
|
||||
expect(appJson).toMatchObject({
|
||||
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.example.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
await expect(
|
||||
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: {} as any,
|
||||
exp: { slug: '' } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
})
|
||||
|
||||
@@ -137,15 +137,8 @@ async function configureBundleIdentifierAsync({
|
||||
exp: ExpoConfig;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<string> {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
|
||||
// we can't automatically update app.config.js
|
||||
if (paths.dynamicConfigPath) {
|
||||
throw new Error(
|
||||
@@ -155,26 +148,51 @@ async function configureBundleIdentifierAsync({
|
||||
|
||||
assert(paths.staticConfigPath, 'app.json must exist');
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/bundle-identifier')
|
||||
)}`
|
||||
);
|
||||
let bundleIdentifier: string;
|
||||
|
||||
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
if (nonInteractive) {
|
||||
let suggestedBundleIdentifier: string | undefined;
|
||||
try {
|
||||
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
} catch {
|
||||
// If we can't get a suggestion, we'll throw the non-interactive error below
|
||||
}
|
||||
if (!suggestedBundleIdentifier) {
|
||||
throw new Error(
|
||||
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
bundleIdentifier = suggestedBundleIdentifier;
|
||||
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
|
||||
} else {
|
||||
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/bundle-identifier')
|
||||
)}`
|
||||
);
|
||||
|
||||
const { bundleIdentifier } = await promptAsync({
|
||||
name: 'bundleIdentifier',
|
||||
type: 'text',
|
||||
message: `What would you like your iOS bundle identifier to be?`,
|
||||
initial: suggestedBundleIdentifier,
|
||||
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
|
||||
});
|
||||
const result = await promptAsync({
|
||||
name: 'bundleIdentifier',
|
||||
type: 'text',
|
||||
message: `What would you like your iOS bundle identifier to be?`,
|
||||
initial: suggestedBundleIdentifier,
|
||||
validate: value =>
|
||||
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
|
||||
});
|
||||
bundleIdentifier = result.bundleIdentifier;
|
||||
}
|
||||
|
||||
const rawStaticConfig = readAppJson(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
|
||||
@@ -1,24 +1,86 @@
|
||||
import assert from 'assert';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
import crypto from 'crypto';
|
||||
import http from 'http';
|
||||
import { Socket } from 'node:net';
|
||||
import querystring from 'querystring';
|
||||
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../api';
|
||||
import fetch from '../fetch';
|
||||
import Log from '../log';
|
||||
|
||||
const CLIENT_ID = 'eas-cli';
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function generateCodeChallenge(codeVerifier: string): string {
|
||||
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
async function exchangeCodeForSessionSecretAsync({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
}: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<string> {
|
||||
const tokenUrl = `${getExpoApiBaseUrl()}/v2/auth/token`;
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
});
|
||||
|
||||
const result: any = await response.json();
|
||||
const sessionSecret = result?.data?.session_secret;
|
||||
if (!sessionSecret) {
|
||||
throw new Error('Failed to obtain session secret from token exchange.');
|
||||
}
|
||||
return sessionSecret;
|
||||
}
|
||||
|
||||
export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Promise<string> {
|
||||
const scheme = 'http';
|
||||
const hostname = 'localhost';
|
||||
const path = '/auth/callback';
|
||||
const callbackPath = '/auth/callback';
|
||||
|
||||
const expoWebsiteUrl = getExpoWebsiteBaseUrl();
|
||||
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
|
||||
const buildRedirectUri = (port: number): string =>
|
||||
`${scheme}://${hostname}:${port}${callbackPath}`;
|
||||
|
||||
const buildExpoLoginUrl = (port: number, sso: boolean): string => {
|
||||
const params = querystring.stringify({
|
||||
confirm_account: true,
|
||||
app_redirect_uri: `${scheme}://${hostname}:${port}${path}`,
|
||||
});
|
||||
// Note: we avoid URLSearchParams here because better-opn calls encodeURI()
|
||||
// on the URL before passing it to AppleScript, which would double-encode
|
||||
// the percent-encoded values from URLSearchParams.toString().
|
||||
const params = [
|
||||
`client_id=${CLIENT_ID}`,
|
||||
`redirect_uri=${buildRedirectUri(port)}`,
|
||||
`response_type=code`,
|
||||
`code_challenge=${codeChallenge}`,
|
||||
`code_challenge_method=S256`,
|
||||
`state=${state}`,
|
||||
`confirm_account=true`,
|
||||
].join('&');
|
||||
return `${expoWebsiteUrl}${sso ? '/sso-login' : '/login'}?${params}`;
|
||||
};
|
||||
|
||||
@@ -39,22 +101,39 @@ export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Prom
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const handleRequestAsync = async (): Promise<void> => {
|
||||
if (!(request.method === 'GET' && request.url?.includes('/auth/callback'))) {
|
||||
throw new Error('Unexpected login response.');
|
||||
}
|
||||
const url = new URL(request.url, `http:${request.headers.host}`);
|
||||
const sessionSecret = url.searchParams.get('session_secret');
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
|
||||
if (!sessionSecret) {
|
||||
throw new Error('Request missing session_secret search parameter.');
|
||||
if (!code) {
|
||||
throw new Error('Request missing code search parameter.');
|
||||
}
|
||||
if (returnedState !== state) {
|
||||
throw new Error('State mismatch. Possible CSRF attack.');
|
||||
}
|
||||
|
||||
const address = server.address();
|
||||
assert(address !== null && typeof address === 'object');
|
||||
const redirectUri = buildRedirectUri(address.port);
|
||||
|
||||
const sessionSecret = await exchangeCodeForSessionSecretAsync({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
resolve(sessionSecret);
|
||||
redirectAndCleanup('success');
|
||||
} catch (error) {
|
||||
};
|
||||
|
||||
handleRequestAsync().catch(error => {
|
||||
redirectAndCleanup('error');
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ class PrettyStream extends Writable {
|
||||
'pid',
|
||||
'hostname',
|
||||
'name',
|
||||
'buildStepInternalId',
|
||||
'buildStepId',
|
||||
'buildStepDisplayName',
|
||||
]);
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'assert';
|
||||
import { Buffer } from 'buffer';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext';
|
||||
@@ -122,6 +121,7 @@ export class BuildStepOutputAccessor {
|
||||
}
|
||||
|
||||
export class BuildStep extends BuildStepOutputAccessor {
|
||||
private static nextGeneratedId = 1;
|
||||
public readonly id: string;
|
||||
public readonly displayName: string;
|
||||
public readonly supportedRuntimePlatforms?: BuildRuntimePlatform[];
|
||||
@@ -139,12 +139,11 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
private readonly outputsDir: string;
|
||||
private readonly envsDir: string;
|
||||
|
||||
private readonly internalId: string;
|
||||
private readonly inputById: BuildStepInputById;
|
||||
protected executed = false;
|
||||
|
||||
public static getNewId(userDefinedId?: string): string {
|
||||
return userDefinedId ?? uuidv4();
|
||||
return userDefinedId ?? `step-${String(BuildStep.nextGeneratedId++).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -198,10 +197,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
this.__metricsId = __metricsId;
|
||||
this.status = BuildStepStatus.NEW;
|
||||
|
||||
this.internalId = uuidv4();
|
||||
|
||||
const logger = ctx.baseLogger.child({
|
||||
buildStepInternalId: this.internalId,
|
||||
buildStepId: this.id,
|
||||
buildStepDisplayName: this.displayName,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from 'path';
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { UUID_REGEX } from './utils/uuid';
|
||||
import { BuildConfigParser } from '../BuildConfigParser';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildStep } from '../BuildStep';
|
||||
@@ -65,8 +64,6 @@ describe('Publish Update job', () => {
|
||||
});
|
||||
|
||||
function assertStepsAreMatching(step1: BuildStep, step2: BuildStep): void {
|
||||
expect(step1.id).toMatch(UUID_REGEX);
|
||||
expect(step2.id).toMatch(UUID_REGEX);
|
||||
expect(step1.displayName).toEqual(step2.displayName);
|
||||
expect(step1.inputs).toStrictEqual(step2.inputs);
|
||||
expect(step1.outputById).toStrictEqual(step2.outputById);
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { getError, getErrorAsync } from './utils/error';
|
||||
import { UUID_REGEX } from './utils/uuid';
|
||||
import { GENERATED_STEP_ID_REGEX } from './utils/stepId';
|
||||
import { BuildConfigParser } from '../BuildConfigParser';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildFunctionGroup } from '../BuildFunctionGroup';
|
||||
@@ -112,7 +112,7 @@ describe(BuildConfigParser, () => {
|
||||
|
||||
// - run: echo "Hi!"
|
||||
const step1 = buildSteps[0];
|
||||
expect(step1.id).toMatch(UUID_REGEX);
|
||||
expect(step1.id).toMatch(GENERATED_STEP_ID_REGEX);
|
||||
expect(step1.displayName).toBe('echo "Hi!"');
|
||||
expect(step1.command).toBe('echo "Hi!"');
|
||||
expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -128,7 +128,6 @@ describe(BuildConfigParser, () => {
|
||||
// echo "L"
|
||||
// echo "O"
|
||||
const step2 = buildSteps[1];
|
||||
expect(step2.id).toMatch(UUID_REGEX);
|
||||
expect(step2.displayName).toBe('Say HELLO');
|
||||
expect(step2.command).toMatchSnapshot();
|
||||
expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -157,7 +156,6 @@ describe(BuildConfigParser, () => {
|
||||
// working_directory: relative/path/to/files
|
||||
// command: ls -la
|
||||
const step4 = buildSteps[3];
|
||||
expect(step4.id).toMatch(UUID_REGEX);
|
||||
expect(step4.displayName).toBe('List files');
|
||||
expect(step4.command).toBe('ls -la');
|
||||
expect(step4.ctx.workingDirectory).toBe(
|
||||
@@ -171,7 +169,6 @@ describe(BuildConfigParser, () => {
|
||||
// working_directory: /home/dsokal
|
||||
// command: ls -la
|
||||
const step5 = buildSteps[4];
|
||||
expect(step5.id).toMatch(UUID_REGEX);
|
||||
expect(step5.displayName).toBe('List files in another directory');
|
||||
expect(step5.command).toBe('ls -la');
|
||||
expect(step5.ctx.workingDirectory).toBe(
|
||||
@@ -186,7 +183,6 @@ describe(BuildConfigParser, () => {
|
||||
// shell: /nib/hsab
|
||||
// command: echo 123
|
||||
const step6 = buildSteps[5];
|
||||
expect(step6.id).toMatch(UUID_REGEX);
|
||||
expect(step6.displayName).toBe('Use non-default shell');
|
||||
expect(step6.command).toBe('echo 123');
|
||||
expect(step6.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -219,7 +215,6 @@ describe(BuildConfigParser, () => {
|
||||
// property3: value4
|
||||
// command: echo "Hi, ${ inputs.name }, ${ inputs.boolean_value }!"
|
||||
const step1 = buildSteps[0];
|
||||
expect(step1.id).toMatch(UUID_REGEX);
|
||||
expect(step1.displayName).toBe('Say HI');
|
||||
expect(step1.command).toBe('echo "Hi, ${ inputs.name }, ${ inputs.boolean_value }!"');
|
||||
expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -270,7 +265,6 @@ describe(BuildConfigParser, () => {
|
||||
// set-output first_name Brent
|
||||
// set-output last_name Vatne
|
||||
const step1 = buildSteps[0];
|
||||
expect(step1.id).toMatch(UUID_REGEX);
|
||||
expect(step1.displayName).toBe('set-output first_name Brent');
|
||||
expect(step1.command).toMatchSnapshot();
|
||||
expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -294,7 +288,6 @@ describe(BuildConfigParser, () => {
|
||||
// set-output last_name Sokal
|
||||
// set-output nickname dsokal
|
||||
const step2 = buildSteps[1];
|
||||
expect(step2.id).toMatch(UUID_REGEX);
|
||||
expect(step2.displayName).toBe('set-output first_name Dominik');
|
||||
expect(step2.command).toMatchSnapshot();
|
||||
expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -333,7 +326,6 @@ describe(BuildConfigParser, () => {
|
||||
// - aaa
|
||||
// - bbb
|
||||
const step1 = buildSteps[0];
|
||||
expect(step1.id).toMatch(UUID_REGEX);
|
||||
expect(step1.displayName).toBe('Hi!');
|
||||
expect(step1.command).toBe('echo "Hi, ${ inputs.name }!"');
|
||||
expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -365,7 +357,6 @@ describe(BuildConfigParser, () => {
|
||||
// name: Szymon
|
||||
// build_number: 122
|
||||
const step2 = buildSteps[1];
|
||||
expect(step2.id).toMatch(UUID_REGEX);
|
||||
expect(step2.displayName).toBe('Hi, Szymon!');
|
||||
expect(step2.command).toBe('echo "Hi, ${ inputs.name }!"');
|
||||
expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -392,7 +383,6 @@ describe(BuildConfigParser, () => {
|
||||
|
||||
// - say_hi_wojtek
|
||||
const step3 = buildSteps[2];
|
||||
expect(step3.id).toMatch(UUID_REGEX);
|
||||
expect(step3.displayName).toBe('Hi, Wojtek!');
|
||||
expect(step3.command).toBe('echo "Hi, Wojtek!"');
|
||||
expect(step3.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -416,7 +406,6 @@ describe(BuildConfigParser, () => {
|
||||
// inputs:
|
||||
// value: ${ steps.random_number.value }
|
||||
const step5 = buildSteps[4];
|
||||
expect(step5.id).toMatch(UUID_REGEX);
|
||||
expect(step5.displayName).toBe('print');
|
||||
expect(step5.command).toBe('echo "${ inputs.value }"');
|
||||
expect(step5.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -431,7 +420,6 @@ describe(BuildConfigParser, () => {
|
||||
// greeting: Hello
|
||||
// num: 123
|
||||
const step6 = buildSteps[5];
|
||||
expect(step6.id).toMatch(UUID_REGEX);
|
||||
expect(step6.displayName).toBe('Hi!');
|
||||
expect(step6.command).toBe('echo "${ inputs.greeting }, ${ inputs.name }!"');
|
||||
expect(step6.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
|
||||
@@ -644,12 +632,11 @@ describe(BuildConfigParser, () => {
|
||||
|
||||
// - eas/download_project
|
||||
const step1 = workflow.buildSteps[0];
|
||||
expect(step1.id).toMatch(UUID_REGEX);
|
||||
expect(step1.id).toMatch(GENERATED_STEP_ID_REGEX);
|
||||
expect(step1.fn).toBe(downloadProjectFn);
|
||||
|
||||
// - eas/build_project
|
||||
const step2 = workflow.buildSteps[1];
|
||||
expect(step2.id).toMatch(UUID_REGEX);
|
||||
expect(step2.fn).toBe(buildProjectFn);
|
||||
});
|
||||
|
||||
@@ -726,37 +713,32 @@ describe(BuildConfigParser, () => {
|
||||
|
||||
// - eas/download_project
|
||||
const step1 = workflow.buildSteps[0];
|
||||
expect(step1.id).toMatch(UUID_REGEX);
|
||||
expect(step1.id).toMatch(GENERATED_STEP_ID_REGEX);
|
||||
expect(step1.fn).toBe(downloadProjectFn);
|
||||
|
||||
// - eas/build_project
|
||||
const step2 = workflow.buildSteps[1];
|
||||
expect(step2.id).toMatch(UUID_REGEX);
|
||||
expect(step2.id).toMatch(GENERATED_STEP_ID_REGEX);
|
||||
expect(step2.fn).toBe(buildProjectFn);
|
||||
|
||||
// - eas/download_project3 originating from build group eas/build
|
||||
const step3 = workflow.buildSteps[2];
|
||||
expect(step3.id).toMatch(UUID_REGEX);
|
||||
expect(step3.fn).toBe(downloadProjectFn);
|
||||
|
||||
// - eas/build_project4 originating from build group eas/build
|
||||
const step4 = workflow.buildSteps[3];
|
||||
expect(step4.id).toMatch(UUID_REGEX);
|
||||
expect(step4.fn).toBe(buildProjectFn);
|
||||
|
||||
// - eas/download_project5 originating from submit group eas/submit
|
||||
const step5 = workflow.buildSteps[4];
|
||||
expect(step5.id).toMatch(UUID_REGEX);
|
||||
expect(step5.fn).toBe(downloadProjectFn);
|
||||
|
||||
// - eas/build_project6 originating from submit group eas/submit
|
||||
const step6 = workflow.buildSteps[5];
|
||||
expect(step6.id).toMatch(UUID_REGEX);
|
||||
expect(step6.fn).toBe(buildProjectFn);
|
||||
|
||||
// - eas/test7 originating from submit group eas/submit
|
||||
const step7 = workflow.buildSteps[6];
|
||||
expect(step7.id).toMatch(UUID_REGEX);
|
||||
expect(step7.fn).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { UUID_REGEX } from './utils/uuid';
|
||||
import { GENERATED_STEP_ID_REGEX } from './utils/stepId';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildStep, BuildStepFunction } from '../BuildStep';
|
||||
import {
|
||||
@@ -86,7 +86,7 @@ describe(BuildFunction, () => {
|
||||
workingDirectory: ctx.defaultWorkingDirectory,
|
||||
});
|
||||
expect(step).toBeInstanceOf(BuildStep);
|
||||
expect(step.id).toMatch(UUID_REGEX);
|
||||
expect(step.id).toMatch(GENERATED_STEP_ID_REGEX);
|
||||
expect(step.displayName).toBe('Test function');
|
||||
expect(step.command).toBe('echo 123');
|
||||
});
|
||||
@@ -102,7 +102,6 @@ describe(BuildFunction, () => {
|
||||
workingDirectory: ctx.defaultWorkingDirectory,
|
||||
});
|
||||
expect(step).toBeInstanceOf(BuildStep);
|
||||
expect(step.id).toMatch(UUID_REGEX);
|
||||
expect(step.displayName).toBe('Test function');
|
||||
expect(step.fn).toBe(fn);
|
||||
});
|
||||
@@ -117,7 +116,6 @@ describe(BuildFunction, () => {
|
||||
workingDirectory: ctx.defaultWorkingDirectory,
|
||||
});
|
||||
expect(step).toBeInstanceOf(BuildStep);
|
||||
expect(step.id).toMatch(UUID_REGEX);
|
||||
expect(step.displayName).toBe('Test function');
|
||||
expect(step.fn).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { instance, mock, verify, when } from 'ts-mockito';
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { getError, getErrorAsync } from './utils/error';
|
||||
import { createMockLogger } from './utils/logger';
|
||||
import { UUID_REGEX } from './utils/uuid';
|
||||
import { GENERATED_STEP_ID_REGEX } from './utils/stepId';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildRuntimePlatform } from '../BuildRuntimePlatform';
|
||||
import { BuildStep, BuildStepFunction, BuildStepStatus } from '../BuildStep';
|
||||
@@ -20,8 +20,15 @@ import { spawnAsync } from '../utils/shell/spawn';
|
||||
|
||||
describe(BuildStep, () => {
|
||||
describe(BuildStep.getNewId, () => {
|
||||
it('returns a uuid if the user-defined id is undefined', () => {
|
||||
expect(BuildStep.getNewId()).toMatch(UUID_REGEX);
|
||||
it('returns a generated step id if the user-defined id is undefined', () => {
|
||||
expect(BuildStep.getNewId()).toMatch(GENERATED_STEP_ID_REGEX);
|
||||
});
|
||||
it('returns unique generated step ids', () => {
|
||||
const stepId1 = BuildStep.getNewId();
|
||||
const stepId2 = BuildStep.getNewId();
|
||||
expect(stepId1).not.toBe(stepId2);
|
||||
expect(stepId1).toMatch(GENERATED_STEP_ID_REGEX);
|
||||
expect(stepId2).toMatch(GENERATED_STEP_ID_REGEX);
|
||||
});
|
||||
it('returns the user-defined id if defined', () => {
|
||||
expect(BuildStep.getNewId('test1')).toBe('test1');
|
||||
@@ -170,7 +177,6 @@ describe(BuildStep, () => {
|
||||
});
|
||||
expect(ctx.baseLogger.child).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildStepInternalId: expect.stringMatching(UUID_REGEX),
|
||||
buildStepId: 'test1',
|
||||
buildStepDisplayName: 'Test step',
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from 'path';
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { getError } from './utils/error';
|
||||
import { UUID_REGEX } from './utils/uuid';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildFunctionGroup } from '../BuildFunctionGroup';
|
||||
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
|
||||
@@ -272,7 +271,6 @@ describe(StepsConfigParser, () => {
|
||||
expect(result.buildSteps).toHaveLength(4);
|
||||
|
||||
const step1 = result.buildSteps[0];
|
||||
expect(step1.id).toMatch(UUID_REGEX);
|
||||
expect(step1.displayName).toBe('command1');
|
||||
expect(step1.command).toBe('command1');
|
||||
expect(step1.shell).toBe('/bin/bash -eo pipefail');
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const GENERATED_STEP_ID_REGEX = /^step-\d{3,}$/;
|
||||
@@ -1,2 +0,0 @@
|
||||
export const UUID_REGEX =
|
||||
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PRECOMPILED_MODULES_PATH } from '@expo/build-tools';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
|
||||
import config from '../config';
|
||||
@@ -33,4 +34,28 @@ describe(getBuildEnv.name, () => {
|
||||
|
||||
expect(env.EAS_CLI_SENTRY_DSN).toBe(config.sentry.dsn);
|
||||
});
|
||||
|
||||
it('enables precompiled modules env vars for flagged iOS jobs', () => {
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
builderEnvironment: {
|
||||
env: {
|
||||
EAS_USE_PRECOMPILED_MODULES: '1',
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
|
||||
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBe(PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import { BuildContext } from '@expo/build-tools';
|
||||
import { Android, Ios, Job } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import { pathExists } from 'fs-extra';
|
||||
|
||||
import { prepareRuntimeEnvironment } from '../runtimeEnvironment';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
@@ -16,8 +14,12 @@ const spawnResult: SpawnResult = {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
};
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: process.env,
|
||||
const ctx = {
|
||||
workingdir: '/tmp/workingdir',
|
||||
env: {
|
||||
...process.env,
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
},
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
|
||||
import {
|
||||
Artifacts,
|
||||
BuildContext,
|
||||
Builders,
|
||||
maybeStartPreparingPrecompiledModules,
|
||||
runGenericJobAsync,
|
||||
} from '@expo/build-tools';
|
||||
import {
|
||||
Android,
|
||||
BuildJob,
|
||||
@@ -41,6 +47,7 @@ export async function build({
|
||||
{ job: omit(ctx.job, 'secrets', 'projectArchive') },
|
||||
'Builder is ready, starting build'
|
||||
);
|
||||
maybeStartPreparingPrecompiledModules(ctx, config);
|
||||
},
|
||||
{ doNotMarkStart: true }
|
||||
);
|
||||
|
||||
@@ -98,6 +98,10 @@ export default {
|
||||
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
precompiledModulesUrls: [
|
||||
'https://storage.googleapis.com/turtle-v2/precompiled-modules/db65b4afac835ff71269ce53937fb20627b133c0/xcframeworks-Debug.zip',
|
||||
'https://storage.googleapis.com/turtle-v2/precompiled-modules/db65b4afac835ff71269ce53937fb20627b133c0/xcframeworks-Release.zip',
|
||||
],
|
||||
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('runMetricsServer'),
|
||||
defaultValue: null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PRECOMPILED_MODULES_PATH, shouldUsePrecompiledDependencies } from '@expo/build-tools';
|
||||
import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { spawnSync } from 'child_process';
|
||||
import micromatch from 'micromatch';
|
||||
@@ -53,6 +54,10 @@ export function getBuildEnv({
|
||||
if (runnerPlatform === Platform.IOS) {
|
||||
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
|
||||
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
|
||||
if (shouldUsePrecompiledDependencies(job.builderEnvironment?.env ?? {})) {
|
||||
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
|
||||
setEnv(env, 'EXPO_PRECOMPILED_MODULES_PATH', PRECOMPILED_MODULES_PATH);
|
||||
}
|
||||
|
||||
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
|
||||
setEnv(env, 'USE_CCACHE', '1');
|
||||
|
||||
+11
-9
@@ -41,7 +41,7 @@ export async function logProjectDependenciesAsync(
|
||||
(packageJSON?.devDependencies as Record<string, string> | undefined) ?? {};
|
||||
const projectDirectory = ctx.getReactNativeProjectDirectory();
|
||||
const plugins = filterSecretsAndParsePlugins(
|
||||
ctx.appConfig.plugins,
|
||||
(await ctx.appConfig).plugins,
|
||||
ctx.job.secrets?.environmentSecrets
|
||||
);
|
||||
|
||||
@@ -71,14 +71,14 @@ export async function logProjectDependenciesAsync(
|
||||
cwd: projectDirectory,
|
||||
env: ctx.env,
|
||||
}),
|
||||
jsEngine: resolveJsEngine(ctx, dependencies),
|
||||
jsEngine: await resolveJsEngine(ctx, dependencies),
|
||||
newArchEnabled: await resolveNewArchEnabled(ctx, dependencies['react-native']),
|
||||
source: 'Turtle Worker',
|
||||
plugins,
|
||||
babelPlugins,
|
||||
iosAssociatedDomains: ctx.appConfig.ios?.associatedDomains,
|
||||
// @ts-expect-error - ApiObject is not exactly the same as AndroidIntentFiltersData though they're compatible
|
||||
androidIntentFilters: ctx.appConfig.android?.intentFilters,
|
||||
iosAssociatedDomains: (await ctx.appConfig).ios?.associatedDomains,
|
||||
// NOTE: ApiObject is not exactly the same as AndroidIntentFiltersData though they're compatible
|
||||
androidIntentFilters: (await ctx.appConfig).android?.intentFilters as any,
|
||||
});
|
||||
} catch (error: any) {
|
||||
sentry.handleError('Failed to report project dependencies metrics', error, {
|
||||
@@ -89,11 +89,12 @@ export async function logProjectDependenciesAsync(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveJsEngine(
|
||||
async function resolveJsEngine(
|
||||
ctx: BuildContext<BuildJob>,
|
||||
dependencies: Record<string, string>
|
||||
): 'jsc' | 'hermes' | 'v8' | undefined {
|
||||
const { job, appConfig } = ctx;
|
||||
): Promise<'jsc' | 'hermes' | 'v8' | undefined> {
|
||||
const { job } = ctx;
|
||||
const appConfig = await ctx.appConfig;
|
||||
const appConfigJsEngine = appConfig?.[job.platform]?.jsEngine ?? appConfig.jsEngine;
|
||||
if (job.type === Workflow.GENERIC || !ctx.metadata?.sdkVersion) {
|
||||
return undefined;
|
||||
@@ -120,7 +121,8 @@ async function resolveNewArchEnabled(
|
||||
ctx: BuildContext<BuildJob>,
|
||||
reactNativeVersion: string | undefined
|
||||
): Promise<boolean | undefined> {
|
||||
const { job, appConfig } = ctx;
|
||||
const { job } = ctx;
|
||||
const appConfig = await ctx.appConfig;
|
||||
|
||||
// Support for disabling the new architecture was removed in react-native 0.83
|
||||
if (
|
||||
|
||||
@@ -2691,12 +2691,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/apple-utils@npm:2.1.13":
|
||||
version: 2.1.13
|
||||
resolution: "@expo/apple-utils@npm:2.1.13"
|
||||
"@expo/apple-utils@npm:2.1.15":
|
||||
version: 2.1.15
|
||||
resolution: "@expo/apple-utils@npm:2.1.15"
|
||||
bin:
|
||||
apple-utils: bin.js
|
||||
checksum: 10c0/38f62fcd59c901a3f7c372034ea0a2d0275dd22a2c22c3b408e1a151014760f3cc8a9e420de13a9836439fe78b90d23ee9f31113a0ee040094c4dd8ea6b4ff45
|
||||
checksum: 10c0/956abdb77d1d3bcb09d42b70745705305d23b9aa4ed50e3da795e93e50dc3ada126cc16f73a55b49538f0d793ccdc455bad0caf7aaadbadd5ee7a3d0f3c69465
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10839,7 +10839,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli@workspace:packages/eas-cli"
|
||||
dependencies:
|
||||
"@expo/apple-utils": "npm:2.1.13"
|
||||
"@expo/apple-utils": "npm:2.1.15"
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:10.0.6"
|
||||
"@expo/config-plugins": "npm:9.0.12"
|
||||
|
||||
Reference in new issue
Block a user