Compare commits

..
Author SHA1 Message Date
Krystof Woldrich eb3caef49b feat(simulator): expose topic commands 2026-05-20 16:24:09 +02:00
130 changed files with 1753 additions and 11307 deletions

No files matched your search

-51
View File
@@ -8,61 +8,10 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman))
### 🎉 New features
- [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Fix `eas/start_ios_simulator` hanging on Xcode 26.4 by writing the readiness screenshot to a temp file instead of `/dev/null`. ([#3794](https://github.com/expo/eas-cli/pull/3794) by [@gwdp](https://github.com/gwdp))
- [build-tools] Disable `apsd` in the iOS Simulator after boot to stop a push-registration log storm that pegs `diagnosticd` CPU for the lifetime of the Simulator. ([#3795](https://github.com/expo/eas-cli/pull/3795) by [@gwdp](https://github.com/gwdp))
## [19.1.0](https://github.com/expo/eas-cli/releases/tag/v19.1.0) - 2026-05-25
### 🎉 New features
- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
- [eas-build-job] Add optional `refreshAdHocProvisioningProfile` field to iOS build jobs. ([#3717](https://github.com/expo/eas-cli/pull/3717) by [@sswrk](https://github.com/sswrk))
- [build-tools] Pass `refreshAdHocProvisioningProfile` through `eas build:internal` for git-based integration builds. ([#3770](https://github.com/expo/eas-cli/pull/3770) by [@sswrk](https://github.com/sswrk))
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
### 🐛 Bug fixes
- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
### 🐛 Bug fixes
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "20.1.0",
"version": "19.0.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "20.1.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -37,20 +37,19 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "20.0.0",
"@expo/logger": "19.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.1.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/steps": "19.0.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
@@ -22,13 +22,13 @@ describe('Datadog singleton', () => {
Datadog.setup(null);
});
it('POSTs distribution metrics to the turtle build metrics endpoint for build targets', () => {
it('POSTs distribution metrics to the turtle build metrics endpoint', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1234, {
@@ -61,118 +61,20 @@ describe('Datadog singleton', () => {
);
});
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.workflow.maestro_cli_version', 1, {
maestro_version: '2.1.0',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.workflow.maestro_cli_version',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched', {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/logs',
'POST',
{
json: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)', {
cache_type: 'gradle',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
{
json: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: {
cache_type: 'gradle',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('is a no-op when setup is null', () => {
Datadog.setup(null);
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows metric upload failures for build targets', async () => {
it('swallows upload failures', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
@@ -190,103 +92,24 @@ describe('Datadog singleton', () => {
);
});
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.workflow.maestro_cli_version', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{ name: 'eas.workflow.maestro_cli_version', type: 'distribution', value: 1, tags: {} },
],
},
})
);
});
it('swallows log upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched');
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build log',
expect.any(Error),
expect.objectContaining({
extras: {
log: { buildId: 'build-id', message: 'artifact dry-run matched', tags: {} },
},
})
);
});
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)');
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun log',
expect.any(Error),
expect.objectContaining({
extras: {
log: { jobRunId: 'job-run-id', message: 'Gradle cache restored (hit)', tags: {} },
},
})
);
});
it('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'first-build-id',
robotAccessToken: 'first-token',
target: { kind: 'build', turtleBuildId: 'first-build-id' },
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'second-build-id',
robotAccessToken: 'second-token',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
'https://api.expo.test/v2/turtle-job-runs/logs',
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
@@ -303,8 +126,8 @@ describe('Datadog singleton', () => {
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
@@ -320,62 +143,6 @@ describe('Datadog singleton', () => {
expect(flushed).toBe(true);
});
it('flushes pending log uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
Datadog.log('artifact dry-run matched');
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
it('only awaits uploads enqueued since the previous flush', async () => {
let resolveSecondUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveSecondUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
// The first upload never settles; the first flush rotates it out of the queue.
Datadog.distribution('eas.build.phase_duration', 1);
void Datadog.flushAsync();
Datadog.distribution('eas.build.phase_duration', 2);
let flushed = false;
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
resolveSecondUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
@@ -4,9 +4,7 @@ import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Datadog } from '../../datadog';
import { restoreCredentials } from '../../android/credentials';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
@@ -58,9 +56,6 @@ jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
@@ -72,11 +67,8 @@ jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
describe(androidBuilder, () => {
beforeEach(() => {
jest.clearAllMocks();
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
@@ -146,6 +138,7 @@ describe(androidBuilder, () => {
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
@@ -164,23 +157,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
@@ -189,23 +182,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
version: {
@@ -221,6 +214,7 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
@@ -240,19 +234,15 @@ describe(androidBuilder, () => {
versionName: '1.2.3',
}
);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
@@ -273,37 +263,4 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -10,8 +10,6 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
@@ -32,7 +30,6 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -183,20 +180,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
try {
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
const profileTasks = await parseGradleProfile(androidDir);
if (profileTasks.length > 0) {
const report = formatGradleProfileReport(profileTasks);
ctx.logger.info(report);
}
} catch (err: any) {
Sentry.capture('Failed to parse Gradle build profile', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -209,12 +192,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
+1 -11
View File
@@ -25,7 +25,6 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -182,16 +181,13 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
}
try {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
const { skipped } = await parseAndReportXcactivitylog({
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
if (skipped) {
ctx.markBuildPhaseSkipped();
}
} catch (err: any) {
Sentry.capture('Failed to parse xcactivitylog', err);
ctx.markBuildPhaseSkipped();
@@ -210,12 +206,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -1,4 +1,4 @@
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
import { BuildJob } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
@@ -15,7 +15,7 @@ jest.mock('../../utils/easCli', () => ({
describe('easBuildInternal', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
@@ -69,131 +69,4 @@ describe('easBuildInternal', () => {
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([
'build:internal',
'--platform',
Platform.IOS,
'--profile',
'preview',
'--refresh-ad-hoc-provisioning-profile',
]),
expect.any(Object)
);
});
it('preserves refreshAdHocProvisioningProfile from the incoming job', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
const { newJob } = await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
});
});
@@ -1,12 +1,4 @@
import {
BuildJob,
Env,
Ios,
Metadata,
Platform,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -50,11 +42,6 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
const refreshAdHocProvisioningProfileArgs = [];
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
}
try {
const result = await spawn(
cmd,
@@ -66,7 +53,6 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
'--profile',
buildProfile,
...autoSubmitArgs,
...refreshAdHocProvisioningProfileArgs,
],
{
cwd,
@@ -152,9 +138,6 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
// We want to retain values that we have set on the job.
appId: oldJob.appId,
initiatingUserId: oldJob.initiatingUserId,
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
? { refreshAdHocProvisioningProfile: true }
: null),
}) as TJob;
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
const newMetadata = sanitizeMetadata(value.metadata);
+5
View File
@@ -2,6 +2,7 @@ import { ExpoConfig } from '@expo/config';
import {
BuildPhase,
BuildPhaseResult,
BuildPhaseStats,
Env,
EnvironmentSecretType,
GenericArtifactType,
@@ -64,6 +65,7 @@ export interface BuildContextOptions {
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
@@ -96,6 +98,7 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: Promise<ExpoConfig>;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
constructor(job: TJob, options: BuildContextOptions) {
@@ -111,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -351,6 +355,7 @@ export class BuildContext<TJob extends Job = Job> {
}
await this.collectAndUpdateEnvVariablesAsync();
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (this.job.platform) {
Datadog.distribution('eas.build.phase_duration', durationMs, {
build_phase: this.buildPhase.toLowerCase(),
+17 -52
View File
@@ -1,18 +1,14 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
turtleBuildId: string;
robotAccessToken: string;
target: MetricsTarget;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
let pendingMetricUploads: Promise<unknown>[] = [];
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
@@ -23,7 +19,7 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const metrics = [
{
name,
@@ -33,57 +29,26 @@ export const Datadog = {
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
const uploadPromise = turtleFetch(
new URL(`turtle-builds/${turtleBuildId}/metrics`, expoApiV2BaseUrl).toString(),
'POST',
{
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}
).catch(err => {
Sentry.capture('Failed to report turtle build metric', err, {
extras: { metrics },
});
});
pendingUploads.push(uploadPromise);
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const log =
target.kind === 'build'
? { buildId: target.turtleBuildId, message, tags }
: { jobRunId: target.turtleJobRunId, message, tags };
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
extras: { log },
});
});
pendingUploads.push(uploadPromise);
pendingMetricUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
// Rotate so each flush only awaits its own batch; uploads enqueued during the
// await land in the fresh array for a later flush.
const uploads = pendingUploads;
pendingUploads = [];
await Promise.allSettled(uploads);
await Promise.allSettled(pendingMetricUploads);
},
};
@@ -9,7 +9,6 @@ exports[`fastfile createFastfileForResigningBuild should create Fastfile with al
"com.example.app" => "/path/to/profiles/main.mobileprovision",
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
)
end
@@ -23,7 +22,6 @@ exports[`fastfile createFastfileForResigningBuild should handle empty provisioni
signing_identity: "iPhone Distribution",
provisioning_profile: {
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -41,7 +39,6 @@ exports[`fastfile createFastfileForResigningBuild should handle multiple provisi
"com.example.app.extension" => "/profiles/extension.mobileprovision",
"com.example.app.intents" => "/profiles/intents.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -56,7 +53,6 @@ exports[`fastfile createFastfileForResigningBuild should handle paths with speci
provisioning_profile: {
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
)
end
@@ -71,7 +67,6 @@ exports[`fastfile createFastfileForResigningBuild should handle single provision
provisioning_profile: {
"com.example.app" => "/tmp/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -86,7 +81,6 @@ exports[`fastfile createFastfileForResigningBuild should produce valid Ruby synt
provisioning_profile: {
"com.example.app" => "/path/to/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -26,12 +26,10 @@ function makeIosBuildContext({
expoVersion,
cocoapodsCacheUrl,
usePrecompiledModules = true,
expoUsePrecompiledModules,
}: {
expoVersion?: string;
cocoapodsCacheUrl?: string;
usePrecompiledModules?: boolean;
expoUsePrecompiledModules?: string;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
@@ -59,9 +57,6 @@ function makeIosBuildContext({
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: {
__API_SERVER_URL: 'http://api.expo.test',
...(expoUsePrecompiledModules
? { EXPO_USE_PRECOMPILED_MODULES: expoUsePrecompiledModules }
: {}),
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
},
uploadArtifact: jest.fn(),
@@ -131,14 +126,14 @@ describe(installPods, () => {
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringMatching(
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
)
);
});
@@ -181,23 +176,4 @@ describe(installPods, () => {
).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
it('does not override EXPO_USE_PRECOMPILED_MODULES=0 even when builder flag is enabled', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
usePrecompiledModules: true,
expoUsePrecompiledModules: '0',
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
expect(
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.'
);
expect(getInstalledExpoPackageVersionAsync).not.toHaveBeenCalled();
});
});
+1 -6
View File
@@ -5,7 +5,7 @@ import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
@@ -46,11 +46,6 @@ export async function installPods<TJob extends Ios.Job>(
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0') {
ctx.logger.info('EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.');
return {};
}
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
@@ -41,7 +41,6 @@ import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
@@ -82,7 +81,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -1,76 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableApsdAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartIosSimulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
const step = fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
return Object.assign(step, { logger });
}
describe(createStartIosSimulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
});
it('disables apsd on the main device and every clone', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'base',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-1',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-2',
env: expect.any(Object),
});
});
it('continues when disabling apsd fails', async () => {
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
// Startup is not aborted: readiness is still awaited for each device.
expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled();
});
});
@@ -19,7 +19,6 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { Datadog } from '../../datadog';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
@@ -283,10 +282,22 @@ export async function restoreGradleCacheAsync({
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
);
Datadog.log(`Gradle cache restored (${hitType})`, {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
});
try {
await turtleFetch(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
json: {
buildId: jobId,
message: `Gradle cache restored (${hitType})`,
tags: {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
});
} catch {}
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
@@ -8,25 +8,26 @@ import {
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
import {
DetachedProcessHandle,
ensureBrewPackageInstalledAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(
@@ -45,13 +46,10 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
// Fail fast before any expensive setup if the orchestrator-injected
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
// report the remote config back to the API server.
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const { runtimePlatform } = global;
@@ -64,6 +62,13 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
logger.info('Ensuring cloudflared is installed.');
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
});
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
@@ -87,20 +92,27 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
description: 'agent-device daemon',
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
const agentDeviceRemoteSessionUrl = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
const cloudflared = spawnDetached({
command: cloudflaredCommand,
args: ['tunnel', '--url', `http://localhost:${daemonPort}`],
env,
});
logger.info('Waiting for a public tunnel URL.');
const agentDeviceRemoteSessionUrl = await waitForMatchInOutputAsync({
process: cloudflared,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
@@ -109,7 +121,6 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -137,6 +148,62 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
async function ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
return 'cloudflared';
}
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
return 'cloudflared';
}
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
env,
logger,
});
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
// Return the absolute install path so the tunnel command works even when
// /usr/local/bin is not on the step's PATH.
return CLOUDFLARED_LINUX_INSTALL_PATH;
}
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
if (arch === 'x64') {
return 'amd64';
}
if (arch === 'arm64') {
return 'arm64';
}
throw new SystemError(
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
);
}
async function isCommandAvailableAsync({
command,
env,
}: {
command: string;
env: BuildStepEnv;
}): Promise<boolean> {
try {
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
return true;
} catch {
return false;
}
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
@@ -153,7 +220,54 @@ async function cloneAgentDeviceAsync({
});
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
async function waitForMatchInOutputAsync({
process,
pattern,
timeoutMs,
description,
}: {
process: DetachedProcessHandle;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const match = pattern.exec(process.getOutput());
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
);
}
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
@@ -162,7 +276,7 @@ function parseDaemonInfo(raw: string): { port: number; token: string } {
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new SystemError(
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
@@ -1,140 +0,0 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
const ArgentToolServerStateSchema = z.object({ port: z.number() });
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_argent_remote_session',
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
// Stale state from a previous run would mask the new server's port.
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
// `argent mcp` is the public entry that triggers @argent/tools-client
// to spawn the tool-server detached + unref'd, so the tool-server
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
// 30-min idle shutdown that would otherwise tear the tunnel down.
spawnDetached({
command: 'bunx',
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
});
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
const { port: toolServerPort } = await waitForFileAsync({
filePath: ARGENT_STATE_FILE,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'argent tool-server state',
parse: parseArgentToolServerState,
});
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const toolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
logger.info(`Tunnel is ready at ${toolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl,
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the tool-server and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
function parseArgentToolServerState(raw: string): { port: number } {
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
if (!result.success) {
throw new SystemError(
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
);
}
return result.data;
}
@@ -65,12 +65,6 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
@@ -102,12 +96,6 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({
udid: cloneUdid,
env,
@@ -4,7 +4,7 @@ import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
ensureBrewPackageInstalledAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
@@ -23,15 +23,16 @@ export function createStartServeSimRemoteSessionBuildFunction(
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
logger.info('Starting serve-sim remote session.');
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
logger.info('Ensuring cloudflared is installed.');
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -7,9 +7,9 @@ import { createMockLogger } from '../../../../__tests__/utils/logger';
import { Sentry } from '../../../../sentry';
import {
buildTargetMetrics,
isCompileStep,
collectTopLevelCompileSteps,
formatReport,
isCompileStep,
parseAndReportXcactivitylog,
} from '../xcactivitylog';
@@ -200,21 +200,13 @@ const TEST_ENV = { PATH: '/custom/bin:/usr/bin' };
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
buildLogEntries = ['12345-abc.xcactivitylog'] as string[] | Error,
}: {
tempDir?: string;
jsonOutput?: string;
buildLogEntries?: string[] | Error;
} = {}) {
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
jest.spyOn(fs, 'readdir').mockImplementation((async () => {
if (buildLogEntries instanceof Error) {
throw buildLogEntries;
}
return buildLogEntries;
}) as any);
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
return {
@@ -242,14 +234,13 @@ describe('parseAndReportXcactivitylog', () => {
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
const result = await parseAndReportXcactivitylog({
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: false });
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
stdio: 'pipe',
env: TEST_ENV,
@@ -276,9 +267,6 @@ describe('parseAndReportXcactivitylog', () => {
'/tmp/workspace',
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
reportPath,
],
@@ -387,14 +375,14 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: true });
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
{ tags: { phase: 'parsing_xclogparser_output' } }
);
});
@@ -418,14 +406,14 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: true });
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
{ tags: { phase: 'parsing_xclogparser_output' } }
);
});
@@ -446,154 +434,14 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: true });
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.objectContaining({ message: 'spawn xclogparser ENOENT' }),
expect.objectContaining({ tags: { phase: 'running_xclogparser' } })
);
});
it('surfaces spawn error stderr/stdout/status/signal as Sentry extras', async () => {
const logger = createMockLogger();
mockFilesystem();
const spawnError: any = new Error('xclogparser exited with non-zero code: 1');
spawnError.stderr = 'XCLogParser fatal: missing xcactivitylog\n';
spawnError.stdout = 'some stdout';
spawnError.status = 1;
spawnError.signal = null;
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockRejectedValueOnce(spawnError); // xclogparser run
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.any(Error),
{
tags: { phase: 'running_xclogparser' },
extras: {
exitStatus: 1,
signal: null,
stderr: 'XCLogParser fatal: missing xcactivitylog\n',
stdout: 'some stdout',
},
}
);
});
it('skips with actionable guidance when Logs/Build has no .xcactivitylog files', async () => {
const logger = createMockLogger();
mockFilesystem({ buildLogEntries: [] });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(
`Build performance analysis skipped: no .xcactivitylog files found at ${path.join(
'/tmp/derived-data',
'Logs',
'Build'
)}.`
)
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('derived_data_path("./build")')
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('result_bundle(true)'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('result_bundle_path("./build/result-bundle.xcresult")')
);
});
it('skips when Logs/Build directory does not exist', async () => {
const logger = createMockLogger();
const enoent: any = new Error('ENOENT');
enoent.code = 'ENOENT';
mockFilesystem({ buildLogEntries: enoent });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
);
});
it('captures to Sentry and skips when readdir fails with a non-ENOENT error', async () => {
const logger = createMockLogger();
const eacces: any = new Error('EACCES: permission denied');
eacces.code = 'EACCES';
mockFilesystem({ buildLogEntries: eacces });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "checking_xcactivitylog_existence"',
expect.objectContaining({ code: 'EACCES' }),
expect.objectContaining({
tags: { phase: 'checking_xcactivitylog_existence' },
})
);
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
});
it('ignores non-xcactivitylog entries in Logs/Build when deciding to skip', async () => {
const logger = createMockLogger();
mockFilesystem({ buildLogEntries: ['LogStoreManifest.plist', 'some-other-file.txt'] });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
{ tags: { phase: 'running_xclogparser' } }
);
});
@@ -612,14 +460,14 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: true });
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "downloading_xclogparser"',
expect.objectContaining({ message: 'network unreachable' }),
expect.objectContaining({ tags: { phase: 'downloading_xclogparser' } })
{ tags: { phase: 'downloading_xclogparser' } }
);
});
@@ -641,7 +489,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: false });
).resolves.toBeUndefined();
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
@@ -676,9 +524,6 @@ describe('parseAndReportXcactivitylog', () => {
'/tmp/workspace',
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
reportPath,
],
@@ -16,9 +16,9 @@ const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Catches all internal failures (logged + routed to Sentry); returns
* `{ skipped: true }` when analysis did not produce a report so callers can
* mark the build phase skipped.
* Never throws — best-effort observability that does not affect build status.
* Failures route to Sentry via `Sentry.capture` for engineering triage;
* users see only a generic skip message.
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
@@ -34,38 +34,10 @@ export async function parseAndReportXcactivitylog({
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<{ skipped: boolean }> {
}): Promise<void> {
let tempDir: string | undefined;
let phase = 'checking_xcactivitylog_existence';
let phase = 'creating_temp_directory';
try {
const logsBuildDir = path.join(derivedDataPath, 'Logs', 'Build');
let buildLogEntries: string[];
try {
buildLogEntries = await fs.readdir(logsBuildDir);
} catch (err: any) {
if (err?.code !== 'ENOENT') {
throw err;
}
buildLogEntries = [];
}
const hasActivityLog = buildLogEntries.some(entry => entry.endsWith('.xcactivitylog'));
if (!hasActivityLog) {
logger.info(
[
`Build performance analysis skipped: no .xcactivitylog files found at ${logsBuildDir}.`,
'',
'This typically happens when your project has a custom ios/Gymfile. To enable',
'build performance analysis, add the following lines to your Gymfile:',
'',
' derived_data_path("./build")',
' result_bundle(true)',
' result_bundle_path("./build/result-bundle.xcresult")',
].join('\n')
);
return { skipped: true };
}
phase = 'creating_temp_directory';
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
phase = 'resolving_xclogparser';
@@ -107,20 +79,10 @@ export async function parseAndReportXcactivitylog({
);
logger.info(formatReport(data));
return { skipped: false };
} catch (err: any) {
logger.info('Build performance analysis skipped.');
const msg = `Build performance analysis failed during "${phase}"`;
Sentry.capture(msg, err, {
tags: { phase },
extras: {
exitStatus: err?.status,
signal: err?.signal,
stderr: err?.stderr?.slice(-4000),
stdout: err?.stdout?.slice(-4000),
},
});
return { skipped: true };
Sentry.capture(msg, err, { tags: { phase } });
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
@@ -241,9 +203,6 @@ async function runXclogparser({
workspacePath,
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
outputPath,
],
@@ -2,14 +2,13 @@ import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
@@ -29,37 +28,13 @@ export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
if (!deviceRunSessionId) {
throw new SystemError(
'DEVICE_RUN_SESSION_ID is not set. ' +
'This step must run as part of a device run session ' +
'This step must run as part of a device run session created by the API server, ' +
'which injects DEVICE_RUN_SESSION_ID into the job environment.'
);
}
return deviceRunSessionId;
}
export function getNgrokTunnelDomainOrThrow(env: BuildStepEnv): string {
const baseDomain = env.EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN;
if (!baseDomain) {
throw new SystemError(
'EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN is not set. ' +
'This step must run as part of a device run session ' +
'which injects EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN into the job environment.'
);
}
return baseDomain;
}
export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
const authtoken = env.NGROK_AUTHTOKEN;
if (!authtoken) {
throw new SystemError(
'NGROK_AUTHTOKEN is not set. ' +
'This step must run as part of a device run session ' +
'which injects NGROK_AUTHTOKEN into the job environment.'
);
}
return authtoken;
}
export async function uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
@@ -84,6 +59,22 @@ export async function uploadRemoteSessionConfigAsync({
}
}
export async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
export type DetachedProcessHandle = {
getOutput: () => string;
};
@@ -121,12 +112,10 @@ export function spawnDetached({
}
export async function startServeSimWithTunnelAsync({
baseDomain,
env,
logger,
timeoutMs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
@@ -137,16 +126,12 @@ export async function startServeSimWithTunnelAsync({
args: [
'serve-sim-szdziedzic@latest',
'--tunnel',
'--tunnel-provider',
'ngrok',
'--tunnel-domain',
baseDomain,
'--tunnel-protocol',
'quic',
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
'--codec',
'webrtc',
],
env,
});
@@ -155,8 +140,8 @@ export async function startServeSimWithTunnelAsync({
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = serveSim.getOutput();
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
const previewUrl = matchLabeledUrl(output, 'Tunnel');
const streamUrl = matchLabeledUrl(output, 'Stream');
if (previewUrl && streamUrl) {
return { previewUrl, streamUrl };
}
@@ -167,76 +152,8 @@ export async function startServeSimWithTunnelAsync({
);
}
function matchLabeledUrl({
output,
label,
baseDomain,
}: {
output: string;
label: string;
baseDomain: string;
}): string | null {
const labelPattern = new RegExp(
`${label}:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
);
const match = labelPattern.exec(output);
function matchLabeledUrl(content: string, label: string): string | null {
const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
const match = labelPattern.exec(content);
return match ? match[1] : null;
}
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
baseDomain,
authtoken,
logger,
}: {
port: number;
subdomainPrefix: string;
baseDomain: string;
authtoken: string;
logger: bunyan;
}): Promise<string> {
const domain = `${subdomainPrefix}-${randomBytes(8).toString('hex')}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
const listener = await ngrok.forward({ addr: port, authtoken, domain });
const url = listener.url();
if (!url) {
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
}
return url;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function waitForFileAsync<T>({
filePath,
timeoutMs,
description,
parse,
}: {
filePath: string;
timeoutMs: number;
description: string;
parse: (raw: string) => T;
}): Promise<T> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
return parse(raw);
} catch (err) {
lastError = err;
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${description} to be ready at ${filePath}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}.`
);
}
@@ -5,7 +5,6 @@ export const FastfileResignTemplate = `lane :do_resign do
provisioning_profile: {<% _.forEach(PROFILES, function(profile) { %>
"<%- profile.BUNDLE_ID %>" => "<%- profile.PATH %>",<% }); %>
},
use_app_entitlements: true,
keychain_path: "<%- KEYCHAIN_PATH %>"
)
end
@@ -122,10 +122,9 @@ export namespace IosSimulatorUtils {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
await retryAsync(
async () => {
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', '/dev/null'], {
env,
});
},
@@ -137,7 +136,6 @@ export namespace IosSimulatorUtils {
},
}
);
await fs.promises.rm(readinessScreenshotPath, { force: true });
// Wait for data migration to complete before declaring the simulator ready
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
@@ -157,25 +155,6 @@ export namespace IosSimulatorUtils {
);
}
export async function disableApsdAsync({
udid,
env,
}: {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env }
);
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env }
);
}
export async function collectLogsAsync({
deviceIdentifier,
env,
@@ -1,64 +0,0 @@
import spawn from '@expo/turtle-spawn';
import os from 'node:os';
import path from 'node:path';
import { IosSimulatorUtils } from '../IosSimulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
const mockedSpawn = jest.mocked(spawn);
describe('IosSimulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
describe(IosSimulatorUtils.waitForReadyAsync, () => {
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
await IosSimulatorUtils.waitForReadyAsync({
udid: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
[
'simctl',
'io',
'test-udid',
'screenshot',
path.join(os.tmpdir(), 'eas-simulator-readiness.png'),
],
{ env: process.env }
);
});
});
describe(IosSimulatorUtils.disableApsdAsync, () => {
it('disables and boots out apsd in the simulator', async () => {
await IosSimulatorUtils.disableApsdAsync({
udid: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env: process.env }
);
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env: process.env }
);
});
});
});
@@ -71,26 +71,6 @@ describe(findArtifacts, () => {
expect(paths.length).toBe(2);
});
test('with absolute glob pattern', async () => {
await fs.mkdirp('/tmp');
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output123');
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output456');
const loggerMock = {
info: jest.fn(),
error: jest.fn(),
};
const paths = await findArtifacts({
rootDir: '/Users/expo/build',
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
logger: loggerMock as any,
});
expect(loggerMock.error).toHaveBeenCalledTimes(0);
expect(paths).toEqual([
'/tmp/maestro_xctestrunner_xcodebuild_output123',
'/tmp/maestro_xctestrunner_xcodebuild_output456',
]);
});
test('with missing file in empty directory', async () => {
await fs.mkdirp('/dir1/dir2/dir3');
let errMsg = '';
@@ -1,273 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { BuildContext } from '../../context';
import * as expoUpdates from '../expoUpdates';
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
import * as easCli from '../easCli';
import * as artifacts from '../artifacts';
jest.mock('../expoUpdates');
jest.mock('../easCli');
jest.mock('../artifacts');
const mockZipEntries = jest.fn();
const mockZipExtract = jest.fn();
const mockZipClose = jest.fn();
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(() => ({
entries: mockZipEntries,
extract: mockZipExtract,
close: mockZipClose,
})),
},
}));
function zipEntryMap(entries: Record<string, true>): Record<string, { name: string }> {
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
}
function makeCtx(overrides: {
platform: Platform;
simulator?: boolean;
channel?: string;
env?: Record<string, string>;
}): BuildContext<any> {
const job =
overrides.platform === Platform.IOS
? {
platform: Platform.IOS,
simulator: overrides.simulator ?? false,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
}
: {
platform: Platform.ANDROID,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
};
return {
job,
env: overrides.env ?? {},
appConfig: Promise.resolve({
updates: { url: 'https://u.expo.dev/project-id' },
}),
logger: {
info: jest.fn(),
warn: jest.fn(),
},
markBuildPhaseSkipped: jest.fn(),
markBuildPhaseHasWarnings: jest.fn(),
getReactNativeProjectDirectory: () => '/project',
} as any;
}
describe('uploadEmbeddedBundleAsync', () => {
beforeEach(() => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
mockZipEntries.mockResolvedValue({});
mockZipExtract.mockResolvedValue(undefined);
mockZipClose.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('skips when EAS Update is not configured', async () => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when no channel is configured and does not look for the archive', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('throws for an unsupported platform', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
(ctx.job as { platform: string }).platform = 'web';
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
'Uploading embedded updates is not supported for the web platform.'
);
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('uploads from Android APK archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
const ctx = makeCtx({
platform: Platform.ANDROID,
channel: 'production',
env: { EAS_BUILD_ID: 'build-123' },
});
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining([
'update:embedded:upload',
'--platform',
Platform.ANDROID,
'--channel',
'production',
'--build-id',
'build-123',
]),
})
);
});
it('uploads from Android AAB archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'base/assets/index.android.bundle': true,
'base/assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'base/assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalled();
});
it('uploads from iOS IPA archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'Payload/App.app/main.jsbundle': true,
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--platform', Platform.IOS]),
})
);
});
it('skips simulator builds', async () => {
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when bundle or manifest is missing from the archive', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
);
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns when build archive is not found', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('treats findArtifacts errors as no archive found', async () => {
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns and continues when CLI upload throws', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
});
it('swallows zip.close() failures so they do not mask the upload result', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
mockZipClose.mockRejectedValue(new Error('close failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
expect(easCli.runEasCliCommand).toHaveBeenCalled();
expect(mockZipClose).toHaveBeenCalled();
});
});
+5 -6
View File
@@ -19,12 +19,11 @@ export async function findArtifacts({
/** If provided, will log error suggesting possible files to upload. */
logger: bunyan | null;
}): Promise<string[]> {
const files =
path.isAbsolute(patternOrPath) && !fg.isDynamicPattern(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
const files = path.isAbsolute(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
if (files.length === 0) {
if (fg.isDynamicPattern(patternOrPath)) {
throw new FindArtifactsError(`There are no files matching pattern "${patternOrPath}"`);
@@ -1,118 +0,0 @@
import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { findArtifacts } from './artifacts';
import { runEasCliCommand } from './easCli';
import { resolveArtifactPath } from '../ios/resolve';
import { BuildContext } from '../context';
import { isEASUpdateConfigured } from './expoUpdates';
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!(await isEASUpdateConfigured(ctx))) {
ctx.markBuildPhaseSkipped();
return;
}
const { platform } = ctx.job;
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
ctx.markBuildPhaseSkipped();
return;
}
const channel = ctx.job.updates?.channel;
if (!channel) {
ctx.logger.warn(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
ctx.markBuildPhaseHasWarnings();
return;
}
const projectDir = ctx.getReactNativeProjectDirectory();
let archivePattern: string;
if (platform === Platform.IOS) {
archivePattern = resolveArtifactPath(ctx as BuildContext<Ios.Job>);
} else if (platform === Platform.ANDROID) {
archivePattern =
(ctx as BuildContext<Android.Job>).job.applicationArchivePath ??
'android/app/build/outputs/**/*.{apk,aab}';
} else {
throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`);
}
const [archivePath] = await findArtifacts({
rootDir: projectDir,
patternOrPath: archivePattern,
logger: null,
}).catch(() => [] as string[]);
if (!archivePath) {
ctx.logger.warn('Skipping embedded bundle upload: build archive not found.');
ctx.markBuildPhaseHasWarnings();
return;
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-'));
const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle';
const bundlePath = path.join(tmpDir, bundleName);
const manifestPath = path.join(tmpDir, 'app.manifest');
const zip = new StreamZip.async({ file: archivePath });
try {
const entries = Object.values(await zip.entries());
const bundleEntry = entries.find(e =>
platform === Platform.IOS
? e.name.endsWith('/main.jsbundle')
: e.name.endsWith('assets/index.android.bundle')
);
const manifestEntry = entries.find(e =>
platform === Platform.IOS
? e.name.includes('EXUpdates.bundle/app.manifest')
: e.name.endsWith('assets/app.manifest')
);
if (!bundleEntry || !manifestEntry) {
ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
ctx.markBuildPhaseHasWarnings();
return;
}
await zip.extract(bundleEntry.name, bundlePath);
await zip.extract(manifestEntry.name, manifestPath);
const args = [
'update:embedded:upload',
'--platform',
platform,
'--bundle',
bundlePath,
'--manifest',
manifestPath,
'--channel',
channel,
'--non-interactive',
];
if (ctx.env.EAS_BUILD_ID) {
args.push('--build-id', ctx.env.EAS_BUILD_ID);
}
await runEasCliCommand({
args,
options: {
cwd: projectDir,
env: ctx.env,
logger: ctx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload embedded bundle.');
ctx.markBuildPhaseHasWarnings();
} finally {
await asyncResult(zip.close());
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "20.1.0",
"version": "19.0.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "20.1.0"
"@expo/steps": "19.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "20.0.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "20.0.0",
"version": "19.0.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "20.1.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "20.0.0",
"@expo/logger": "19.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/turtle-spawn": "19.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -534,47 +534,4 @@ describe('Ios.JobSchema', () => {
expect(value).toMatchObject(job);
expect(error).toBeFalsy();
});
test('accepts optional refreshAdHocProvisioningProfile', () => {
const job = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
initiatingUserId: randomUUID(),
appId: randomUUID(),
refreshAdHocProvisioningProfile: true,
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value.refreshAdHocProvisioningProfile).toBe(true);
expect(error).toBeFalsy();
});
test('does not require refreshAdHocProvisioningProfile', () => {
const job = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value.refreshAdHocProvisioningProfile).toBeUndefined();
expect(error).toBeFalsy();
});
});
+6
View File
@@ -181,6 +181,12 @@ export const CacheSchema = Joi.object({
paths: Joi.array().items(Joi.string()).default([]),
});
export interface BuildPhaseStats {
buildPhase: BuildPhase;
result: BuildPhaseResult;
durationMs: number;
}
const GitHubContextZ = z.object({
triggering_actor: z.string().optional(),
event_name: z.enum(['push', 'pull_request', 'workflow_dispatch', 'schedule']),
+1
View File
@@ -5,6 +5,7 @@ export {
ArchiveSource,
ArchiveSourceSchemaZ,
BuildMode,
BuildPhaseStats,
BuildTrigger,
EasCliNpmTags,
Env,
-4
View File
@@ -144,8 +144,6 @@ export interface Job {
appId: string;
environment?: string;
refreshAdHocProvisioningProfile?: boolean;
}
const SecretsSchema = Joi.object({
@@ -231,8 +229,6 @@ export const JobSchema = Joi.object({
environment: Joi.string(),
refreshAdHocProvisioningProfile: Joi.boolean(),
workflowInterpolationContext: Joi.object().custom(workflowInterpolationContext =>
StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext)
),
-3
View File
@@ -27,7 +27,6 @@ export enum BuildPhase {
*/
UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS',
UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE',
UPLOAD_EMBEDDED_BUNDLE = 'UPLOAD_EMBEDDED_BUNDLE',
UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS',
PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS',
CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS',
@@ -92,7 +91,6 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'Cache stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'Upload embedded bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials',
@@ -159,7 +157,6 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'cache-stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'upload-embedded-bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials',
+101 -375
View File
File diff suppressed because it is too large. Load diff
+11 -2706
View File
File diff suppressed because it is too large. Load diff
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "20.1.0",
"version": "19.0.1",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,11 +47,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-json": "20.1.0",
"@expo/eas-build-job": "19.0.0",
"@expo/eas-json": "19.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "20.0.0",
"@expo/logger": "19.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.1.0",
"@expo/steps": "19.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -221,6 +221,9 @@
"secret": {
"description": "manage project and account secrets"
},
"simulator": {
"description": "manage remote simulator sessions"
},
"update": {
"description": "manage individual updates"
},
@@ -1,197 +0,0 @@
import { getConfigFilePaths } from '@expo/config';
import { getWorkflowRunUrl } from '../../build/utils/url';
import Go from '../../commands/go';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { selectAsync } from '../../prompts';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { detectProjectSdkVersionAsync } from '../../commands/go';
import { mockTestCommand } from './utils';
jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
getConfigFilePaths: jest.fn(),
}));
jest.mock('../../project/expoConfig');
jest.mock('../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
withTick: jest.fn(),
newLine: jest.fn(),
succeed: jest.fn(),
debug: jest.fn(),
markFreshLine: jest.fn(),
error: jest.fn(),
},
learnMore: jest.fn().mockReturnValue(''),
}));
jest.mock('../../ora', () => ({
ora: jest.fn().mockReturnValue({
start: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
}),
}));
jest.mock('fs-extra', () => ({
ensureDir: jest.fn().mockResolvedValue(undefined),
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../build/utils/url');
jest.mock('../../prompts');
const mockGetConfigFilePaths = jest.mocked(getConfigFilePaths);
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);
describe('detectProjectSdkVersionAsync', () => {
it('returns undefined when no config file exists', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
it('returns the sdkVersion from the project config', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBe('55.0.0');
});
it('returns undefined when reading the config throws', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('config error'));
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
});
const mockAccount = { id: 'account-id', name: 'testuser' };
const mockActor = {
__typename: 'User' as const,
id: 'user-id',
username: 'testuser',
primaryAccount: mockAccount,
};
describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
} as any);
jest.mocked(WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync).mockResolvedValue({
id: 'run-id',
} as any);
jest.mocked(uploadAccountScopedProjectSourceAsync).mockResolvedValue({
projectArchiveBucketKey: 'archive-key',
});
jest
.mocked(uploadAccountScopedFileAsync)
.mockResolvedValue({ fileBucketKey: 'file-key' } as any);
jest.mocked(getWorkflowRunUrl).mockReturnValue('https://expo.dev/run/123');
});
afterEach(() => {
jest.clearAllMocks();
});
function makeCmd(argv: string[] = []) {
const ctx = {
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
analytics: {} as any,
};
const cmd = mockTestCommand(Go, ['--bundle-id', 'com.test.go', ...argv], ctx);
jest.spyOn(cmd as any, 'ensureEasProjectAsync').mockResolvedValue('project-id');
jest.spyOn(cmd as any, 'setupCredentialsAsync').mockResolvedValue({ id: 'asc-app-id' });
jest.spyOn(cmd as any, 'monitorWorkflowJobsAsync').mockResolvedValue(WorkflowRunStatus.Success);
return cmd;
}
it('logs auto-selected SDK message and reports resolved version after dispatch', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await makeCmd().run();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('SDK 55'));
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Using Expo Go SDK'));
});
it('skips auto-select log when --sdk-version flag is provided', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await makeCmd(['--sdk-version', '55.0.0']).run();
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
});
it('prompts for SDK version when no project config is found', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
{ sdkVersion: '54.0.0', isLatest: false, isBeta: false, isDeprecated: false },
{ sdkVersion: '55.0.0', isLatest: true, isBeta: false, isDeprecated: false },
{ sdkVersion: '56.0.0', isLatest: false, isBeta: true, isDeprecated: false },
]);
jest.mocked(selectAsync).mockResolvedValue('55.0.0');
await makeCmd().run();
expect(selectAsync).toHaveBeenCalledWith(
'Select an Expo SDK version',
expect.arrayContaining([
expect.objectContaining({ title: 'SDK 54', value: '54.0.0' }),
expect.objectContaining({ title: 'SDK 55 (latest)', value: '55.0.0' }),
expect.objectContaining({ title: 'SDK 56 (beta)', value: '56.0.0' }),
]),
expect.objectContaining({ initial: '55.0.0' })
);
});
it('skips prompt when all versions are deprecated', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest
.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync)
.mockResolvedValue([
{ sdkVersion: '54.0.0', isLatest: false, isBeta: false, isDeprecated: true },
]);
await makeCmd().run();
expect(selectAsync).not.toHaveBeenCalled();
});
it('falls back gracefully when supportedSdkVersions fetch fails', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest
.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync)
.mockRejectedValue(new Error('network error'));
await makeCmd().run();
expect(selectAsync).not.toHaveBeenCalled();
});
});
@@ -42,7 +42,6 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata,
buildLoggerLevel,
freezeCredentials,
refreshAdHocProvisioningProfile: refreshAdHocProvisioningProfileFlag,
isVerboseLoggingEnabled,
whatToTest,
env,
@@ -66,7 +65,6 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata?: CustomBuildConfigMetadata;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Record<string, string>;
@@ -92,8 +90,6 @@ export async function createBuildContextAsync<T extends Platform>({
const requiredPackageManager = resolvePackageManager(projectDir);
const refreshAdHocProvisioningProfile = refreshAdHocProvisioningProfileFlag ?? false;
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive,
@@ -105,7 +101,6 @@ export async function createBuildContextAsync<T extends Platform>({
easJsonCliConfig,
vcsClient,
freezeCredentials,
refreshAdHocProvisioningProfile,
});
const devClientProperties = getDevClientEventProperties({
@@ -1,40 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import { BuildContext } from '../../context';
import { ensureIosCredentialsAsync } from '../credentials';
describe(ensureIosCredentialsAsync, () => {
it('errors when refresh is enabled with local credentials source', async () => {
const buildCtx = {
buildProfile: {
credentialsSource: CredentialsSource.LOCAL,
simulator: false,
withoutCredentials: false,
},
credentialsCtx: {
refreshAdHocProvisioningProfile: true,
},
} as BuildContext<Platform.IOS>;
await expect(ensureIosCredentialsAsync(buildCtx, [])).rejects.toThrow(
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
});
it('does not reject refresh when credentials source is remote', async () => {
const buildCtx = {
buildProfile: {
credentialsSource: CredentialsSource.REMOTE,
simulator: true,
withoutCredentials: false,
distribution: 'internal',
},
credentialsCtx: {
refreshAdHocProvisioningProfile: true,
},
} as BuildContext<Platform.IOS>;
await expect(ensureIosCredentialsAsync(buildCtx, [])).resolves.toBeUndefined();
});
});
+2 -10
View File
@@ -17,16 +17,6 @@ export async function ensureIosCredentialsAsync(
return;
}
const { credentialsSource } = buildCtx.buildProfile;
if (
buildCtx.credentialsCtx.refreshAdHocProvisioningProfile &&
credentialsSource === 'local'
) {
throw new Error(
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
}
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
targets,
@@ -34,6 +24,8 @@ export async function ensureIosCredentialsAsync(
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
});
const { credentialsSource } = buildCtx.buildProfile;
logCredentialsSource(credentialsSource, Platform.IOS);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
@@ -112,9 +112,6 @@ export async function prepareJobAsync(
// See: https://github.com/expo/eas-build/pull/454
appId: ctx.projectId,
initiatingUserId: ctx.user.id,
...(ctx.credentialsCtx.refreshAdHocProvisioningProfile && {
refreshAdHocProvisioningProfile: true,
}),
};
return sanitizeBuildJob(job) as Ios.Job;
}
@@ -103,7 +103,6 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
simulator?: SimulatorRunTarget;
@@ -414,7 +413,6 @@ async function prepareAndStartBuildAsync({
customBuildConfigMetadata,
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
refreshAdHocProvisioningProfile: flags.refreshAdHocProvisioningProfile,
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
whatToTest: flags.whatToTest,
env,
-1
View File
@@ -35,5 +35,4 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
}
@@ -40,7 +40,6 @@ interface RawBuildFlags {
message?: string;
'build-logger-level'?: LoggerLevel;
'freeze-credentials': boolean;
'refresh-ad-hoc-provisioning-profile': boolean;
'verbose-logs'?: boolean;
'what-to-test'?: string;
}
@@ -122,11 +121,6 @@ export default class Build extends EasCommand {
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
}),
'refresh-ad-hoc-provisioning-profile': Flags.boolean({
default: false,
description:
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
}),
'verbose-logs': Flags.boolean({
default: false,
description: 'Use verbose logs for the build process',
@@ -194,20 +188,6 @@ export default class Build extends EasCommand {
flags: RawBuildFlags
): Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform } {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (flags['refresh-ad-hoc-provisioning-profile']) {
if (!nonInteractive) {
Errors.error(
'--refresh-ad-hoc-provisioning-profile can only be used in non-interactive mode.',
{ exit: 1 }
);
}
if (flags['freeze-credentials']) {
Errors.error(
'Cannot use --refresh-ad-hoc-provisioning-profile with --freeze-credentials.',
{ exit: 1 }
);
}
}
if (!flags.local && flags.output) {
Errors.error('--output is allowed only for local builds', { exit: 1 });
}
@@ -269,7 +249,6 @@ export default class Build extends EasCommand {
message,
buildLoggerLevel: flags['build-logger-level'],
freezeCredentials: flags['freeze-credentials'],
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
isVerboseLoggingEnabled: flags['verbose-logs'],
whatToTest: flags['what-to-test'],
};
@@ -40,11 +40,6 @@ export default class BuildInternal extends EasCommand {
helpValue: 'PROFILE_NAME',
exclusive: ['auto-submit'],
}),
'refresh-ad-hoc-provisioning-profile': Flags.boolean({
default: false,
description:
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
}),
};
static override contextDefinition = {
@@ -90,7 +85,6 @@ export default class BuildInternal extends EasCommand {
profile: flags.profile,
nonInteractive: true,
freezeCredentials: false,
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
wait: false,
clearCache: false,
json: true,
+6 -66
View File
@@ -1,4 +1,4 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { ExpoConfig } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -26,9 +26,8 @@ import { AppMutation } from '../graphql/mutations/AppMutation';
import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync, selectAsync } from '../prompts';
import { confirmAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
@@ -46,20 +45,6 @@ function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
@@ -204,16 +189,7 @@ export default class Go extends EasCommand {
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
if (detectedSdkVersion && !flags['sdk-version']) {
Log.log(
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
);
}
let sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
if (!sdkVersion) {
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
}
const sdkVersion = flags['sdk-version'];
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
@@ -255,11 +231,7 @@ export default class Go extends EasCommand {
}
);
const {
workflowUrl,
workflowRunId,
sdkVersion: resolvedSdkVersion,
} = await this.dispatchWorkflowAsync(
const { workflowUrl, workflowRunId } = await this.dispatchWorkflowAsync(
graphqlClient,
projectId,
actor,
@@ -270,7 +242,6 @@ export default class Go extends EasCommand {
tmpDir,
vcsClient
);
Log.withTick(`Using Expo Go SDK ${chalk.cyan(resolvedSdkVersion.split('.')[0])}`);
Log.withTick(`Build started: ${chalk.cyan(workflowUrl)}`);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
@@ -304,37 +275,6 @@ export default class Go extends EasCommand {
}
}
private async selectSdkVersionAsync(
graphqlClient: ExpoGraphqlClient
): Promise<{ sdkVersion: string | undefined }> {
let versions;
try {
versions = await WorkflowRunQuery.expoGoSupportedSdkVersionsAsync(graphqlClient);
} catch {
return { sdkVersion: undefined };
}
const selectable = versions.filter(v => !v.isDeprecated);
if (selectable.length === 0) {
return { sdkVersion: undefined };
}
const defaultVersion = selectable.find(v => v.isLatest) ?? selectable.at(-1);
return {
sdkVersion: await selectAsync(
'Select an Expo SDK version',
selectable.map(v => {
const major = v.sdkVersion.split('.')[0];
const title = v.isLatest
? `SDK ${major} (latest)`
: v.isBeta
? `SDK ${major} (beta)`
: `SDK ${major}`;
return { title, value: v.sdkVersion };
}),
{ initial: defaultVersion?.sdkVersion }
),
};
}
private generateBundleId(actor: Actor): string {
const username = ensureActorHasPrimaryAccount(actor).name;
const sanitizedUsername = username
@@ -461,7 +401,7 @@ export default class Go extends EasCommand {
sdkVersion: string | undefined,
tmpDir: string,
vcsClient: Client
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
): Promise<{ workflowUrl: string; workflowRunId: string }> {
const account = ensureActorHasPrimaryAccount(actor);
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
@@ -511,7 +451,7 @@ export default class Go extends EasCommand {
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
return { workflowUrl, workflowRunId: result.id };
}
private async monitorWorkflowJobsAsync(
@@ -1,51 +1,31 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppObservePlatform } from '../../../graphql/generated';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../../../observe/fetchCustomEvents';
import {
buildObserveCustomEventNamesJson,
buildObserveCustomEventsEmptyWithSuggestionsJson,
buildObserveCustomEventsEmptyWithSuggestionsTable,
buildObserveCustomEventsJson,
} from '../../../observe/formatCustomEvents';
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvents from '../events';
jest.mock('../../../observe/fetchCustomEvents');
jest.mock('../../../observe/formatCustomEvents', () => ({
buildObserveCustomEventsTable: jest.fn().mockReturnValue('table'),
buildObserveCustomEventsJson: jest.fn().mockReturnValue({}),
buildObserveCustomEventNamesTable: jest.fn().mockReturnValue('names-table'),
buildObserveCustomEventNamesJson: jest.fn().mockReturnValue({ names: [], isTruncated: false }),
buildObserveCustomEventsEmptyWithSuggestionsTable: jest
.fn()
.mockReturnValue('empty-with-suggestions-table'),
buildObserveCustomEventsEmptyWithSuggestionsJson: jest.fn().mockReturnValue({
filteredEventName: 'my_event',
events: [],
availableEventNames: [],
availableEventNamesIsTruncated: false,
}),
}));
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
ObserveQuery: {
customEventNamesAsync: jest.fn(),
},
jest.mock('../../../observe/fetchEvents', () => {
const actual = jest.requireActual('../../../observe/fetchEvents');
return {
...actual,
fetchObserveEventsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
buildObserveEventsJson: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
const mockBuildObserveCustomEventsJson = jest.mocked(buildObserveCustomEventsJson);
const mockBuildObserveCustomEventNamesJson = jest.mocked(buildObserveCustomEventNamesJson);
const mockBuildObserveCustomEventsEmptyWithSuggestionsTable = jest.mocked(
buildObserveCustomEventsEmptyWithSuggestionsTable
);
const mockBuildObserveCustomEventsEmptyWithSuggestionsJson = jest.mocked(
buildObserveCustomEventsEmptyWithSuggestionsJson
);
const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync);
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
@@ -56,11 +36,10 @@ describe(ObserveEvents, () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveCustomEventsAsync.mockResolvedValue({
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
});
function createCommand(argv: string[]): ObserveEvents {
@@ -73,80 +52,38 @@ describe(ObserveEvents, () => {
return command;
}
it('passes eventName arg to fetchObserveCustomEventsAsync', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [{ id: 'evt-1' } as any],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['my_event']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.eventName).toBe('my_event');
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
});
it('routes to customEventNamesAsync when no positional arg is provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
});
it('routes to fetchObserveCustomEventsAsync when --all-events is set with no positional arg', async () => {
const command = createCommand(['--all-events']);
await command.runAsync();
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.eventName).toBeUndefined();
});
it('throws when both an event name argument and --all-events are provided', async () => {
const command = createCommand(['my_event', '--all-events']);
await expect(command.runAsync()).rejects.toThrow(
'--all-events cannot be combined with an event name argument'
);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
});
it('passes the resolved time range and platform to customEventNamesAsync', async () => {
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days', '7', '--platform', 'ios']);
const command = createCommand(['tti', '--days', '7']);
await command.runAsync();
expect(mockCustomEventNamesAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
startTime: '2025-06-08T12:00:00.000Z',
endTime: '2025-06-15T12:00:00.000Z',
platform: AppObservePlatform.Ios,
});
jest.useRealTimers();
});
it('uses --days to compute start/end time range when an event name is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['my_event', '--days', '7']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days nor --start/--end are provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'my_event',
'tti',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
@@ -154,86 +91,109 @@ describe(ObserveEvents, () => {
]);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti', '--start', '2025-01-01T00:00:00.000Z']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days combined with --start', async () => {
const command = createCommand([
'my_event',
'--days',
'7',
'--start',
'2025-01-01T00:00:00.000Z',
]);
const command = createCommand(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --limit to fetchObserveCustomEventsAsync', async () => {
const command = createCommand(['my_event', '--limit', '42']);
it('passes --limit to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor', async () => {
const command = createCommand(['my_event', '--after', 'cursor-xyz']);
it('passes --after cursor to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.after).toBe('cursor-xyz');
});
it('passes --platform ios as AppObservePlatform.Ios', async () => {
const command = createCommand(['my_event', '--platform', 'ios']);
it('does not pass after when --after flag is not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options).not.toHaveProperty('after');
});
it('rejects --days combined with --end', async () => {
const command = createCommand(['tti', '--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
const command = createCommand(['tti', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes --app-version', async () => {
const command = createCommand(['my_event', '--app-version', '2.1.0']);
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
const command = createCommand(['tti', '--platform', 'android']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Android);
});
it('passes --app-version to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--app-version', '2.1.0']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.appVersion).toBe('2.1.0');
});
it('passes --update-id', async () => {
const command = createCommand(['my_event', '--update-id', 'update-xyz']);
it('passes --update-id to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--update-id', 'update-xyz']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.updateId).toBe('update-xyz');
});
it('passes --session-id', async () => {
const command = createCommand(['my_event', '--session-id', 'session-xyz']);
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.sessionId).toBe('session-xyz');
});
it('does not pass platform, appVersion, updateId, or sessionId when flags are not provided', async () => {
const command = createCommand(['my_event']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
expect(options.sessionId).toBeUndefined();
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => {
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
const mockEvents = [
{
id: 'evt-1',
eventName: 'my_event',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -243,139 +203,78 @@ describe(ObserveEvents, () => {
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
properties: [],
},
];
mockFetchObserveCustomEventsAsync.mockResolvedValue({
mockFetchObserveEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['my_event', '--json', '--non-interactive']);
const command = createCommand(['tti', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith(
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('falls back to fetching event names and renders the empty-with-suggestions table when filtered fetch returns 0 events', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const mockNames = [
{ eventName: 'foo', count: 10 },
{ eventName: 'bar', count: 5 },
];
mockCustomEventNamesAsync.mockResolvedValue({
names: mockNames as any,
isTruncated: false,
});
const command = createCommand(['my_event']);
it('does not call enableJsonOutput when --json is not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).toHaveBeenCalledWith(
'my_event',
mockNames,
expect.objectContaining({ isTruncated: false })
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('passes --sort flag through to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--sort', 'slowest']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.orderBy).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('throws in non-interactive mode when no metric is provided', async () => {
const command = createCommand(['--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
'metric argument is required in non-interactive mode'
);
});
});
it('does not call customEventNamesAsync when filtered fetch returns at least one event', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
{
id: 'evt-1',
eventName: 'my_event',
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
easClientId: 'client-1',
properties: [],
} as any,
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
describe(resolveOrderBy, () => {
it('resolves lowercase "slowest" to MetricValue DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
const command = createCommand(['my_event']);
await command.runAsync();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
});
it('emits empty-with-suggestions JSON when filtered fetch returns 0 events and --json is set', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
it('resolves lowercase "fastest" to MetricValue ASC', () => {
expect(resolveOrderBy('fastest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
});
const mockNames = [{ eventName: 'foo', count: 10 }];
mockCustomEventNamesAsync.mockResolvedValue({
names: mockNames as any,
isTruncated: false,
});
const command = createCommand(['my_event', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).toHaveBeenCalledWith(
'my_event',
mockNames,
false
);
expect(mockBuildObserveCustomEventsJson).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('does not run the empty-with-suggestions fallback when no event name is provided (event names mode)', async () => {
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).not.toHaveBeenCalled();
});
it('does not run the empty-with-suggestions fallback for --all-events with 0 results', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
it('resolves lowercase "newest" to Timestamp DESC', () => {
expect(resolveOrderBy('newest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
});
const command = createCommand(['--all-events']);
await command.runAsync();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
});
it('emits JSON of event names + counts when --json is provided without an event name', async () => {
const mockNames = [
{ eventName: 'foo', count: 10 },
{ eventName: 'bar', count: 5 },
];
mockCustomEventNamesAsync.mockResolvedValue({
names: mockNames as any,
isTruncated: false,
it('resolves lowercase "oldest" to Timestamp ASC', () => {
expect(resolveOrderBy('oldest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
});
const command = createCommand(['--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveCustomEventNamesJson).toHaveBeenCalledWith(mockNames, false);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
});
@@ -0,0 +1,381 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppObservePlatform } from '../../../graphql/generated';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../../../observe/fetchCustomEvents';
import {
buildObserveCustomEventNamesJson,
buildObserveCustomEventsEmptyWithSuggestionsJson,
buildObserveCustomEventsEmptyWithSuggestionsTable,
buildObserveCustomEventsJson,
} from '../../../observe/formatCustomEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveLogs from '../logs';
jest.mock('../../../observe/fetchCustomEvents');
jest.mock('../../../observe/formatCustomEvents', () => ({
buildObserveCustomEventsTable: jest.fn().mockReturnValue('table'),
buildObserveCustomEventsJson: jest.fn().mockReturnValue({}),
buildObserveCustomEventNamesTable: jest.fn().mockReturnValue('names-table'),
buildObserveCustomEventNamesJson: jest.fn().mockReturnValue({ names: [], isTruncated: false }),
buildObserveCustomEventsEmptyWithSuggestionsTable: jest
.fn()
.mockReturnValue('empty-with-suggestions-table'),
buildObserveCustomEventsEmptyWithSuggestionsJson: jest.fn().mockReturnValue({
filteredEventName: 'my_event',
events: [],
availableEventNames: [],
availableEventNamesIsTruncated: false,
}),
}));
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
ObserveQuery: {
customEventNamesAsync: jest.fn(),
},
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
const mockBuildObserveCustomEventsJson = jest.mocked(buildObserveCustomEventsJson);
const mockBuildObserveCustomEventNamesJson = jest.mocked(buildObserveCustomEventNamesJson);
const mockBuildObserveCustomEventsEmptyWithSuggestionsTable = jest.mocked(
buildObserveCustomEventsEmptyWithSuggestionsTable
);
const mockBuildObserveCustomEventsEmptyWithSuggestionsJson = jest.mocked(
buildObserveCustomEventsEmptyWithSuggestionsJson
);
const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveLogs, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
});
function createCommand(argv: string[]): ObserveLogs {
const command = new ObserveLogs(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('passes eventName arg to fetchObserveCustomEventsAsync', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [{ id: 'evt-1' } as any],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['my_event']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.eventName).toBe('my_event');
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
});
it('routes to customEventNamesAsync when no positional arg is provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
});
it('routes to fetchObserveCustomEventsAsync when --all-events is set with no positional arg', async () => {
const command = createCommand(['--all-events']);
await command.runAsync();
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.eventName).toBeUndefined();
});
it('throws when both an event name argument and --all-events are provided', async () => {
const command = createCommand(['my_event', '--all-events']);
await expect(command.runAsync()).rejects.toThrow(
'--all-events cannot be combined with an event name argument'
);
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
});
it('passes the resolved time range and platform to customEventNamesAsync', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days', '7', '--platform', 'ios']);
await command.runAsync();
expect(mockCustomEventNamesAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
startTime: '2025-06-08T12:00:00.000Z',
endTime: '2025-06-15T12:00:00.000Z',
platform: AppObservePlatform.Ios,
});
jest.useRealTimers();
});
it('uses --days to compute start/end time range when an event name is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['my_event', '--days', '7']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'my_event',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('rejects --days combined with --start', async () => {
const command = createCommand([
'my_event',
'--days',
'7',
'--start',
'2025-01-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --limit to fetchObserveCustomEventsAsync', async () => {
const command = createCommand(['my_event', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor', async () => {
const command = createCommand(['my_event', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.after).toBe('cursor-xyz');
});
it('passes --platform ios as AppObservePlatform.Ios', async () => {
const command = createCommand(['my_event', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes --app-version', async () => {
const command = createCommand(['my_event', '--app-version', '2.1.0']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.appVersion).toBe('2.1.0');
});
it('passes --update-id', async () => {
const command = createCommand(['my_event', '--update-id', 'update-xyz']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.updateId).toBe('update-xyz');
});
it('passes --session-id', async () => {
const command = createCommand(['my_event', '--session-id', 'session-xyz']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.sessionId).toBe('session-xyz');
});
it('does not pass platform, appVersion, updateId, or sessionId when flags are not provided', async () => {
const command = createCommand(['my_event']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
expect(options.sessionId).toBeUndefined();
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => {
const mockEvents = [
{
id: 'evt-1',
eventName: 'my_event',
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
properties: [],
},
];
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['my_event', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('falls back to fetching event names and renders the empty-with-suggestions table when filtered fetch returns 0 events', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const mockNames = [
{ eventName: 'foo', count: 10 },
{ eventName: 'bar', count: 5 },
];
mockCustomEventNamesAsync.mockResolvedValue({
names: mockNames as any,
isTruncated: false,
});
const command = createCommand(['my_event']);
await command.runAsync();
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).toHaveBeenCalledWith(
'my_event',
mockNames,
expect.objectContaining({ isTruncated: false })
);
});
it('does not call customEventNamesAsync when filtered fetch returns at least one event', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
{
id: 'evt-1',
eventName: 'my_event',
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
easClientId: 'client-1',
properties: [],
} as any,
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['my_event']);
await command.runAsync();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
});
it('emits empty-with-suggestions JSON when filtered fetch returns 0 events and --json is set', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const mockNames = [{ eventName: 'foo', count: 10 }];
mockCustomEventNamesAsync.mockResolvedValue({
names: mockNames as any,
isTruncated: false,
});
const command = createCommand(['my_event', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).toHaveBeenCalledWith(
'my_event',
mockNames,
false
);
expect(mockBuildObserveCustomEventsJson).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('does not run the empty-with-suggestions fallback when no event name is provided (event names mode)', async () => {
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).not.toHaveBeenCalled();
});
it('does not run the empty-with-suggestions fallback for --all-events with 0 results', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['--all-events']);
await command.runAsync();
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
});
it('emits JSON of event names + counts when --json is provided without an event name', async () => {
const mockNames = [
{ eventName: 'foo', count: 10 },
{ eventName: 'bar', count: 5 },
];
mockCustomEventNamesAsync.mockResolvedValue({
names: mockNames as any,
isTruncated: false,
});
const command = createCommand(['--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveCustomEventNamesJson).toHaveBeenCalledWith(mockNames, false);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
});
@@ -1,228 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetricsSummary from '../metrics-summary';
jest.mock('../../../observe/fetchMetrics', () => {
const actual = jest.requireActual('../../../observe/fetchMetrics');
return {
...actual,
fetchObserveMetricsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatMetrics', () => ({
...jest.requireActual('../../../observe/formatMetrics'),
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveMetricsSummaryAsync = jest.mocked(fetchObserveMetricsAsync);
const mockBuildObserveMetricsSummaryTable = jest.mocked(buildObserveMetricsTable);
const mockBuildObserveMetricsSummaryJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveMetricsSummary, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveMetricsSummaryAsync.mockResolvedValue({
metricsMap: new Map(),
buildNumbersMap: new Map(),
updateIdsMap: new Map(),
totalEventCounts: new Map(),
});
});
function createCommand(argv: string[]): ObserveMetricsSummary {
const command = new ObserveMetricsSummary(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches metrics with default parameters (both platforms)', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
expect(mockFetchObserveMetricsSummaryAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
jest.useRealTimers();
});
it('queries only Android when --platform android is passed', async () => {
const command = createCommand(['--platform', 'android']);
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
});
it('queries only iOS when --platform ios is passed', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
await command.runAsync();
const metricNames = mockFetchObserveMetricsSummaryAsync.mock.calls[0][2];
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
const startTime = mockFetchObserveMetricsSummaryAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsSummaryAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const startTime = mockFetchObserveMetricsSummaryAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsSummaryAsync.mock.calls[0][5];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
await command.runAsync();
expect(mockBuildObserveMetricsSummaryTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['p90', 'eventCount'],
expect.objectContaining({ daysBack: 60 })
);
});
it('deduplicates --stat flags that resolve to the same key', async () => {
const command = createCommand(['--stat', 'median', '--stat', 'median']);
await command.runAsync();
expect(mockBuildObserveMetricsSummaryTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median'],
expect.objectContaining({ daysBack: 60 })
);
});
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days', '7']);
await command.runAsync();
const startTime = mockFetchObserveMetricsSummaryAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsSummaryAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days combined with --start', async () => {
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('rejects --days combined with --end', async () => {
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('uses default stats when --stat is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveMetricsSummaryTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median', 'eventCount'],
expect.objectContaining({ daysBack: 60 })
);
});
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
const command = createCommand([
'--json',
'--non-interactive',
'--stat',
'min',
'--stat',
'average',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveMetricsSummaryJson).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['min', 'average'],
expect.any(Map),
expect.any(Map),
expect.any(Map)
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
});
describe(validateDateFlag, () => {
it('throws on invalid --start date', () => {
expect(() => {
validateDateFlag('not-a-date', '--start');
}).toThrow('Invalid --start date: "not-a-date"');
});
it('throws on invalid --end date', () => {
expect(() => {
validateDateFlag('also-bad', '--end');
}).toThrow('Invalid --end date: "also-bad"');
});
it('accepts valid ISO date in --start', () => {
expect(() => {
validateDateFlag('2025-01-01', '--start');
}).not.toThrow();
});
});
@@ -1,31 +1,29 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetrics from '../metrics';
jest.mock('../../../observe/fetchEvents', () => {
const actual = jest.requireActual('../../../observe/fetchEvents');
jest.mock('../../../observe/fetchMetrics', () => {
const actual = jest.requireActual('../../../observe/fetchMetrics');
return {
...actual,
fetchObserveEventsAsync: jest.fn(),
fetchObserveMetricsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
buildObserveEventsJson: jest.fn().mockReturnValue({}),
jest.mock('../../../observe/formatMetrics', () => ({
...jest.requireActual('../../../observe/formatMetrics'),
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
@@ -36,15 +34,17 @@ describe(ObserveMetrics, () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
mockFetchObserveMetricsAsync.mockResolvedValue({
metricsMap: new Map(),
buildNumbersMap: new Map(),
updateIdsMap: new Map(),
totalEventCounts: new Map(),
});
});
function createCommand(argv: string[]): ObserveMetrics {
const command = new ObserveMetrics(argv, mockConfig);
// @ts-expect-error
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
@@ -52,38 +52,61 @@ describe(ObserveMetrics, () => {
return command;
}
it('uses --days to compute start/end time range', async () => {
it('fetches metrics with default parameters (both platforms)', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti', '--days', '7']);
const command = createCommand([]);
await command.runAsync();
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
jest.useRealTimers();
});
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days nor --start/--end are provided', async () => {
it('queries only Android when --platform android is passed', async () => {
const command = createCommand(['--platform', 'android']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
});
it('queries only iOS when --platform ios is passed', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
await command.runAsync();
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti']);
const command = createCommand([]);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'tti',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
@@ -91,190 +114,111 @@ describe(ObserveMetrics, () => {
]);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('defaults endTime to now when only --start is provided', async () => {
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['p90', 'eventCount'],
expect.objectContaining({ daysBack: 60 })
);
});
it('deduplicates --stat flags that resolve to the same key', async () => {
const command = createCommand(['--stat', 'median', '--stat', 'median']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median'],
expect.objectContaining({ daysBack: 60 })
);
});
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti', '--start', '2025-01-01T00:00:00.000Z']);
const command = createCommand(['--days', '7']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days combined with --start', async () => {
const command = createCommand(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --limit to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.after).toBe('cursor-xyz');
});
it('does not pass after when --after flag is not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options).not.toHaveProperty('after');
});
it('rejects --days combined with --end', async () => {
const command = createCommand(['tti', '--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
const command = createCommand(['tti', '--platform', 'ios']);
it('uses default stats when --stat is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median', 'eventCount'],
expect.objectContaining({ daysBack: 60 })
);
});
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
const command = createCommand(['tti', '--platform', 'android']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Android);
});
it('passes --app-version to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--app-version', '2.1.0']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.appVersion).toBe('2.1.0');
});
it('passes --update-id to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--update-id', 'update-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.updateId).toBe('update-xyz');
});
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
const mockEvents = [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
},
];
mockFetchObserveEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['tti', '--json', '--non-interactive']);
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
const command = createCommand([
'--json',
'--non-interactive',
'--stat',
'min',
'--stat',
'average',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['min', 'average'],
expect.any(Map),
expect.any(Map),
expect.any(Map)
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
});
it('does not call enableJsonOutput when --json is not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('passes --sort flag through to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--sort', 'slowest']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.orderBy).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('throws in non-interactive mode when no metric is provided', async () => {
const command = createCommand(['--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
'metric argument is required in non-interactive mode'
describe(validateDateFlag, () => {
it('throws on invalid --start date', () => {
expect(() => validateDateFlag('not-a-date', '--start')).toThrow(
'Invalid --start date: "not-a-date"'
);
});
});
describe(resolveOrderBy, () => {
it('resolves lowercase "slowest" to MetricValue DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
it('throws on invalid --end date', () => {
expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"');
});
it('resolves lowercase "fastest" to MetricValue ASC', () => {
expect(resolveOrderBy('fastest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
});
});
it('resolves lowercase "newest" to Timestamp DESC', () => {
expect(resolveOrderBy('newest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('resolves lowercase "oldest" to Timestamp ASC', () => {
expect(resolveOrderBy('oldest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
});
it('accepts valid ISO date in --start', () => {
expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow();
});
});
@@ -171,7 +171,6 @@ describe(ObserveVersions, () => {
firstSeenAt: '2025-06-01T00:00:00.000Z',
eventCount: 100,
uniqueUserCount: 50,
lastSeenUserCount: 12,
buildNumbers: [],
updates: [],
metrics: [],
+63 -89
View File
@@ -1,11 +1,16 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
import {
EventsOrderPreset,
fetchObserveEventsAsync,
fetchTotalEventCountAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
@@ -14,33 +19,35 @@ import {
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import {
buildObserveCustomEventNamesJson,
buildObserveCustomEventNamesTable,
buildObserveCustomEventsEmptyWithSuggestionsJson,
buildObserveCustomEventsEmptyWithSuggestionsTable,
buildObserveCustomEventsJson,
buildObserveCustomEventsTable,
} from '../../observe/formatCustomEvents';
import { appObservePlatformFromFlag } from '../../observe/platforms';
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
export default class ObserveEvents extends EasCommand {
static override description =
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.';
static override hidden = true;
static override description = 'display individual app performance events ordered by metric value';
static override args = {
eventName: Args.string({
description: 'Event name to filter by',
metric: Args.string({
description: 'Metric to query (e.g. tti, cold_launch)',
required: false,
options: Object.keys(METRIC_ALIASES),
}),
};
static override flags = {
sort: Flags.option({
description: 'Sort order for events',
options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()),
required: false,
default: EventsOrderPreset.Oldest.valueOf().toLowerCase(),
})(),
...ObservePlatformFlag,
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
@@ -50,14 +57,6 @@ export default class ObserveEvents extends EasCommand {
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveUpdateIdFlag,
'session-id': Flags.string({
description: 'Filter by session ID',
}),
'all-events': Flags.boolean({
description:
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
default: false,
}),
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -74,12 +73,6 @@ export default class ObserveEvents extends EasCommand {
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvents);
if (args.eventName && flags['all-events']) {
throw new Error(
'--all-events cannot be combined with an event name argument. Pass an event name to filter by it, or pass --all-events to list all events across all event names.'
);
}
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveEvents,
@@ -90,84 +83,65 @@ export default class ObserveEvents extends EasCommand {
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
let metricName: string;
if (args.metric) {
metricName = resolveMetricName(args.metric);
} else if (flags['non-interactive']) {
throw new EasCommandError(
'A metric argument is required in non-interactive mode. Available metrics: ' +
Object.keys(METRIC_ALIASES).join(', ')
);
} else {
const choices = Object.entries(METRIC_SHORT_NAMES).map(([fullName, displayName]) => ({
title: `${displayName} (${fullName})`,
value: fullName,
}));
metricName = await selectAsync('Select a metric', choices);
}
const orderBy = resolveOrderBy(flags.sort);
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platform = appObservePlatformFromFlag(flags.platform);
const platforms = appPlatformsFromFlag(flags.platform);
if (!args.eventName && !flags['all-events']) {
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
const [{ events, pageInfo }, totalEventCount] = await Promise.all([
fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
});
if (flags.json) {
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveCustomEventNamesTable(names, {
daysBack,
startTime,
endTime,
isTruncated,
})
);
}
return;
}
const { events, pageInfo } = await fetchObserveCustomEventsAsync(graphqlClient, projectId, {
eventName: args.eventName,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
sessionId: flags['session-id'],
});
if (args.eventName && events.length === 0) {
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
appVersion: flags['app-version'],
updateId: flags['update-id'],
}),
fetchTotalEventCountAsync(
graphqlClient,
projectId,
metricName,
platforms,
startTime,
endTime,
platform,
});
if (flags.json) {
printJsonOnlyOutput(
buildObserveCustomEventsEmptyWithSuggestionsJson(args.eventName, names, isTruncated)
);
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveCustomEventsEmptyWithSuggestionsTable(args.eventName, names, {
daysBack,
startTime,
endTime,
isTruncated,
})
);
}
return;
}
endTime
),
]);
if (flags.json) {
printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo));
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveCustomEventsTable(events, pageInfo, {
eventName: args.eventName,
buildObserveEventsTable(events, pageInfo, {
metricName,
daysBack,
startTime,
endTime,
totalEventCount,
})
);
}
@@ -0,0 +1,178 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import {
buildObserveCustomEventNamesJson,
buildObserveCustomEventNamesTable,
buildObserveCustomEventsEmptyWithSuggestionsJson,
buildObserveCustomEventsEmptyWithSuggestionsTable,
buildObserveCustomEventsJson,
buildObserveCustomEventsTable,
} from '../../observe/formatCustomEvents';
import { appObservePlatformFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
export default class ObserveLogs extends EasCommand {
static override hidden = true;
static override description =
'display individual custom events (logs) emitted by the app, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.';
static override args = {
eventName: Args.string({
description: 'Custom event name to filter by',
required: false,
}),
};
static override flags = {
...ObservePlatformFlag,
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_EVENTS_LIMIT,
limit: 100,
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveUpdateIdFlag,
'session-id': Flags.string({
description: 'Filter by session ID',
}),
'all-events': Flags.boolean({
description:
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
default: false,
}),
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveLogs);
if (args.eventName && flags['all-events']) {
throw new Error(
'--all-events cannot be combined with an event name argument. Pass an event name to filter by it, or pass --all-events to list all events across all event names.'
);
}
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveLogs,
loggedInOnlyContextDefinition: ObserveLogs.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platform = appObservePlatformFromFlag(flags.platform);
if (!args.eventName && !flags['all-events']) {
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
endTime,
platform,
});
if (flags.json) {
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveCustomEventNamesTable(names, {
daysBack,
startTime,
endTime,
isTruncated,
})
);
}
return;
}
const { events, pageInfo } = await fetchObserveCustomEventsAsync(graphqlClient, projectId, {
eventName: args.eventName,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
sessionId: flags['session-id'],
});
if (args.eventName && events.length === 0) {
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
endTime,
platform,
});
if (flags.json) {
printJsonOnlyOutput(
buildObserveCustomEventsEmptyWithSuggestionsJson(args.eventName, names, isTruncated)
);
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveCustomEventsEmptyWithSuggestionsTable(args.eventName, names, {
daysBack,
startTime,
endTime,
isTruncated,
})
);
}
return;
}
if (flags.json) {
printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveCustomEventsTable(events, pageInfo, {
eventName: args.eventName,
daysBack,
startTime,
endTime,
})
);
}
}
}
@@ -1,135 +0,0 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import {
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
resolveStatKey,
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_METRICS = [
'expo.app_startup.cold_launch_time',
'expo.app_startup.warm_launch_time',
'expo.app_startup.tti',
'expo.app_startup.ttr',
'expo.app_startup.bundle_load_time',
];
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
const DEFAULT_STATS_JSON: StatisticKey[] = [
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
];
export default class ObserveMetricsSummary extends EasCommand {
static override description =
'display aggregated performance metric statistics grouped by app version';
static override flags = {
...ObservePlatformFlag,
metric: Flags.option({
description: 'Metric name to display (can be specified multiple times).',
multiple: true,
options: Object.keys(METRIC_ALIASES),
})(),
stat: Flags.option({
description: 'Statistic to display per metric (can be specified multiple times)',
multiple: true,
options: DEFAULT_STATS_JSON,
})(),
...ObserveTimeRangeFlags,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetricsSummary);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveMetricsSummary,
loggedInOnlyContextDefinition: ObserveMetricsSummary.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
}
const metricNames = flags.metric?.length
? flags.metric.map(resolveMetricName)
: DEFAULT_METRICS;
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
await fetchObserveMetricsAsync(
graphqlClient,
projectId,
metricNames,
platforms,
startTime,
endTime
);
const argumentsStat = flags.stat?.length
? Array.from(new Set(flags.stat.map(resolveStatKey)))
: undefined;
if (flags.json) {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(
buildObserveMetricsJson(
metricsMap,
metricNames,
stats,
totalEventCounts,
buildNumbersMap,
updateIdsMap
)
);
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
Log.addNewLineIfNone();
Log.log(
buildObserveMetricsTable(metricsMap, metricNames, stats, {
daysBack,
buildNumbersMap,
totalEventCounts,
})
);
}
}
}
@@ -1,61 +1,63 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
EventsOrderPreset,
fetchObserveEventsAsync,
fetchTotalEventCountAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
import {
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
resolveStatKey,
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
const DEFAULT_METRICS = [
'expo.app_startup.cold_launch_time',
'expo.app_startup.warm_launch_time',
'expo.app_startup.tti',
'expo.app_startup.ttr',
'expo.app_startup.bundle_load_time',
];
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
const DEFAULT_STATS_JSON: StatisticKey[] = [
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
];
export default class ObserveMetrics extends EasCommand {
static override description = 'display individual performance metric samples ordered by value';
static override args = {
metric: Args.string({
description: 'Metric to query (e.g. tti, cold_launch)',
required: false,
options: Object.keys(METRIC_ALIASES),
}),
};
static override hidden = true;
static override description = 'display app performance metrics grouped by app version';
static override flags = {
sort: Flags.option({
description: 'Sort order for events',
options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()),
required: false,
default: EventsOrderPreset.Oldest.valueOf().toLowerCase(),
})(),
...ObservePlatformFlag,
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_EVENTS_LIMIT,
limit: 100,
}),
metric: Flags.option({
description: 'Metric name to display (can be specified multiple times).',
multiple: true,
options: Object.keys(METRIC_ALIASES),
})(),
stat: Flags.option({
description: 'Statistic to display per metric (can be specified multiple times)',
multiple: true,
options: DEFAULT_STATS_JSON,
})(),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveUpdateIdFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -70,7 +72,7 @@ export default class ObserveMetrics extends EasCommand {
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveMetrics);
const { flags } = await this.parse(ObserveMetrics);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
@@ -82,63 +84,52 @@ export default class ObserveMetrics extends EasCommand {
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
let metricName: string;
if (args.metric) {
metricName = resolveMetricName(args.metric);
} else if (flags['non-interactive']) {
throw new EasCommandError(
'A metric argument is required in non-interactive mode. Available metrics: ' +
Object.keys(METRIC_ALIASES).join(', ')
);
} else {
const choices = Object.entries(METRIC_SHORT_NAMES).map(([fullName, displayName]) => ({
title: `${displayName} (${fullName})`,
value: fullName,
}));
metricName = await selectAsync('Select a metric', choices);
}
const orderBy = resolveOrderBy(flags.sort);
const metricNames = flags.metric?.length
? flags.metric.map(resolveMetricName)
: DEFAULT_METRICS;
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platform = appObservePlatformFromFlag(flags.platform);
const platforms = appPlatformsFromFlag(flags.platform);
const [{ events, pageInfo }, totalEventCount] = await Promise.all([
fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
}),
fetchTotalEventCountAsync(
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
await fetchObserveMetricsAsync(
graphqlClient,
projectId,
metricName,
metricNames,
platforms,
startTime,
endTime
),
]);
);
const argumentsStat = flags.stat?.length
? Array.from(new Set(flags.stat.map(resolveStatKey)))
: undefined;
if (flags.json) {
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(
buildObserveMetricsJson(
metricsMap,
metricNames,
stats,
totalEventCounts,
buildNumbersMap,
updateIdsMap
)
);
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
Log.addNewLineIfNone();
Log.log(
buildObserveEventsTable(events, pageInfo, {
metricName,
buildObserveMetricsTable(metricsMap, metricNames, stats, {
daysBack,
startTime,
endTime,
totalEventCount,
buildNumbersMap,
totalEventCounts,
})
);
}
@@ -34,6 +34,7 @@ const DEFAULT_STATS_TABLE: NavigationStatKey[] = ['median', 'count'];
const DEFAULT_STATS_JSON: NavigationStatKey[] = ['median', 'p90', 'count'];
export default class ObserveRoutes extends EasCommand {
static override hidden = true;
static override description =
'display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name';
@@ -92,6 +93,8 @@ export default class ObserveRoutes extends EasCommand {
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
const metricNames = flags.metric?.length
@@ -14,6 +14,7 @@ import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ObserveVersions extends EasCommand {
static override hidden = true;
static override description = 'display app versions with build and update details';
static override flags = {
@@ -45,6 +46,8 @@ export default class ObserveVersions extends EasCommand {
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
const { startTime, endTime } = resolveTimeRange(flags);
@@ -1,134 +0,0 @@
import { loadEnvFiles, loadProjectEnv } from '@expo/env';
import spawnAsync from '@expo/spawn-async';
import { Config } from '@oclif/core';
import SimulatorExec from '../exec';
jest.mock('@expo/env', () => ({
loadEnvFiles: jest.fn(),
loadProjectEnv: jest.fn(),
}));
jest.mock('@expo/spawn-async');
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorExec, () => {
const mockConfig = getMockOclifConfig();
const projectDir = '/test/project';
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(spawnAsync).mockResolvedValue({} as never);
});
function createCommand(argv: string[]): {
command: SimulatorExec;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorExec(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectDir,
});
return { command, getContextAsync };
}
it('loads local env files and spawns the supplied command with inherited stdio', async () => {
const { command, getContextAsync } = createCommand(['agent-device', 'touch', '@e2']);
await command.runAsync();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorExec, {
nonInteractive: true,
});
expect(loadProjectEnv).toHaveBeenCalledWith(projectDir, { silent: true });
expect(loadEnvFiles).toHaveBeenCalledWith([`${projectDir}/.env.eas-simulator`], {
force: true,
});
expect(spawnAsync).toHaveBeenCalledWith('agent-device', ['touch', '@e2'], {
stdio: 'inherit',
env: process.env,
});
});
it('passes through command flags as args', async () => {
const { command } = createCommand([
'agent-device',
'screenshot',
'/test/path.png',
'--format',
'png',
]);
await command.runAsync();
expect(spawnAsync).toHaveBeenCalledWith(
'agent-device',
['screenshot', '/test/path.png', '--format', 'png'],
{
stdio: 'inherit',
env: process.env,
}
);
});
it('does not emit an oclif unparsed arguments warning', async () => {
const { command } = createCommand(['echo', '"test"']);
const emitWarningSpy = jest.spyOn(process, 'emitWarning').mockImplementation();
jest.spyOn(command, 'run').mockImplementation(async () => {
await command.runAsync();
});
jest.spyOn(command, 'finally').mockResolvedValue(undefined);
try {
// @ts-expect-error _run is internal to oclif, but it is where the unparsed warning is emitted.
await command._run();
const emittedUnparsedCommandWarning = emitWarningSpy.mock.calls.some(([warning, options]) => {
return (
typeof warning === 'string' &&
warning.includes('did not parse its arguments') &&
typeof options === 'object' &&
options !== null &&
'code' in options &&
options.code === 'UnparsedCommand'
);
});
expect(emittedUnparsedCommandWarning).toBe(false);
} finally {
emitWarningSpy.mockRestore();
}
});
it('throws a helpful error when no command is provided', async () => {
const { command, getContextAsync } = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
'No command provided. Run `eas simulator:exec <command> [args...]`.'
);
expect(getContextAsync).not.toHaveBeenCalled();
expect(loadProjectEnv).not.toHaveBeenCalled();
expect(loadEnvFiles).not.toHaveBeenCalled();
expect(spawnAsync).not.toHaveBeenCalled();
});
it('loads simulator-specific env after regular env files', async () => {
const { command } = createCommand(['agent-device', 'touch', '@e2']);
await command.runAsync();
expect(loadProjectEnv).toHaveBeenCalledWith(projectDir, { silent: true });
expect(loadEnvFiles).toHaveBeenCalledWith([`${projectDir}/.env.eas-simulator`], {
force: true,
});
expect(jest.mocked(loadProjectEnv).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(loadEnvFiles).mock.invocationCallOrder[0]
);
});
});
@@ -1,167 +0,0 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorGet from '../get';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/json');
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: {
id: 'account-123',
name: 'testuser',
},
},
remoteConfig: {
__typename: 'AgentDeviceRunSessionRemoteConfig',
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
agentDeviceRemoteSessionToken: 'token-123',
webPreviewUrl: 'https://preview.example.com',
},
turtleJobRun: {
id: 'job-123',
status: JobRunStatus.InProgress,
},
...overrides,
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorGet, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectDir = '/test/project';
beforeEach(() => {
jest.clearAllMocks();
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
});
function createCommand(argv: string[]): {
command: SimulatorGet;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorGet(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
});
return { command, getContextAsync };
}
it('emits JSON when --json is passed', async () => {
const session = makeDeviceRunSession();
mockByIdAsync.mockResolvedValue(session);
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(getContextAsync).toHaveBeenCalledWith(SimulatorGet, {
nonInteractive: true,
});
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
type: 'agent-device',
status: DeviceRunSessionStatus.InProgress,
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
remoteConfig: session.remoteConfig,
});
});
it(`uses ${EAS_SIMULATOR_SESSION_ID} from simulator env when --id is not passed`, async () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
const session = makeDeviceRunSession({ id: 'session-from-env' });
mockByIdAsync.mockResolvedValue(session);
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env';
try {
const { command } = createCommand([]);
await command.runAsync();
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-from-env');
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
}
});
it('throws a helpful error when no simulator session ID is available', async () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
delete process.env[EAS_SIMULATOR_SESSION_ID];
try {
const { command } = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(mockByIdAsync).not.toHaveBeenCalled();
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
}
});
});
@@ -1,213 +0,0 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeviceRunSessionStatus,
DeviceRunSessionType,
DeviceRunSessionsByAppIdQuery,
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorList from '../list';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/json');
type DeviceRunSessionEdge =
DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated']['edges'][number];
type DeviceRunSessionNode = DeviceRunSessionEdge['node'];
type DeviceRunSessionsPaginated =
DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated'];
const mockListByAppIdAsync = jest.mocked(DeviceRunSessionQuery.listByAppIdAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeSession(overrides: Partial<DeviceRunSessionNode> = {}): DeviceRunSessionNode {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: null,
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: {
id: 'account-123',
name: 'testuser',
},
},
turtleJobRun: {
id: 'job-123',
status: JobRunStatus.InProgress,
},
...overrides,
};
}
function makeConnection(
nodes: DeviceRunSessionNode[],
pageInfo: Partial<DeviceRunSessionsPaginated['pageInfo']> = {}
): DeviceRunSessionsPaginated {
return {
edges: nodes.map((node, index) => ({ cursor: `cursor-${index}`, node })),
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: nodes.length > 0 ? 'cursor-0' : null,
endCursor: nodes.length > 0 ? `cursor-${nodes.length - 1}` : null,
...pageInfo,
},
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorList, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
function createCommand(argv: string[]): {
command: SimulatorList;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorList(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId: 'app-123',
loggedIn: { graphqlClient },
});
return { command, getContextAsync };
}
it('emits JSON when --json is passed', async () => {
const session = makeSession();
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
const { command, getContextAsync } = createCommand(['--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorList, {
nonInteractive: true,
});
expect(mockListByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'app-123',
first: 10,
after: undefined,
filter: undefined,
});
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
sessions: [
{
id: 'session-123',
type: 'agent-device',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: undefined,
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor-0',
endCursor: 'cursor-0',
},
});
});
it('passes filters and cursor to the query', async () => {
mockListByAppIdAsync.mockResolvedValue(makeConnection([]));
const { command } = createCommand([
'--json',
'--status',
'in-progress',
'--status',
'new',
'--type',
'argent',
'--platform',
'ios',
'--limit',
'25',
'--after',
'page-cursor',
]);
await command.runAsync();
expect(mockListByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'app-123',
first: 25,
after: 'page-cursor',
filter: {
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
types: [DeviceRunSessionType.Argent],
platforms: [AppPlatform.Ios],
},
});
});
it('runs non-interactively without --json', async () => {
const session = makeSession({ id: 'session-1', turtleJobRun: null });
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
const { command, getContextAsync } = createCommand(['--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorList, {
nonInteractive: true,
});
expect(mockListByAppIdAsync).toHaveBeenCalled();
});
it('handles empty results', async () => {
mockListByAppIdAsync.mockResolvedValue(makeConnection([]));
const { command } = createCommand(['--json']);
await command.runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
sessions: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
});
});
});
@@ -1,317 +0,0 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
CreateDeviceRunSessionMutation,
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_HEADER,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import SimulatorStart from '../start';
jest.mock('fs-extra');
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log', () => ({
__esModule: true,
default: {
debug: jest.fn(),
log: jest.fn(),
newLine: jest.fn(),
warn: jest.fn(),
withTick: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
resetSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
type CreatedDeviceRunSession =
CreateDeviceRunSessionMutation['deviceRunSession']['createDeviceRunSession'];
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const graphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/test/project';
const simulatorDotenvPath = `${projectDir}/.env.eas-simulator`;
const jobRunUrl = 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123';
const mockCreateDeviceRunSessionAsync = jest.mocked(
DeviceRunSessionMutation.createDeviceRunSessionAsync
);
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
const mockOra = jest.mocked(ora);
function makeCreatedDeviceRunSession(
overrides: Partial<CreatedDeviceRunSession> = {}
): CreatedDeviceRunSession {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: {
id: 'account-123',
name: 'testuser',
},
},
turtleJobRun: {
id: 'job-123',
},
...overrides,
};
}
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: {
id: 'account-123',
name: 'testuser',
},
},
remoteConfig: {
__typename: 'AgentDeviceRunSessionRemoteConfig',
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
agentDeviceRemoteSessionToken: 'token-123',
webPreviewUrl: 'https://preview.example.com',
},
turtleJobRun: {
id: 'job-123',
status: JobRunStatus.InProgress,
},
...overrides,
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorStart, () => {
const mockConfig = getMockOclifConfig();
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
beforeEach(() => {
jest.clearAllMocks();
delete process.env[EAS_SIMULATOR_SESSION_ID];
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
mockLoadSimulatorEnvAsync.mockResolvedValue();
mockResetSimulatorEnvAsync.mockResolvedValue();
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
});
afterAll(() => {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
});
function createCommand(argv: string[]): {
command: SimulatorStart;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorStart(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
projectId: 'project-123',
});
return { command, getContextAsync };
}
it('prints environment variables without saving when outputting env', async () => {
const { command, getContextAsync } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--out-config-type',
'env',
]);
await command.runAsync();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorStart, {
nonInteractive: true,
});
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'project-123',
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
});
expect(fs.writeFile).not.toHaveBeenCalled();
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
`Device run session created (id: session-123) ${jobRunUrl}`
);
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining("export AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'")
);
});
it('writes .env.eas-simulator with the environment variables by default', async () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
expect(fs.writeFile).toHaveBeenNthCalledWith(
1,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
expect(fs.writeFile).toHaveBeenNthCalledWith(
2,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER +
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
expect(jest.mocked(fs.writeFile).mock.invocationCallOrder[0]).toBeLessThan(
mockByIdAsync.mock.invocationCallOrder[0]
);
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
`Device run session created (id: session-123, saved to ${SIMULATOR_DOTENV_FILE_NAME}) ${jobRunUrl}`
);
expect(Log.withTick).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(
[
'🔑 Run the following to use agent-device with the simulator:',
'',
'eas simulator:exec agent-device <command>',
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
'https://preview.example.com',
].join('\n')
);
});
it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--out-config-type',
'dotenv',
]);
await command.runAsync();
expect(fs.writeFile).toHaveBeenNthCalledWith(
1,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
expect(fs.writeFile).toHaveBeenNthCalledWith(
2,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER +
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
});
it(`warns and creates a new session when ${EAS_SIMULATOR_SESSION_ID} is already present by default`, async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
' Overwriting previous simulator session (id: existing-session). ' +
'The previous remote session will continue running until stopped. ' +
'To stop it, run: eas simulator:stop --id existing-session'
);
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'project-123',
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
});
});
it(`creates a new session when ${EAS_SIMULATOR_SESSION_ID} is present with --force`, async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--force']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
' Overwriting previous simulator session (id: existing-session). ' +
'The previous remote session will continue running until stopped. ' +
'To stop it, run: eas simulator:stop --id existing-session'
);
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'project-123',
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
});
});
it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--no-force']);
await expect(command.runAsync()).rejects.toThrow(
'Existing simulator session in environment. Use --force to create a new device session.'
);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('resets .env.eas-simulator when the interactive wait observes the session end', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
});
});
@@ -1,152 +0,0 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
DeviceRunSessionStatus,
EnsureDeviceRunSessionStoppedMutation,
} from '../../../graphql/generated';
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorStop from '../stop';
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/json');
type StoppedDeviceRunSession =
EnsureDeviceRunSessionStoppedMutation['deviceRunSession']['ensureDeviceRunSessionStopped'];
const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeStoppedDeviceRunSession(
overrides: Partial<StoppedDeviceRunSession> = {}
): StoppedDeviceRunSession {
return {
id: 'session-123',
status: DeviceRunSessionStatus.Stopped,
...overrides,
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorStop, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectDir = '/test/project';
beforeEach(() => {
jest.clearAllMocks();
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(makeStoppedDeviceRunSession());
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
});
function createCommand(argv: string[]): {
command: SimulatorStop;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorStop(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
});
return { command, getContextAsync };
}
it('emits JSON when --json is passed', async () => {
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(getContextAsync).toHaveBeenCalledWith(SimulatorStop, {
nonInteractive: true,
});
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-123'
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
status: DeviceRunSessionStatus.Stopped,
});
});
it(`uses ${EAS_SIMULATOR_SESSION_ID} from simulator env when --id is not passed`, async () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env';
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(
makeStoppedDeviceRunSession({ id: 'session-from-env' })
);
try {
const { command } = createCommand([]);
await command.runAsync();
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-from-env'
);
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
}
});
it('throws a helpful error when no simulator session ID is available', async () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
delete process.env[EAS_SIMULATOR_SESSION_ID];
try {
const { command } = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
}
});
});
@@ -1,47 +0,0 @@
import spawnAsync from '@expo/spawn-async';
import EasCommand from '../../commandUtils/EasCommand';
import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulator/env';
export default class SimulatorExec extends EasCommand {
static override hidden = true;
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
static override strict = false;
static override contextDefinition = {
...this.ContextOptions.ProjectDir,
};
private isRunningSubprocess = false;
async runAsync(): Promise<void> {
const rawArgv = [...this.argv];
// Required to avoid `Warning: Command exec did not parse its arguments. Did you forget to call 'this.parse'?`
await this.parse(SimulatorExec, []);
const [command, ...args] = rawArgv;
if (typeof command !== 'string' || command.length === 0) {
throw new Error('No command provided. Run `eas simulator:exec <command> [args...]`.');
}
const { projectDir } = await this.getContextAsync(SimulatorExec, {
nonInteractive: true,
});
await loadSimulatorEnvAsync(projectDir);
this.isRunningSubprocess = true;
await spawnAsync(command, args, {
stdio: 'inherit',
env: process.env,
});
}
protected override catch(err: Error): Promise<any> {
// Propagate wrapped command from spawnAsync rejection
if (this.isRunningSubprocess) {
process.exitCode = process.exitCode ?? (err as any).status ?? 1;
return Promise.resolve();
}
return super.catch(err);
}
}
+10 -48
View File
@@ -2,72 +2,45 @@ import { Flags } from '@oclif/core';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
deviceRunSessionTypeToFlagValue,
formatRemoteSessionInstructions,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { formatRemoteSessionInstructions } from '../../simulator/utils';
export default class SimulatorGet extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its device run session ID';
static override flags = {
id: Flags.string({
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
description: 'Device run session ID',
required: true,
}),
...EasNonInteractiveAndJsonFlags,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorGet);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorGet, {
nonInteractive,
nonInteractive: flags['non-interactive'],
});
await loadSimulatorEnvAsync(projectDir);
const flagId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID];
if (!flagId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const fetchSpinner = ora(`Fetching device run session ${flagId}`).start();
const fetchSpinner = ora(`Fetching device run session ${flags.id}`).start();
let session;
try {
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flagId);
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flags.id);
fetchSpinner.succeed(`Fetched device run session ${session.id}`);
} catch (err) {
fetchSpinner.fail(`Failed to fetch device run session ${flagId}`);
fetchSpinner.fail(`Failed to fetch device run session ${flags.id}`);
throw err;
}
@@ -75,17 +48,6 @@ export default class SimulatorGet extends EasCommand {
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
: '';
if (jsonFlag) {
printJsonOnlyOutput({
id: session.id,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
jobRunUrl: jobRunUrl || undefined,
remoteConfig: session.remoteConfig,
});
return;
}
Log.newLine();
Log.log(`ID: ${session.id}`);
Log.log(`Type: ${session.type}`);
@@ -95,7 +57,7 @@ export default class SimulatorGet extends EasCommand {
if (session.status === DeviceRunSessionStatus.InProgress) {
Log.newLine();
if (session.remoteConfig) {
Log.log(formatRemoteSessionInstructions(session.remoteConfig, 'env'));
Log.log(formatRemoteSessionInstructions(session.remoteConfig));
} else {
Log.log(
'⏳ Session is starting up — remote config is not available yet. Re-run this command in a moment.'
@@ -1,185 +0,0 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import {
AppPlatform,
DeviceRunSessionFilterInput,
DeviceRunSessionStatus,
} from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
deviceRunSessionTypeToFlagValue,
} from '../../simulator/utils';
import { fromNow } from '../../utils/date';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 100;
const STATUS_FLAG_VALUES: Record<DeviceRunSessionStatus, string> = {
[DeviceRunSessionStatus.New]: 'new',
[DeviceRunSessionStatus.InProgress]: 'in-progress',
[DeviceRunSessionStatus.Stopped]: 'stopped',
[DeviceRunSessionStatus.Errored]: 'errored',
};
const STATUS_BY_FLAG_VALUE = Object.fromEntries(
Object.entries(STATUS_FLAG_VALUES).map(([status, value]) => [
value,
status as DeviceRunSessionStatus,
])
);
const PLATFORM_FLAG_VALUES: Record<AppPlatform, string> = {
[AppPlatform.Android]: 'android',
[AppPlatform.Ios]: 'ios',
};
const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
Object.entries(PLATFORM_FLAG_VALUES).map(([platform, value]) => [value, platform as AppPlatform])
);
export default class SimulatorList extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] list remote simulator sessions for the current project';
static override flags = {
status: Flags.option({
description: 'Filter by session status (repeatable)',
options: Object.values(STATUS_FLAG_VALUES),
multiple: true,
})(),
type: Flags.option({
description: 'Filter by session type (repeatable)',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
multiple: true,
})(),
platform: Flags.option({
description: 'Filter by device platform (repeatable)',
options: Object.values(PLATFORM_FLAG_VALUES),
multiple: true,
})(),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorList);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorList, {
nonInteractive,
});
const filter: DeviceRunSessionFilterInput = {};
if (flags.status && flags.status.length > 0) {
filter.statuses = flags.status.map(value => STATUS_BY_FLAG_VALUE[value]);
}
if (flags.type && flags.type.length > 0) {
filter.types = flags.type.map(value => DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[value]);
}
if (flags.platform && flags.platform.length > 0) {
filter.platforms = flags.platform.map(value => PLATFORM_BY_FLAG_VALUE[value]);
}
const limit = flags.limit ?? DEFAULT_LIMIT;
const fetchSpinner = jsonFlag ? null : ora('Fetching device run sessions').start();
let connection;
try {
connection = await DeviceRunSessionQuery.listByAppIdAsync(graphqlClient, {
appId: projectId,
first: limit,
after: flags.after,
filter: Object.keys(filter).length > 0 ? filter : undefined,
});
fetchSpinner?.succeed(`Fetched ${connection.edges.length} device run session(s)`);
} catch (err) {
fetchSpinner?.fail('Failed to fetch device run sessions');
throw err;
}
const sessions = connection.edges.map(edge => edge.node);
if (jsonFlag) {
printJsonOnlyOutput({
sessions: sessions.map(session => ({
id: session.id,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
platform: session.platform,
createdAt: session.createdAt,
startedAt: session.startedAt ?? undefined,
finishedAt: session.finishedAt ?? undefined,
jobRunUrl: session.turtleJobRun
? getBareJobRunUrl(
session.app.ownerAccount.name,
session.app.slug,
session.turtleJobRun.id
)
: undefined,
})),
pageInfo: connection.pageInfo,
});
return;
}
if (sessions.length === 0) {
Log.newLine();
Log.log('No device run sessions found.');
return;
}
Log.newLine();
const formattedEntries = sessions.map(session => {
const jobRunUrl = session.turtleJobRun
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
: null;
const lines = [
`ID: ${session.id}`,
`Type: ${session.type}`,
`Status: ${session.status}`,
`Platform: ${session.platform}`,
`Created: ${fromNow(new Date(session.createdAt))} ago`,
];
if (jobRunUrl) {
lines.push(`URL: ${link(jobRunUrl)}`);
}
return lines.join('\n');
});
Log.log(formattedEntries.join(`\n\n${chalk.dim('———')}\n\n`));
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
Log.newLine();
Log.log(
`More results available. Re-run with --after ${connection.pageInfo.endCursor} to fetch the next page.`
);
}
}
}
@@ -1,5 +1,4 @@
import { Flags } from '@oclif/core';
import nullthrows from 'nullthrows';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
@@ -19,31 +18,31 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue
import Log, { link } from '../../log';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
writeSimulatorEnvAsync,
} from '../../simulator/env';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
DeviceRunSessionRemoteConfig,
formatRemoteSessionInstructions,
getRemoteSessionEnvironmentVariables,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
import nullthrows from 'nullthrows';
const POLL_INTERVAL_MS = 5_000; // 5 seconds
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
const OUT_CONFIG_TYPE_VALUES = {
Env: 'env',
Dotenv: 'dotenv',
} as const;
// Mapping enum → CLI flag value. Declared as Record<DeviceRunSessionType, string>
// so adding a new enum value in codegen fails the build until it is wired up here.
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
[DeviceRunSessionType.AgentDevice]: 'agent-device',
[DeviceRunSessionType.Argent]: 'argent',
[DeviceRunSessionType.ServeSim]: 'serve-sim',
};
const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
(Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map(
([type, value]) => [value, type]
)
) as Record<string, DeviceRunSessionType>;
export default class SimulatorStart extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
@@ -62,23 +61,11 @@ export default class SimulatorStart extends EasCommand {
description:
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
force: Flags.boolean({
description:
'[default: true] Create a new device session even when an existing simulator session is present in the environment.',
default: true,
allowNo: true,
}),
'out-config-type': Flags.option({
description: `How to output simulator connection configuration. Use "env" to print shell exports, or "dotenv" to write ${SIMULATOR_DOTENV_FILE_NAME}.`,
options: Object.values(OUT_CONFIG_TYPE_VALUES),
default: OUT_CONFIG_TYPE_VALUES.Dotenv,
})(),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
@@ -92,28 +79,11 @@ export default class SimulatorStart extends EasCommand {
const {
projectId,
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStart, {
nonInteractive,
});
await loadSimulatorEnvAsync(projectDir);
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
if (existingDeviceRunSessionId && !flags.force) {
throw new Error(
`Existing simulator session in environment. Use --force to create a new device session.`
);
}
if (existingDeviceRunSessionId) {
Log.warn(
` Overwriting previous simulator session (id: ${existingDeviceRunSessionId}). ` +
`The previous remote session will continue running until stopped. ` +
`To stop it, run: eas simulator:stop --id ${existingDeviceRunSessionId}`
);
Log.newLine();
}
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
const createSpinner = ora('🚀 Creating device run session').start();
@@ -129,16 +99,8 @@ export default class SimulatorStart extends EasCommand {
deviceRunSessionId = session.id;
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
const simulatorEnvWritten =
!jsonFlag && flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
? await writeSimulatorEnvSafelyAsync(projectDir, {
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
})
: false;
createSpinner.succeed(
`Device run session created (id: ${deviceRunSessionId}${
simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : ''
}) ${link(jobRunUrl)}`
`Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}`
);
} catch (err) {
createSpinner.fail('Failed to create device run session');
@@ -195,13 +157,6 @@ export default class SimulatorStart extends EasCommand {
);
}
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
await writeSimulatorEnvSafelyAsync(projectDir, {
...getRemoteSessionEnvironmentVariables(remoteConfig),
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
});
}
if (jsonFlag) {
printJsonOnlyOutput({
id: deviceRunSessionId,
@@ -213,7 +168,7 @@ export default class SimulatorStart extends EasCommand {
}
Log.newLine();
Log.log(formatRemoteSessionInstructions(remoteConfig, flags['out-config-type']));
Log.log(formatRemoteSessionInstructions(remoteConfig));
Log.newLine();
if (nonInteractive) {
@@ -227,38 +182,18 @@ export default class SimulatorStart extends EasCommand {
graphqlClient,
deviceRunSessionId,
jobRunUrl,
projectDir,
});
}
}
async function writeSimulatorEnvSafelyAsync(
projectDir: string,
environmentVariables: Record<string, string>
): Promise<boolean> {
try {
await writeSimulatorEnvAsync(projectDir, environmentVariables);
return true;
} catch (err) {
Log.warn(
`Failed to write simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}: ${
err instanceof Error ? err.message : String(err)
}`
);
return false;
}
}
async function waitForSessionEndOrInterruptAsync({
graphqlClient,
deviceRunSessionId,
jobRunUrl,
projectDir,
}: {
graphqlClient: ExpoGraphqlClient;
deviceRunSessionId: string;
jobRunUrl: string;
projectDir: string;
}): Promise<void> {
const spinner = ora(
`Device run session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
@@ -315,7 +250,6 @@ async function waitForSessionEndOrInterruptAsync({
jobRunStatus === JobRunStatus.Finished
) {
spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`);
await resetSimulatorEnvVerboseAsync(projectDir);
return;
}
@@ -329,7 +263,6 @@ async function waitForSessionEndOrInterruptAsync({
);
if (stopped) {
spinner.succeed('Device run session stopped');
await resetSimulatorEnvVerboseAsync(projectDir);
} else {
spinner.fail(
`Could not confirm the device run session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
@@ -340,15 +273,6 @@ async function waitForSessionEndOrInterruptAsync({
}
}
async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise<void> {
try {
await resetSimulatorEnvAsync(projectDir);
} catch (err) {
Log.error(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
throw err;
}
}
async function ensureDeviceRunSessionStoppedSafelyAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
@@ -1,74 +1,45 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorStop extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
static override flags = {
id: Flags.string({
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
description: 'Device run session ID',
required: true,
}),
...EasNonInteractiveAndJsonFlags,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStop);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStop, {
nonInteractive,
nonInteractive: flags['non-interactive'],
});
await loadSimulatorEnvAsync(projectDir);
const flagId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID];
if (!flagId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const stopSpinner = ora(`🛑 Stopping device run session ${flagId}`).start();
let session;
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
try {
session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
const session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
graphqlClient,
flagId
flags.id
);
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
} catch (err) {
stopSpinner.fail(`Failed to stop device run session ${flagId}`);
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
throw err;
}
if (jsonFlag) {
printJsonOnlyOutput({ id: session.id, status: session.status });
}
}
}
@@ -1,130 +0,0 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EmbeddedUpdateMutation } from '../../../../graphql/mutations/EmbeddedUpdateMutation';
import Log from '../../../../log';
import * as prompts from '../../../../prompts';
import * as json from '../../../../utils/json';
import UpdateEmbeddedDelete from '../delete';
jest.mock('../../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
EmbeddedUpdateMutation: { deleteEmbeddedUpdateAsync: jest.fn() },
}));
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../prompts');
const mockDelete = jest.mocked(EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync);
const mockToggleConfirm = jest.mocked(prompts.toggleConfirmAsync);
const mockLogLog = jest.mocked(Log.log);
const mockLogWithTick = jest.mocked(Log.withTick);
const mockLogError = jest.mocked(Log.error);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
const MOCK_CONTEXT = {
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
describe(UpdateEmbeddedDelete, () => {
const mockConfig = getMockOclifConfig();
let processExitSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
mockDelete.mockResolvedValue({ id: VALID_UUID });
mockToggleConfirm.mockResolvedValue(true);
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as never);
});
afterEach(() => {
processExitSpy.mockRestore();
});
function createCommand(argv: string[]): UpdateEmbeddedDelete {
const command = new UpdateEmbeddedDelete(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('deletes when the user confirms', async () => {
await createCommand([VALID_UUID]).run();
expect(mockToggleConfirm).toHaveBeenCalledTimes(1);
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
});
it('skips confirmation in non-interactive mode', async () => {
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockToggleConfirm).not.toHaveBeenCalled();
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
});
it('aborts (process.exit) without calling the mutation when the user declines', async () => {
mockToggleConfirm.mockResolvedValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
expect(mockDelete).not.toHaveBeenCalled();
expect(mockLogWithTick).not.toHaveBeenCalled();
});
it('--json prints { id } and skips the human-readable success line', async () => {
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
expect(mockLogWithTick).not.toHaveBeenCalled();
});
it('rethrows unexpected mutation errors', async () => {
const boom = new Error('boom');
mockDelete.mockRejectedValue(boom);
await expect(createCommand([VALID_UUID, '--non-interactive']).run()).rejects.toThrow('boom');
});
it('succeeds for an unknown id (server best-effort)', async () => {
// Server returns { id } even when nothing was deleted, so we should still tick.
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
});
it('prints an explanatory message before prompting (interactive)', async () => {
await createCommand([VALID_UUID]).run();
const lines = mockLogLog.mock.calls.map(c => String(c[0])).join('\n');
expect(lines).toContain(`permanently delete embedded update: "${VALID_UUID}"`);
expect(lines).toContain('Diff patches already generated');
expect(lines).toContain('re-upload');
});
it('logs a cancel message when the user declines', async () => {
mockToggleConfirm.mockResolvedValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
expect(mockLogError).toHaveBeenCalledWith(
`Canceled deletion of embedded update: "${VALID_UUID}".`
);
});
it('passes the id through to deleteEmbeddedUpdateAsync', async () => {
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
});
it('--json + --non-interactive: skips prompt, skips tick, prints { id }', async () => {
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
expect(mockToggleConfirm).not.toHaveBeenCalled();
expect(mockLogWithTick).not.toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
});
});
@@ -1,280 +0,0 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../../graphql/generated';
import { ChannelQuery } from '../../../../graphql/queries/ChannelQuery';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../../log';
import { selectAsync } from '../../../../prompts';
import * as json from '../../../../utils/json';
import UpdateEmbeddedList from '../list';
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
EmbeddedUpdateQuery: { viewPaginatedAsync: jest.fn() },
}));
jest.mock('../../../../graphql/queries/ChannelQuery', () => ({
ChannelQuery: { viewUpdateChannelsOnAppAsync: jest.fn() },
}));
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
const mockPaginated = jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync);
const mockViewChannels = jest.mocked(ChannelQuery.viewUpdateChannelsOnAppAsync);
const mockSelectAsync = jest.mocked(selectAsync);
const mockLogLog = jest.mocked(Log.log);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const MOCK_CONTEXT = {
projectId: 'project-123',
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
const ROW_A: EmbeddedUpdateFragment = {
id: 'aaaaaaaa-1111-4000-8000-000000000001',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2026-05-29T00:00:00Z',
launchAsset: { id: 'asset-a', fileSize: 1024, finalFileSize: 768, fileSHA256: 'abc123' },
};
const ROW_B: EmbeddedUpdateFragment = {
id: 'bbbbbbbb-2222-4000-8000-000000000002',
platform: AppPlatform.Android,
runtimeVersion: '1.0.1',
channel: 'preview',
createdAt: '2026-05-30T00:00:00Z',
launchAsset: { id: 'asset-b', fileSize: 2048, finalFileSize: null, fileSHA256: 'def456' },
};
function emptyConnection(): {
edges: { cursor: string; node: EmbeddedUpdateFragment }[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
} {
return {
edges: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null },
};
}
describe(UpdateEmbeddedList, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
// Default: no channels on the project (skips the prompt).
mockViewChannels.mockResolvedValue([]);
});
function createCommand(argv: string[]): UpdateEmbeddedList {
const command = new UpdateEmbeddedList(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('prints each row when results exist', async () => {
mockPaginated.mockResolvedValue({
edges: [
{ cursor: 'c1', node: ROW_A },
{ cursor: 'c2', node: ROW_B },
],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c2' },
});
await createCommand(['--non-interactive']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_A.id))).toBe(true);
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_B.id))).toBe(true);
});
it('prints empty message when no results', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive']).run();
expect(mockLogLog).toHaveBeenCalledWith('No embedded updates found.');
});
it('--json prints connection payload and skips formatted output', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--json', '--non-interactive']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({
embeddedUpdates: [ROW_A],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
expect(mockLogLog.mock.calls.every(c => !String(c[0]).includes(ROW_A.id))).toBe(true);
});
it('passes filter when flags supplied', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([
'--non-interactive',
'--platform',
'ios',
'--runtime-version',
'1.2.0',
'--channel',
'preview',
]).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.2.0', channel: 'preview' },
first: 25,
after: undefined,
});
});
it('shows next-page hint when hasNextPage', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('--after-cursor c1'))).toBe(true);
});
it('prompts for channel in interactive mode and applies the selected channel', async () => {
mockViewChannels.mockResolvedValue([
{ id: 'ch1', name: 'production' } as any,
{ id: 'ch2', name: 'preview' } as any,
]);
mockSelectAsync.mockResolvedValue('preview');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: { platform: undefined, runtimeVersion: undefined, channel: 'preview' },
first: 25,
after: undefined,
});
});
it('skips channel filter when "All channels" is selected', async () => {
mockViewChannels.mockResolvedValue([{ id: 'ch1', name: 'production' } as any]);
// The sentinel value returned by selectAsync for the "All channels" option.
mockSelectAsync.mockResolvedValue('__embedded_update_list__all_channels__');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
});
it('skips the channel prompt when --channel is supplied', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--channel', 'preview']).run();
expect(mockSelectAsync).not.toHaveBeenCalled();
expect(mockViewChannels).not.toHaveBeenCalled();
});
it('treats --channel all as no channel filter', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--channel', 'all']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
});
it('skips the channel prompt when the project has no channels', async () => {
mockViewChannels.mockResolvedValue([]);
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockSelectAsync).not.toHaveBeenCalled();
});
it('passes --after-cursor through to viewPaginatedAsync', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive', '--after-cursor', 'cursor-123']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: 'cursor-123',
});
});
it('honors --limit', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive', '--limit', '5']).run();
expect(mockPaginated).toHaveBeenCalledWith(
MOCK_CONTEXT.loggedIn.graphqlClient,
expect.objectContaining({ first: 5 })
);
});
it('prints the section header with row count', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1)'))).toBe(
true
);
});
it('suffixes the section header count with "+" when hasNextPage', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1+)'))).toBe(
true
);
});
it('shows relative-time "ago" hints in formatted rows', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).toBe(true);
});
it('puts "All channels" first in the prompt, then channels in order', async () => {
mockViewChannels.mockResolvedValue([
{ id: 'ch1', name: 'production' } as any,
{ id: 'ch2', name: 'preview' } as any,
]);
mockSelectAsync.mockResolvedValue('production');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
const [, choices] = mockSelectAsync.mock.calls[0];
const titles = (choices as any[]).map(c => c.title);
expect(titles).toEqual(['All channels', 'production', 'preview']);
});
});
@@ -1,337 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { Updates } from '@expo/config-plugins';
import { vol } from 'memfs';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EmbeddedUpdateAssetMutation } from '../../../../graphql/mutations/EmbeddedUpdateAssetMutation';
import {
EmbeddedUpdateMutation,
isEmbeddedUpdateAlreadyExistsError,
isEmbeddedUpdateAssetNotAvailableError,
} from '../../../../graphql/mutations/EmbeddedUpdateMutation';
import { AppPlatform } from '../../../../graphql/generated';
import Log from '../../../../log';
import { ora } from '../../../../ora';
import * as uploads from '../../../../uploads';
import * as json from '../../../../utils/json';
import * as promise from '../../../../utils/promise';
import UpdateEmbeddedUpload from '../upload';
jest.mock('fs', () => jest.requireActual('memfs').fs);
jest.mock('../../../../ora');
jest.mock('@expo/config-plugins', () => ({
Updates: { getRuntimeVersionNullableAsync: jest.fn() },
}));
jest.mock('../../../../graphql/mutations/EmbeddedUpdateAssetMutation', () => ({
EmbeddedUpdateAssetMutation: { getSignedUploadSpecAsync: jest.fn() },
}));
jest.mock('../../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
EmbeddedUpdateMutation: { uploadEmbeddedUpdateAsync: jest.fn() },
isEmbeddedUpdateAssetNotAvailableError: jest.fn(),
isEmbeddedUpdateAlreadyExistsError: jest.fn(),
}));
jest.mock('../../../../uploads');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../utils/promise', () => ({
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
const mockGetRuntimeVersion = jest.mocked(Updates.getRuntimeVersionNullableAsync);
const mockGetSignedUploadSpec = jest.mocked(EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync);
const mockUpload = jest.mocked(uploads.uploadWithPresignedPostWithRetryAsync);
const mockUploadEmbeddedUpdate = jest.mocked(EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync);
const mockOra = jest.mocked(ora);
const mockUploadSpinnerSucceed = jest.fn();
const mockUploadSpinnerFail = jest.fn();
const mockRegisterSpinnerSucceed = jest.fn();
const mockRegisterSpinnerFail = jest.fn();
const mockSleepAsync = jest.mocked(promise.sleepAsync);
const mockIsEmbeddedUpdateAssetNotAvailableError = jest.mocked(
isEmbeddedUpdateAssetNotAvailableError
);
const mockIsEmbeddedUpdateAlreadyExistsError = jest.mocked(isEmbeddedUpdateAlreadyExistsError);
const mockLogLog = jest.mocked(Log.log);
const BUNDLE_PATH = '/project/app.bundle';
const MANIFEST_PATH = '/project/app.manifest';
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
const VALID_MANIFEST = JSON.stringify({ id: VALID_UUID });
const BASE_ARGV = [
'--platform',
Platform.IOS,
'--bundle',
BUNDLE_PATH,
'--manifest',
MANIFEST_PATH,
'--channel',
'production',
];
const MOCK_CONTEXT = {
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
privateProjectConfig: {
projectId: 'project-123',
exp: { name: 'test', slug: 'test' },
projectDir: '/project',
},
};
const MOCK_EMBEDDED_UPDATE = {
id: 'embedded-update-id-abc',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
};
describe(UpdateEmbeddedUpload, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockOra.mockImplementation(
(text?: string | object) =>
({
start: () =>
text === 'Uploading bundle...'
? { succeed: mockUploadSpinnerSucceed, fail: mockUploadSpinnerFail }
: { succeed: mockRegisterSpinnerSucceed, fail: mockRegisterSpinnerFail },
}) as any
);
vol.reset();
vol.fromJSON({
[BUNDLE_PATH]: 'bundle-bytes',
[MANIFEST_PATH]: VALID_MANIFEST,
});
mockGetRuntimeVersion.mockResolvedValue('1.0.0');
mockGetSignedUploadSpec.mockResolvedValue({
storageKey: 'storage-key-abc',
presignedUrl: 'https://storage.googleapis.com/upload-bucket',
fields: { key: 'obj-key', policy: 'abc123' },
});
mockUpload.mockImplementation(async (_path, _spec, onProgress) => {
// Invoke the progress callback so the no-op arrow passed by the command is executed.
(onProgress as () => void)();
return undefined as any;
});
mockUploadEmbeddedUpdate.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
mockIsEmbeddedUpdateAssetNotAvailableError.mockReturnValue(false);
mockIsEmbeddedUpdateAlreadyExistsError.mockReturnValue(false);
});
function createCommand(argv: string[]): UpdateEmbeddedUpload {
const command = new UpdateEmbeddedUpload(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
describe('file existence checks', () => {
it('runs successfully when both files exist', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle');
expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith(
expect.stringContaining('production')
);
});
it('errors with message when bundle file does not exist', async () => {
vol.reset();
vol.fromJSON({ [MANIFEST_PATH]: VALID_MANIFEST });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/Bundle file not found/);
});
it('errors with message when manifest file does not exist', async () => {
vol.reset();
vol.fromJSON({ [BUNDLE_PATH]: 'bundle-bytes' });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/Could not read or parse manifest/);
});
it('includes build-id in log when provided', async () => {
const command = createCommand([...BASE_ARGV, '--build-id', 'build-uuid-123']);
await command.runAsync();
expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith(
expect.stringContaining('production')
);
});
it('accepts android platform', async () => {
const argv = [...BASE_ARGV];
argv[1] = Platform.ANDROID;
const command = createCommand(argv);
await command.runAsync();
expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith(
expect.stringContaining(Platform.ANDROID)
);
});
});
describe('manifest parsing', () => {
it('reads embeddedUpdateId from manifest and passes platform directly to getRuntimeVersion', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockGetRuntimeVersion).toHaveBeenCalledWith(
MOCK_CONTEXT.privateProjectConfig.projectDir,
MOCK_CONTEXT.privateProjectConfig.exp,
Platform.IOS
);
});
it('errors when manifest contains invalid JSON', async () => {
vol.fromJSON({ [MANIFEST_PATH]: 'not-json', [BUNDLE_PATH]: 'bytes' });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/Could not read or parse manifest/);
});
it('errors when manifest id is missing', async () => {
vol.fromJSON({ [MANIFEST_PATH]: JSON.stringify({ noId: true }), [BUNDLE_PATH]: 'bytes' });
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/"id" field/);
});
it('errors when manifest id is not a valid UUID', async () => {
vol.fromJSON({
[MANIFEST_PATH]: JSON.stringify({ id: 'not-a-uuid' }),
[BUNDLE_PATH]: 'bytes',
});
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/is not a UUID/);
});
it('errors when runtimeVersion cannot be resolved', async () => {
mockGetRuntimeVersion.mockResolvedValue(null);
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/runtimeVersion/);
});
});
describe('bundle upload', () => {
it('requests a presigned URL with embeddedUpdateId and uploads the bundle', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockGetSignedUploadSpec).toHaveBeenCalledWith(
MOCK_CONTEXT.loggedIn.graphqlClient,
expect.objectContaining({
appId: MOCK_CONTEXT.privateProjectConfig.projectId,
embeddedUpdateId: VALID_UUID,
contentType: 'application/javascript',
})
);
expect(mockUpload).toHaveBeenCalledWith(
BUNDLE_PATH,
{
url: 'https://storage.googleapis.com/upload-bucket',
fields: { key: 'obj-key', policy: 'abc123' },
},
expect.any(Function)
);
});
it('propagates upload errors', async () => {
mockUpload.mockRejectedValue(new Error('upload failed'));
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow('upload failed');
});
});
describe('mutation registration', () => {
it('calls uploadEmbeddedUpdateAsync with channel name and AppPlatform enum', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith(
MOCK_CONTEXT.loggedIn.graphqlClient,
expect.objectContaining({
appId: MOCK_CONTEXT.privateProjectConfig.projectId,
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
embeddedUpdateId: VALID_UUID,
})
);
});
it('logs the embedded update id on success', async () => {
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle');
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(MOCK_EMBEDDED_UPDATE.id));
});
it('passes build-id as turtleBuildId when provided', async () => {
const command = createCommand([...BASE_ARGV, '--build-id', 'turtle-build-xyz']);
await command.runAsync();
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ turtleBuildId: 'turtle-build-xyz' })
);
});
it('retries on ASSET_NOT_AVAILABLE: succeeds on second attempt, sleeps once with first delay', async () => {
const assetNotAvailableError = new Error('asset not available');
mockIsEmbeddedUpdateAssetNotAvailableError.mockImplementation(
e => e === assetNotAvailableError
);
mockUploadEmbeddedUpdate
.mockRejectedValueOnce(assetNotAvailableError)
.mockResolvedValueOnce(MOCK_EMBEDDED_UPDATE);
const command = createCommand(BASE_ARGV);
await command.runAsync();
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(2);
expect(mockSleepAsync).toHaveBeenCalledTimes(1);
expect(mockSleepAsync).toHaveBeenCalledWith(3_000);
expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle');
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(MOCK_EMBEDDED_UPDATE.id));
});
it('exhausts all 10 attempts on ASSET_NOT_AVAILABLE, sleeping 9 times then throwing', async () => {
const assetNotAvailableError = new Error('asset not available');
mockIsEmbeddedUpdateAssetNotAvailableError.mockImplementation(
e => e === assetNotAvailableError
);
mockUploadEmbeddedUpdate.mockRejectedValue(assetNotAvailableError);
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow();
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(10);
expect(mockSleepAsync).toHaveBeenCalledTimes(9);
});
it('propagates unexpected mutation errors immediately without retrying', async () => {
const unexpectedError = new Error('network timeout');
mockUploadEmbeddedUpdate.mockRejectedValue(unexpectedError);
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow('network timeout');
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1);
expect(mockRegisterSpinnerFail).toHaveBeenCalledWith('Failed to register embedded update');
});
it('fails with a clean message when the update is already registered', async () => {
const alreadyExistsError = new Error('already exists');
mockIsEmbeddedUpdateAlreadyExistsError.mockImplementation(e => e === alreadyExistsError);
mockUploadEmbeddedUpdate.mockRejectedValue(alreadyExistsError);
const command = createCommand(BASE_ARGV);
await expect(command.runAsync()).rejects.toThrow(/already registered/);
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1);
expect(mockRegisterSpinnerFail).toHaveBeenCalledWith('Failed to register embedded update');
});
});
describe('--json flag', () => {
it('enables JSON output and prints the embedded update as JSON', async () => {
const command = createCommand([...BASE_ARGV, '--json', '--non-interactive']);
await command.runAsync();
expect(jest.mocked(json.enableJsonOutput)).toHaveBeenCalled();
expect(jest.mocked(json.printJsonOnlyOutput)).toHaveBeenCalledWith(MOCK_EMBEDDED_UPDATE);
});
});
});
@@ -1,144 +0,0 @@
import { CombinedError } from '@urql/core';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../../graphql/generated';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
isEmbeddedUpdateNotFoundError,
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../../log';
import * as json from '../../../../utils/json';
import UpdateEmbeddedView from '../view';
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
EmbeddedUpdateQuery: { viewByIdAsync: jest.fn() },
isEmbeddedUpdateNotFoundError: jest.fn(),
}));
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
const mockView = jest.mocked(EmbeddedUpdateQuery.viewByIdAsync);
const mockIsNotFound = jest.mocked(isEmbeddedUpdateNotFoundError);
const mockLogLog = jest.mocked(Log.log);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
const MOCK_CONTEXT = {
projectId: 'project-123',
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
const MOCK_EMBEDDED_UPDATE: EmbeddedUpdateFragment = {
id: VALID_UUID,
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2026-05-29T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: 768,
fileSHA256: 'abc123',
},
};
describe(UpdateEmbeddedView, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockView.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
mockIsNotFound.mockReturnValue(false);
});
function createCommand(argv: string[]): UpdateEmbeddedView {
const command = new UpdateEmbeddedView(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('prints formatted details on success', async () => {
await createCommand([VALID_UUID]).run();
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
embeddedUpdateId: VALID_UUID,
appId: MOCK_CONTEXT.projectId,
});
// Header line + body line
expect(mockLogLog).toHaveBeenCalledTimes(2);
expect(mockLogLog.mock.calls[0][0]).toContain('Embedded update');
expect(mockLogLog.mock.calls[1][0]).toContain(VALID_UUID);
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle size');
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle SHA-256');
});
it('--json prints the raw embedded update and skips formatted output', async () => {
await createCommand([VALID_UUID, '--json']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith(MOCK_EMBEDDED_UPDATE);
expect(mockLogLog).not.toHaveBeenCalled();
});
it('exits with a friendly message when the server returns NOT_FOUND', async () => {
const notFound = new CombinedError({ graphQLErrors: [] });
mockView.mockRejectedValue(notFound);
mockIsNotFound.mockReturnValue(true);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow();
expect(mockIsNotFound).toHaveBeenCalledWith(notFound);
});
it('rethrows unexpected errors', async () => {
const boom = new Error('boom');
mockView.mockRejectedValue(boom);
mockIsNotFound.mockReturnValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('boom');
});
it('renders bundle size from finalFileSize when present', async () => {
mockView.mockResolvedValue({
...MOCK_EMBEDDED_UPDATE,
launchAsset: { id: 'asset-x', fileSize: 2048, finalFileSize: 768, fileSHA256: 'abc' },
});
await createCommand([VALID_UUID]).run();
// 768 B; not 2.0 KB (which would be fileSize).
expect(mockLogLog.mock.calls[1][0]).toContain('768 B');
});
it('falls back to fileSize when finalFileSize is null', async () => {
mockView.mockResolvedValue({
...MOCK_EMBEDDED_UPDATE,
launchAsset: { id: 'asset-y', fileSize: 2048, finalFileSize: null, fileSHA256: 'abc' },
});
await createCommand([VALID_UUID]).run();
expect(mockLogLog.mock.calls[1][0]).toContain('2.0 KB');
});
it('shows runtime version, channel, and SHA-256 in the body', async () => {
await createCommand([VALID_UUID]).run();
const body = String(mockLogLog.mock.calls[1][0]);
expect(body).toContain('1.0.0');
expect(body).toContain('production');
expect(body).toContain('abc123');
});
it('shows a relative-time hint in the Created at row', async () => {
await createCommand([VALID_UUID]).run();
expect(mockLogLog.mock.calls[1][0]).toMatch(/\(.+ago\)/);
});
it('passes the project id and uuid through to viewByIdAsync', async () => {
await createCommand([VALID_UUID]).run();
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
embeddedUpdateId: VALID_UUID,
appId: MOCK_CONTEXT.projectId,
});
});
});
@@ -1,72 +0,0 @@
import { Args } from '@oclif/core';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { EmbeddedUpdateMutation } from '../../../graphql/mutations/EmbeddedUpdateMutation';
import Log from '../../../log';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class UpdateEmbeddedDelete extends EasCommand {
static override description = 'delete an embedded update registered with EAS Update';
static override args = {
id: Args.string({
required: true,
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { id: embeddedUpdateId },
flags,
} = await this.parse(UpdateEmbeddedDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedDelete, { nonInteractive });
if (jsonFlag) {
enableJsonOutput();
}
if (!nonInteractive) {
Log.log(
`You are about to permanently delete embedded update: "${embeddedUpdateId}". ` +
`Diff patches already generated against this bundle keep serving, but new diffs ` +
`can't be generated until you re-upload it.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of embedded update: "${embeddedUpdateId}".`);
process.exit(1);
}
}
// Best-effort delete on the server: deleting an unknown id succeeds (idempotent),
// so we don't need a not-found branch here.
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(graphqlClient, {
id: embeddedUpdateId,
});
if (jsonFlag) {
printJsonOnlyOutput({ id: embeddedUpdateId });
return;
}
Log.withTick(`Deleted embedded update ${embeddedUpdateId}`);
}
}
@@ -1,172 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
import { AppPlatform } from '../../../graphql/generated';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
} from '../../../graphql/queries/EmbeddedUpdateQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import { selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
import formatFields from '../../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
const DEFAULT_LIMIT = 25;
const MAX_LIMIT = 50;
const CHANNELS_LIMIT = 50;
export default class UpdateEmbeddedList extends EasCommand {
static override description = 'list embedded updates registered with EAS Update for this project';
static override flags = {
platform: Flags.option({
char: 'p',
description: 'Filter by platform',
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
'runtime-version': Flags.string({
description: 'Filter by runtime version',
}),
channel: Flags.string({
description: 'Filter by channel name (pass "all" to skip the channel prompt)',
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
'after-cursor': Flags.string({
description: 'Return items after this cursor (for pagination)',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateEmbeddedList);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedList, { nonInteractive });
if (jsonFlag) {
enableJsonOutput();
}
const platform: AppPlatform | undefined = flags.platform
? toAppPlatform(flags.platform)
: undefined;
// Resolve channel filter:
// - `--channel <name>`: use it
// - `--channel all` (or no flag in non-interactive / json): no channel filter
// - no flag in interactive: prompt with the project's channels + "All channels"
let channel: string | undefined;
if (flags.channel) {
channel = flags.channel.toLowerCase() === 'all' ? undefined : flags.channel;
} else if (!nonInteractive && !jsonFlag) {
channel = await promptForChannelAsync(graphqlClient, projectId);
}
const filter =
platform || flags['runtime-version'] || channel
? {
platform,
runtimeVersion: flags['runtime-version'],
channel,
}
: undefined;
const limit = flags.limit ?? DEFAULT_LIMIT;
const connection = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, {
appId: projectId,
filter,
first: limit,
after: flags['after-cursor'],
});
const embeddedUpdates = connection.edges.map(e => e.node);
if (jsonFlag) {
printJsonOnlyOutput({
embeddedUpdates,
pageInfo: connection.pageInfo,
});
return;
}
if (embeddedUpdates.length === 0) {
Log.log('No embedded updates found.');
return;
}
Log.addNewLineIfNone();
Log.log(
chalk.bold(
`Embedded updates (${embeddedUpdates.length}${connection.pageInfo.hasNextPage ? '+' : ''}):`
)
);
Log.newLine();
Log.log(embeddedUpdates.map(formatEmbeddedUpdateRow).join(`\n\n${chalk.dim('———')}\n\n`));
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
Log.newLine();
Log.log(
chalk.dim(
`Showing ${embeddedUpdates.length}. For the next page, run with --after-cursor ${connection.pageInfo.endCursor}`
)
);
}
}
}
// Sentinel for the "All channels" option. We can't use `undefined` here because
// the underlying `prompts` library substitutes a choice's index when its value
// is undefined, which then leaks into the GraphQL filter.
const ALL_CHANNELS = '__embedded_update_list__all_channels__';
async function promptForChannelAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<string | undefined> {
const channels = await ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: CHANNELS_LIMIT,
});
if (channels.length === 0) {
// Nothing to choose from — fall back to listing everything.
return undefined;
}
const selected = await selectAsync<string>('Filter embedded updates by which channel?', [
{ title: 'All channels', value: ALL_CHANNELS },
...channels.map(c => ({ title: c.name, value: c.name })),
]);
return selected === ALL_CHANNELS ? undefined : selected;
}
function formatEmbeddedUpdateRow(embeddedUpdate: EmbeddedUpdateFragment): string {
const createdAt = new Date(embeddedUpdate.createdAt);
return formatFields([
{ label: 'ID', value: embeddedUpdate.id },
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
{ label: 'Channel', value: embeddedUpdate.channel },
{ label: 'Created', value: `${fromNow(createdAt)} ago` },
]);
}
@@ -1,177 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { Updates } from '@expo/config-plugins';
import { Errors, Flags } from '@oclif/core';
import fs from 'fs-extra';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { EmbeddedUpdateAssetMutation } from '../../../graphql/mutations/EmbeddedUpdateAssetMutation';
import {
EmbeddedUpdateMutation,
EmbeddedUpdateResult,
isEmbeddedUpdateAlreadyExistsError,
isEmbeddedUpdateAssetNotAvailableError,
} from '../../../graphql/mutations/EmbeddedUpdateMutation';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import { ora } from '../../../ora';
import { readEmbeddedManifestAsync } from '../../../update/embeddedManifest';
import { uploadWithPresignedPostWithRetryAsync } from '../../../uploads';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { sleepAsync } from '../../../utils/promise';
const MAX_ATTEMPTS = 10;
const RETRY_BASE_DELAY_MS = 3_000;
const RETRY_MAX_DELAY_MS = 10_000;
export default class UpdateEmbeddedUpload extends EasCommand {
static override description =
'upload the JS bundle embedded in a native build so EAS Update can generate bsdiff patches against it';
static override examples = [
'$ eas update:embedded:upload --platform ios --bundle ios/build/App.app/main.jsbundle --manifest ios/build/App.app/app.manifest --channel production',
'$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>',
];
static override flags = {
platform: Flags.option({
char: 'p',
description: 'Platform of the embedded bundle',
options: [Platform.IOS, Platform.ANDROID] as const,
required: true,
})(),
bundle: Flags.string({
description: 'Path to the embedded JS bundle file',
required: true,
}),
manifest: Flags.string({
description: 'Path to the app.manifest file embedded in the build',
required: true,
}),
channel: Flags.string({
description: 'Channel name the embedded update should be associated with',
required: true,
}),
'build-id': Flags.string({
description: 'EAS Build ID that produced this binary (required when invoked from EAS Build)',
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateEmbeddedUpload);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const platform = flags.platform;
const bundlePath = flags.bundle;
const manifestPath = flags.manifest;
const channelName = flags.channel;
const buildId = flags['build-id'];
const {
loggedIn: { graphqlClient },
privateProjectConfig: { projectId, exp, projectDir },
} = await this.getContextAsync(UpdateEmbeddedUpload, {
nonInteractive,
withServerSideEnvironment: null,
});
if (jsonFlag) {
enableJsonOutput();
}
if (!(await fs.pathExists(bundlePath))) {
Errors.error(
`Bundle file not found at "${bundlePath}". Check that the path is correct and points to the JS bundle in your native build output.`,
{ exit: 1 }
);
}
const { id: embeddedUpdateId } = await readEmbeddedManifestAsync(manifestPath);
const runtimeVersion = await Updates.getRuntimeVersionNullableAsync(projectDir, exp, platform);
if (runtimeVersion === null) {
Errors.error(
`Could not resolve runtimeVersion for platform "${platform}". ` +
`Ensure runtimeVersion is set in your app.json under the expo key.`,
{ exit: 1 }
);
}
const appPlatform = toAppPlatform(platform);
const uploadSpinner = ora('Uploading bundle...').start();
const contentType = 'application/javascript';
const uploadSpec = await EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync(graphqlClient, {
appId: projectId,
embeddedUpdateId,
contentType,
});
await uploadWithPresignedPostWithRetryAsync(
bundlePath,
{ url: uploadSpec.presignedUrl, fields: uploadSpec.fields },
() => {}
);
uploadSpinner.succeed('Uploaded bundle');
const registerSpinner = ora('Registering embedded update...').start();
let embeddedUpdate: EmbeddedUpdateResult | undefined;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
embeddedUpdate = await EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync(graphqlClient, {
appId: projectId,
platform: appPlatform,
runtimeVersion,
channel: channelName,
embeddedUpdateId,
turtleBuildId: buildId,
});
break;
} catch (e: unknown) {
if (isEmbeddedUpdateAssetNotAvailableError(e)) {
if (attempt < MAX_ATTEMPTS) {
await sleepAsync(
Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)
);
}
continue;
}
registerSpinner.fail('Failed to register embedded update');
if (isEmbeddedUpdateAlreadyExistsError(e)) {
Errors.error(
`An embedded update with id "${embeddedUpdateId}" is already registered for this app. Delete it before re-uploading.`,
{ exit: 1 }
);
}
throw e;
}
}
if (embeddedUpdate === undefined) {
registerSpinner.fail('Failed to register embedded update');
throw new Error(
'Embedded bundle could not be processed in time. Try re-running the command in a moment.'
);
}
registerSpinner.succeed(
`Registered ${platform} embedded update (runtimeVersion: ${runtimeVersion}, channel: "${channelName}")`
);
Log.log(`Embedded update ID: ${embeddedUpdate.id}`);
if (jsonFlag) {
printJsonOnlyOutput(embeddedUpdate);
}
}
}
@@ -1,95 +0,0 @@
import { Args, Errors } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../../commandUtils/flags';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
isEmbeddedUpdateNotFoundError,
} from '../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../log';
import { fromNow } from '../../../utils/date';
import { formatBytes } from '../../../utils/files';
import formatFields from '../../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class UpdateEmbeddedView extends EasCommand {
static override description = 'view details of an embedded update registered with EAS Update';
static override args = {
id: Args.string({
required: true,
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
}),
};
static override flags = {
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { id: embeddedUpdateId },
flags: { json: jsonFlag },
} = await this.parse(UpdateEmbeddedView);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedView, { nonInteractive: true });
if (jsonFlag) {
enableJsonOutput();
}
let embeddedUpdate;
try {
embeddedUpdate = await EmbeddedUpdateQuery.viewByIdAsync(graphqlClient, {
embeddedUpdateId,
appId: projectId,
});
} catch (e: unknown) {
if (isEmbeddedUpdateNotFoundError(e)) {
Errors.error(
`No embedded update found with id "${embeddedUpdateId}" for this project. ` +
`Run "eas update:embedded:list" to see the embedded updates registered for this app.`,
{ exit: 1 }
);
}
throw e;
}
if (jsonFlag) {
printJsonOnlyOutput(embeddedUpdate);
return;
}
Log.addNewLineIfNone();
Log.log(chalk.bold('Embedded update:'));
Log.log(formatEmbeddedUpdate(embeddedUpdate));
}
}
export function formatEmbeddedUpdate(embeddedUpdate: EmbeddedUpdateFragment): string {
const createdAt = new Date(embeddedUpdate.createdAt);
const bundleSize =
embeddedUpdate.launchAsset.finalFileSize ?? embeddedUpdate.launchAsset.fileSize;
return formatFields([
{ label: 'ID', value: embeddedUpdate.id },
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
{ label: 'Channel', value: embeddedUpdate.channel },
{ label: 'Bundle size', value: formatBytes(bundleSize) },
{ label: 'Bundle SHA-256', value: embeddedUpdate.launchAsset.fileSHA256 },
{
label: 'Created at',
value: `${createdAt.toLocaleString()} (${fromNow(createdAt)} ago)`,
},
]);
}
@@ -27,7 +27,6 @@ export class CredentialsContext {
public readonly nonInteractive: boolean;
public readonly autoAcceptCredentialReuse: boolean;
public readonly freezeCredentials: boolean = false;
public readonly refreshAdHocProvisioningProfile: boolean = false;
public readonly projectDir: string;
public readonly user: Actor;
public readonly graphqlClient: ExpoGraphqlClient;
@@ -53,7 +52,6 @@ export class CredentialsContext {
vcsClient: Client;
freezeCredentials?: boolean;
autoAcceptCredentialReuse?: boolean;
refreshAdHocProvisioningProfile?: boolean;
env?: Env;
}
) {
@@ -67,7 +65,6 @@ export class CredentialsContext {
this.autoAcceptCredentialReuse = options.autoAcceptCredentialReuse ?? false;
this.projectInfo = options.projectInfo;
this.freezeCredentials = options.freezeCredentials ?? false;
this.refreshAdHocProvisioningProfile = options.refreshAdHocProvisioningProfile ?? false;
this.usesBroadcastPushNotifications =
options.projectInfo?.exp.ios?.usesBroadcastPushNotifications ?? false;
}
@@ -1,37 +1,6 @@
import { promptAsync } from '../.././../prompts';
import { AppleDevice, AppleDeviceClass, AppleDeviceFragment } from '../../../graphql/generated';
import { AppleDevice, AppleDeviceFragment } from '../../../graphql/generated';
import { APPLE_DEVICE_CLASS_LABELS } from '../../../graphql/types/credentials/AppleDevice';
import { ApplePlatform } from '../appstore/constants';
export function filterDevicesForApplePlatform(
devices: AppleDeviceFragment[],
applePlatform: ApplePlatform
): AppleDeviceFragment[] {
if (applePlatform === ApplePlatform.TV_OS) {
throw new Error('Filtering for tvOS is not supported yet');
}
if (applePlatform === ApplePlatform.VISION_OS) {
throw new Error('Filtering for visionOS is not supported yet');
}
return devices.filter(device => isDeviceCompatibleWithApplePlatform(device, applePlatform));
}
function isDeviceCompatibleWithApplePlatform(
device: AppleDeviceFragment,
applePlatform: Exclude<ApplePlatform, ApplePlatform.TV_OS | ApplePlatform.VISION_OS>
): boolean {
switch (applePlatform) {
case ApplePlatform.IOS:
return (
device.deviceClass === AppleDeviceClass.Iphone ||
device.deviceClass === AppleDeviceClass.Ipad
);
case ApplePlatform.MAC_OS:
return device.deviceClass === AppleDeviceClass.Mac;
}
}
export async function chooseDevicesAsync(
allDevices: AppleDeviceFragment[],
@@ -6,11 +6,7 @@ import nullthrows from 'nullthrows';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils';
import {
chooseDevicesAsync,
filterDevicesForApplePlatform,
formatDeviceLabel,
} from './DeviceUtils';
import { chooseDevicesAsync, formatDeviceLabel } from './DeviceUtils';
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
import {
@@ -22,8 +18,6 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import {
@@ -35,13 +29,9 @@ import {
import differenceBy from '../../../utils/expodash/differenceBy';
import { CredentialsContext } from '../../context';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { ApplePlatform } from '../appstore/constants';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import { MinimalAscApiKey } from '../credentials';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
@@ -61,23 +51,11 @@ export class SetUpAdhocProvisioningProfile {
async runAsync(ctx: CredentialsContext): Promise<IosAppBuildCredentialsFragment> {
const { app } = this.options;
if (ctx.refreshAdHocProvisioningProfile && ctx.freezeCredentials) {
throw new Error(
'Cannot refresh ad-hoc provisioning profile when credentials are frozen. Remove --freeze-credentials or --refresh-ad-hoc-provisioning-profile.'
);
}
const distCert = await new SetUpDistributionCertificate(
app,
IosDistributionType.AdHoc
).runAsync(ctx);
if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) {
await this.ensureAppStoreAuthenticatedForAdhocRefreshAsync(ctx, app);
return await this.runWithDistributionCertificateAsync(ctx, distCert);
}
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
if (ctx.nonInteractive) {
@@ -133,11 +111,6 @@ export class SetUpAdhocProvisioningProfile {
appleTeam
);
if (registeredAppleDevices.length === 0) {
if (ctx.nonInteractive) {
throw new Error(
'No devices are registered for this Apple team. Register devices with eas device:create first.'
);
}
const shouldRegisterDevices = await confirmAsync({
message: `You don't have any registered devices yet. Would you like to register them now?`,
initial: true,
@@ -154,13 +127,13 @@ export class SetUpAdhocProvisioningProfile {
const provisionedDeviceIdentifiers = (
currentBuildCredentials?.provisioningProfile?.appleDevices ?? []
).map(i => i.identifier);
const applePlatform = getApplePlatformFromTarget(target);
const chosenDevices =
ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile
? filterDevicesForApplePlatform(registeredAppleDevices, applePlatform)
: await chooseDevicesAsync(registeredAppleDevices, provisionedDeviceIdentifiers);
const chosenDevices = await chooseDevicesAsync(
registeredAppleDevices,
provisionedDeviceIdentifiers
);
// 4. Reuse or create the profile on Apple Developer Portal
const applePlatform = getApplePlatformFromTarget(target);
const profileType =
applePlatform === ApplePlatform.TV_OS
? ProfileType.TVOS_APP_ADHOC
@@ -214,25 +187,21 @@ export class SetUpAdhocProvisioningProfile {
Log.log(
'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet'
);
if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) {
// Continue without prompting in non-interactive refresh mode.
} else {
const shouldContinue = await selectAsync(
'Do you want to continue without provisioning these devices?',
[
{
title: 'Yes',
value: true,
},
{
title: 'No (EAS CLI will exit)',
value: false,
},
]
);
if (!shouldContinue) {
Errors.exit(1);
}
const shouldContinue = await selectAsync(
'Do you want to continue without provisioning these devices?',
[
{
title: 'Yes',
value: true,
},
{
title: 'No (EAS CLI will exit)',
value: false,
},
]
);
if (!shouldContinue) {
Errors.exit(1);
}
}
@@ -402,67 +371,6 @@ export class SetUpAdhocProvisioningProfile {
}
}
}
private async ensureAppStoreAuthenticatedForAdhocRefreshAsync(
ctx: CredentialsContext,
app: AppLookupParams
): Promise<void> {
if (hasAscEnvVars()) {
await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY });
return;
}
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
graphqlClient: ctx.graphqlClient,
app,
});
if (!resolvedKey) {
throw new Error(
'No App Store Connect API Key found for ad-hoc provisioning profile refresh. 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 credentials service: configure an App Store Connect API Key for submissions on this app'
);
}
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
teamId: resolvedKey.teamId,
teamName: resolvedKey.teamName,
// Provide a non-enterprise team type to avoid interactive team-type resolution.
// Ad-hoc profile handling below uses explicit ProfileType and does not branch on team.inHouse.
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
});
}
}
async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}
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,
};
}
export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
@@ -54,18 +54,6 @@ export class SetUpTargetBuildCredentials {
ctx: CredentialsContext
): Promise<IosAppBuildCredentialsFragment> {
const { app, distribution, enterpriseProvisioning, target } = this.options;
if (ctx.refreshAdHocProvisioningProfile) {
if (distribution !== 'internal') {
throw new Error(
'--refresh-ad-hoc-provisioning-profile is only supported for internal distribution builds.'
);
}
if (enterpriseProvisioning === 'universal') {
throw new Error(
'--refresh-ad-hoc-provisioning-profile is only supported for ad-hoc internal builds.'
);
}
}
if (distribution === 'internal') {
if (enterpriseProvisioning === 'adhoc') {
return await new SetUpAdhocProvisioningProfile({ app, target }).runAsync(ctx);
@@ -1,65 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
import { AppleDeviceClass, AppleDeviceFragment } from '../../../../graphql/generated';
import { ApplePlatform } from '../../appstore/constants';
import { filterDevicesForApplePlatform, formatDeviceLabel } from '../DeviceUtils';
function createDevice(
overrides: Partial<AppleDeviceFragment> & Pick<AppleDeviceFragment, 'identifier'>
): AppleDeviceFragment {
return {
__typename: 'AppleDevice',
id: uuidv4(),
name: 'device',
model: null,
deviceClass: null,
createdAt: new Date().toISOString(),
...overrides,
} as AppleDeviceFragment;
}
describe(filterDevicesForApplePlatform, () => {
const iphone = createDevice({
identifier: 'iphone',
deviceClass: AppleDeviceClass.Iphone,
});
const ipad = createDevice({
identifier: 'ipad',
deviceClass: AppleDeviceClass.Ipad,
});
const mac = createDevice({
identifier: 'mac',
deviceClass: AppleDeviceClass.Mac,
});
const appleTv = createDevice({
identifier: 'appletv',
model: 'AppleTV6,3',
deviceClass: AppleDeviceClass.Unknown,
});
const unknown = createDevice({
identifier: 'unknown',
deviceClass: AppleDeviceClass.Unknown,
});
const noClass = createDevice({
identifier: 'noclass',
deviceClass: null,
});
it('keeps only iPhone and iPad devices for iOS targets', () => {
expect(
filterDevicesForApplePlatform(
[iphone, ipad, mac, appleTv, unknown, noClass],
ApplePlatform.IOS
)
).toEqual([iphone, ipad]);
});
it('throws for tvOS targets', () => {
expect(() =>
filterDevicesForApplePlatform([iphone, ipad, mac, appleTv], ApplePlatform.TV_OS)
).toThrow('Filtering for tvOS is not supported yet');
});
});
import { formatDeviceLabel } from '../DeviceUtils';
describe(formatDeviceLabel, () => {
it('returns createdAt clause', async () => {
@@ -23,37 +23,17 @@ import { ApplePlatform } from '../../appstore/constants';
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildCredentialsUtils';
import { chooseDevicesAsync } from '../DeviceUtils';
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
import { AuthenticationMode, AppleTeamType } from '../../appstore/authenticateTypes';
import { hasAscEnvVars } from '../../appstore/resolveCredentials';
import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery';
jest.mock('../BuildCredentialsUtils');
jest.mock('../../../context');
jest.mock('../../../ios/api/GraphqlClient', () => ({
...jest.requireActual('../../../ios/api/GraphqlClient'),
getAscApiKeyForAppSubmissionsAsync: jest.fn(),
}));
jest.mock('../../appstore/resolveCredentials', () => ({
hasAscEnvVars: jest.fn(),
}));
jest.mock('../../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
AppStoreConnectApiKeyQuery: {
getByIdAsync: jest.fn(),
},
}));
jest.mock('../../../ios/api/GraphqlClient');
jest.mock('../DeviceUtils', () => {
return {
__esModule: true,
...jest.requireActual('../DeviceUtils'),
chooseDevicesAsync: jest.fn(),
formatDeviceLabel: jest.requireActual('../DeviceUtils').formatDeviceLabel,
};
});
jest.mock('../SetUpDistributionCertificate', () => ({
SetUpDistributionCertificate: jest.fn(),
}));
import { SetUpDistributionCertificate } from '../SetUpDistributionCertificate';
jest.mock('../../../../project/ios/target');
jest.mock('../../../../prompts');
@@ -168,231 +148,6 @@ describe('runWithDistributionCertificateAsync', () => {
});
});
describe('runAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
jest.mocked(SetUpDistributionCertificate).mockImplementation(
() =>
({
runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment),
}) as any
);
});
const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({
app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' },
target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} },
});
it('skips non-interactive reuse when refresh is enabled', async () => {
const { ctx } = setUpTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
const areBuildCredentialsSetupAsyncSpy = jest
.spyOn(SetUpAdhocProvisioningProfile.prototype as any, 'areBuildCredentialsSetupAsync')
.mockResolvedValue(true);
const ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy = jest
.spyOn(
SetUpAdhocProvisioningProfile.prototype as any,
'ensureAppStoreAuthenticatedForAdhocRefreshAsync'
)
.mockResolvedValue(undefined);
jest
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
await setUpAdhocProvisioningProfile.runAsync(ctx);
expect(areBuildCredentialsSetupAsyncSpy).not.toHaveBeenCalled();
expect(ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy).toHaveBeenCalledWith(ctx, {
account: {} as Account,
projectName: 'projName',
bundleIdentifier: 'bundleId',
});
expect(
SetUpAdhocProvisioningProfile.prototype.runWithDistributionCertificateAsync
).toHaveBeenCalled();
expect(getBuildCredentialsAsync).not.toHaveBeenCalled();
});
it('fails when refresh is enabled and credentials are frozen', async () => {
const { ctx } = setUpTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
Object.defineProperty(ctx, 'freezeCredentials', { value: true });
await expect(setUpAdhocProvisioningProfile.runAsync(ctx)).rejects.toThrow(
'Cannot refresh ad-hoc provisioning profile when credentials are frozen'
);
});
});
describe('refresh ad-hoc provisioning profile', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
jest.mocked(getApplePlatformFromTarget).mockReturnValue(ApplePlatform.IOS);
jest.mocked(SetUpDistributionCertificate).mockImplementation(
() =>
({
runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment),
}) as any
);
jest.mocked(hasAscEnvVars).mockReturnValue(false);
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue(null);
});
const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({
app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' },
target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} },
});
it('provisions all registered devices without prompting in refresh mode', async () => {
const { ctx, distCert } = setUpRefreshTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
expect(chooseDevicesAsync).not.toHaveBeenCalled();
expect(ctx.appStore.createOrReuseAdhocProvisioningProfileAsync).toHaveBeenCalledWith(
['id1', 'id2', 'id3'],
'bundleId',
distCert.serialNumber,
expect.anything()
);
});
it('filters devices by target platform only in refresh mode', async () => {
const { ctx, distCert } = setUpRefreshTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([
{ identifier: 'id1', model: 'iPhone17,1', deviceClass: AppleDeviceClass.Iphone },
{ identifier: 'id2', model: 'AppleTV6,3', deviceClass: AppleDeviceClass.Unknown },
{ identifier: 'id3', deviceClass: AppleDeviceClass.Mac },
]);
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
expect(ctx.appStore.createOrReuseAdhocProvisioningProfileAsync).toHaveBeenCalledWith(
['id1'],
'bundleId',
distCert.serialNumber,
expect.anything()
);
expect(chooseDevicesAsync).not.toHaveBeenCalled();
});
it('continues without prompting when some devices are not provisioned in refresh mode', async () => {
const { ctx, distCert } = setUpRefreshTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
jest.mocked(getBuildCredentialsAsync).mockResolvedValue({
provisioningProfile: {
appleTeam: { appleTeamIdentifier: 'team' },
appleDevices: [{ identifier: 'id1' }],
developerPortalIdentifier: 'provisioningProfileId',
},
} as IosAppBuildCredentialsFragment);
ctx.ios.updateProvisioningProfileAsync = jest.fn().mockResolvedValue({
appleTeam: { appleTeamIdentifier: 'team' },
appleDevices: [{ identifier: 'id1' }],
developerPortalIdentifier: 'provisioningProfileId',
});
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
expect(selectAsync).not.toHaveBeenCalled();
expect(assignBuildCredentialsAsync).toHaveBeenCalled();
});
it('errors when no devices are registered in non-interactive mode', async () => {
const { ctx, distCert } = setUpRefreshTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([]);
await expect(
setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert)
).rejects.toThrow(
'No devices are registered for this Apple team. Register devices with eas device:create first.'
);
});
describe('ensureAppStoreAuthenticatedForAdhocRefreshAsync', () => {
it('authenticates with ASC environment variables when present', async () => {
const { ctx } = setUpRefreshTest();
jest.mocked(hasAscEnvVars).mockReturnValue(true);
jest
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
await setUpAdhocProvisioningProfile.runAsync(ctx);
expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({
mode: AuthenticationMode.API_KEY,
});
expect(getAscApiKeyForAppSubmissionsAsync).not.toHaveBeenCalled();
});
it('authenticates with the stored submissions ASC API key when env vars are absent', async () => {
const { ctx } = setUpRefreshTest();
jest.mocked(hasAscEnvVars).mockReturnValue(false);
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue({
id: 'asc-key-id',
appleTeam: {
appleTeamIdentifier: 'TEAM123',
appleTeamName: 'Team Name',
},
} as any);
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
keyP8: 'key-p8',
keyIdentifier: 'key-id',
issuerIdentifier: 'issuer-id',
} as any);
jest
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
await setUpAdhocProvisioningProfile.runAsync(ctx);
expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({
mode: AuthenticationMode.API_KEY,
ascApiKey: {
keyP8: 'key-p8',
keyId: 'key-id',
issuerId: 'issuer-id',
},
teamId: 'TEAM123',
teamName: 'Team Name',
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
});
});
});
});
function setUpRefreshTest(): {
ctx: CredentialsContext;
distCert: AppleDistributionCertificateFragment;
} {
const { ctx, distCert } = setUpTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
distCert.appleTeam = {
appleTeamIdentifier: 'team',
} as AppleDistributionCertificateFragment['appleTeam'];
distCert.serialNumber = 'serial-number';
ctx.appStore.ensureAuthenticatedAsync = jest.fn().mockResolvedValue({});
jest.mocked(getBuildCredentialsAsync).mockResolvedValue(null);
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([
{ identifier: 'id1', deviceClass: AppleDeviceClass.Iphone },
{ identifier: 'id2', deviceClass: AppleDeviceClass.Ipad },
{ identifier: 'id3', deviceClass: AppleDeviceClass.Iphone },
] as AppleDeviceFragment[]);
return { ctx, distCert };
}
function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCertificateFragment } {
const ctx = jest.mocked(
new CredentialsContext(
@@ -422,7 +177,8 @@ function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCert
{ identifier: 'id2', name: 'Device 2', deviceClass: AppleDeviceClass.Iphone },
{ identifier: 'id3', name: 'Device 3', deviceClass: AppleDeviceClass.Mac },
] as AppleDevice[]);
jest.mocked(getApplePlatformFromTarget).mockReturnValue(ApplePlatform.IOS);
// @ts-expect-error
jest.mocked(getApplePlatformFromTarget).mockResolvedValue(ApplePlatform.IOS);
Object.defineProperty(ctx, 'appStore', { value: jest.mock('../../appstore/AppStoreApi') });
ctx.appStore.createOrReuseAdhocProvisioningProfileAsync = jest.fn().mockResolvedValue({
provisioningProfileId: 'provisioningProfileId',
@@ -430,11 +186,6 @@ function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCert
ctx.ios.createOrGetExistingAppleAppIdentifierAsync = jest
.fn()
.mockResolvedValue({} as AppleAppIdentifierFragment);
ctx.ios.createProvisioningProfileAsync = jest.fn().mockResolvedValue({
appleTeam: { appleTeamIdentifier: 'team' },
appleDevices: [{ identifier: 'id1' }, { identifier: 'id2' }, { identifier: 'id3' }],
developerPortalIdentifier: 'provisioningProfileId',
});
jest.mocked(assignBuildCredentialsAsync).mockResolvedValue({} as IosAppBuildCredentialsFragment);
const distCert = {} as AppleDistributionCertificateFragment;
return { ctx, distCert };
@@ -1,110 +0,0 @@
import { Env } from '@expo/eas-build-job';
import { EasJson } from '@expo/eas-json';
import { Analytics } from '../../../../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { Account, IosAppBuildCredentialsFragment } from '../../../../graphql/generated';
import { Actor } from '../../../../user/User';
import { Client } from '../../../../vcs/vcs';
import { CredentialsContext, CredentialsContextProjectInfo } from '../../../context';
import { SetUpAdhocProvisioningProfile } from '../SetUpAdhocProvisioningProfile';
import { SetUpInternalProvisioningProfile } from '../SetUpInternalProvisioningProfile';
import { SetUpTargetBuildCredentials } from '../SetUpTargetBuildCredentials';
jest.mock('../SetUpAdhocProvisioningProfile', () => ({
SetUpAdhocProvisioningProfile: jest.fn(),
}));
jest.mock('../SetUpInternalProvisioningProfile', () => ({
SetUpInternalProvisioningProfile: jest.fn(),
}));
jest.mock('../../../context');
describe(SetUpTargetBuildCredentials, () => {
const app = {
account: { name: 'account' } as Account,
projectName: 'projName',
bundleIdentifier: 'bundleId',
};
const target = { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} };
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(SetUpAdhocProvisioningProfile).mockImplementation(
() =>
({
runAsync: jest.fn().mockResolvedValue({} as IosAppBuildCredentialsFragment),
}) as any
);
jest.mocked(SetUpInternalProvisioningProfile).mockImplementation(
() =>
({
runAsync: jest.fn().mockResolvedValue({} as IosAppBuildCredentialsFragment),
}) as any
);
});
function createCtx(overrides: Partial<CredentialsContext> = {}): CredentialsContext {
const ctx = jest.mocked(
new CredentialsContext(
{} as {
projectInfo: CredentialsContextProjectInfo | null;
easJsonCliConfig?: EasJson['cli'];
nonInteractive: boolean;
projectDir: string;
user: Actor;
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
env?: Env;
vcsClient: Client;
}
)
);
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', {
value: true,
writable: true,
});
return Object.assign(ctx, overrides);
}
it('errors when refresh is enabled for store distribution builds', async () => {
const action = new SetUpTargetBuildCredentials({
app,
distribution: 'store',
entitlements: {},
target,
});
await expect(action.setupBuildCredentialsAsync(createCtx())).rejects.toThrow(
'--refresh-ad-hoc-provisioning-profile is only supported for internal distribution builds.'
);
});
it('errors when refresh is enabled for universal enterprise internal builds', async () => {
const action = new SetUpTargetBuildCredentials({
app,
distribution: 'internal',
enterpriseProvisioning: 'universal',
entitlements: {},
target,
});
await expect(action.setupBuildCredentialsAsync(createCtx())).rejects.toThrow(
'--refresh-ad-hoc-provisioning-profile is only supported for ad-hoc internal builds.'
);
});
it('delegates to internal provisioning setup when refresh is enabled for internal ad-hoc builds', async () => {
const action = new SetUpTargetBuildCredentials({
app,
distribution: 'internal',
entitlements: {},
target,
});
const ctx = createCtx();
await action.setupBuildCredentialsAsync(ctx);
expect(SetUpInternalProvisioningProfile).toHaveBeenCalledWith({ app, target });
expect(SetUpAdhocProvisioningProfile).not.toHaveBeenCalled();
});
});
@@ -1,46 +1,16 @@
import { CombinedError } from '@urql/core';
import Log from '../../log';
import {
EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE,
withErrorHandlingAsync,
withUpgradeRequiredErrorHandlingAsync,
} from '../client';
jest.mock('../../log');
function makeError(message: string, extensions?: Record<string, unknown>): CombinedError {
return new CombinedError({
graphQLErrors: [{ message, extensions } as any],
});
}
const mockLogError = jest.mocked(Log.error);
describe(withErrorHandlingAsync, () => {
beforeEach(() => jest.clearAllMocks());
it('logs the transient error message for generic transient errors', async () => {
const error = makeError('Transient failure', {
isTransient: true,
errorCode: 'SOME_TRANSIENT',
});
await expect(withErrorHandlingAsync(Promise.resolve({ error } as any))).rejects.toBe(error);
expect(mockLogError).toHaveBeenCalledWith(
expect.stringContaining("We've encountered a transient error")
);
});
it('suppresses the transient error message for EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE', async () => {
const error = makeError('Asset not yet available', {
isTransient: true,
errorCode: 'EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE',
});
await expect(withErrorHandlingAsync(Promise.resolve({ error } as any))).rejects.toBe(error);
expect(mockLogError).not.toHaveBeenCalled();
});
});
describe(withUpgradeRequiredErrorHandlingAsync, () => {
it('returns data when the promise resolves successfully', async () => {
const result = await withUpgradeRequiredErrorHandlingAsync(
-1
View File
@@ -16,7 +16,6 @@ export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult
![
'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED',
'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED',
'EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE',
].includes(e?.extensions?.errorCode as string)
)
) {
+4 -480
View File
@@ -1458,8 +1458,6 @@ export type App = Project & {
/** @deprecated Classic updates have been deprecated. */
description: Scalars['String']['output'];
devDomainName?: Maybe<AppDevDomainName>;
deviceRunSessionsPaginated: AppDeviceRunSessionsConnection;
embeddedUpdatesPaginated: AppEmbeddedUpdatesConnection;
/** Environment secrets for an app */
environmentSecrets: Array<EnvironmentSecret>;
environmentVariableEnvironments: Array<Scalars['EnvironmentVariableEnvironment']['output']>;
@@ -1608,8 +1606,6 @@ export type App = Project & {
workerDeploymentsCrashes?: Maybe<WorkerDeploymentCrashes>;
workerDeploymentsRequest: WorkerDeploymentRequestEdge;
workerDeploymentsRequests?: Maybe<WorkerDeploymentRequests>;
workflowDeviceTestCaseHistory: WorkflowDeviceTestCaseHistory;
workflowDeviceTestCaseInsights: WorkflowDeviceTestCaseInsights;
workflowRunGitBranchesPaginated: AppWorkflowRunGitBranchesConnection;
workflowRunsPaginated: AppWorkflowRunsConnection;
workflows: Array<Workflow>;
@@ -1690,26 +1686,6 @@ export type AppDeploymentsArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppDeviceRunSessionsPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<DeviceRunSessionFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppEmbeddedUpdatesPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<EmbeddedUpdateFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppEnvironmentSecretsArgs = {
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -1934,21 +1910,6 @@ export type AppWorkerDeploymentsRequestsArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWorkflowDeviceTestCaseHistoryArgs = {
filters?: InputMaybe<WorkflowDeviceTestCaseHistoryFiltersInput>;
path: Scalars['String']['input'];
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWorkflowDeviceTestCaseInsightsArgs = {
filters?: InputMaybe<WorkflowDeviceTestCaseInsightsFiltersInput>;
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWorkflowRunGitBranchesPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
@@ -2043,30 +2004,6 @@ export type AppDevDomainNameMutationChangeDevDomainNameArgs = {
name: Scalars['DevDomainName']['input'];
};
export type AppDeviceRunSessionEdge = {
__typename?: 'AppDeviceRunSessionEdge';
cursor: Scalars['String']['output'];
node: DeviceRunSession;
};
export type AppDeviceRunSessionsConnection = {
__typename?: 'AppDeviceRunSessionsConnection';
edges: Array<AppDeviceRunSessionEdge>;
pageInfo: PageInfo;
};
export type AppEmbeddedUpdateEdge = {
__typename?: 'AppEmbeddedUpdateEdge';
cursor: Scalars['String']['output'];
node: EmbeddedUpdate;
};
export type AppEmbeddedUpdatesConnection = {
__typename?: 'AppEmbeddedUpdatesConnection';
edges: Array<AppEmbeddedUpdateEdge>;
pageInfo: PageInfo;
};
export type AppFingerprintEdge = {
__typename?: 'AppFingerprintEdge';
cursor: Scalars['String']['output'];
@@ -2210,15 +2147,6 @@ export type AppObserve = {
navigationRoutes: AppObserveNavigationRoutesConnection;
timeSeries: AppObserveTimeSeries;
totalEventCount: Scalars['Int']['output'];
/**
* Approximate count of unique users (`eas_client_id`) with at least one
* supported-metric event in `[startTime, endTime)`. Uses ClickHouse's
* HyperLogLog `uniq()`, so the value can drift by a small percent on apps
* with very large user bases. `metricNames` on the input is currently
* ignored: the count is always over all supported metrics, matching the
* universe used by `appVersions` headline counts.
*/
uniqueActiveUserCount: Scalars['Int']['output'];
updates: AppObserveUpdatesConnection;
};
@@ -2288,11 +2216,6 @@ export type AppObserveTimeSeriesArgs = {
};
export type AppObserveUniqueActiveUserCountArgs = {
input: AppObserveReleasesInput;
};
export type AppObserveUpdatesArgs = {
input: AppObserveUpdatesInput;
};
@@ -2301,11 +2224,6 @@ export type AppObserveAppBuildEmbeddedSummary = {
__typename?: 'AppObserveAppBuildEmbeddedSummary';
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/**
* Unique users whose most recent supported-metric event in the queried
* range ran this build's embedded bundle.
*/
lastSeenUserCount: Scalars['Int']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
@@ -2321,18 +2239,7 @@ export type AppObserveAppBuildNumber = {
embedded?: Maybe<AppObserveAppBuildEmbeddedSummary>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/**
* Unique users whose most recent supported-metric event in the queried
* range was on this (appVersion, appBuildNumber) tuple.
*/
lastSeenUserCount: Scalars['Int']['output'];
uniqueUserCount: Scalars['Int']['output'];
/**
* Updates seen on this build number in the queried time range. Each
* entry's counts and EAS builds are scoped to this (appVersion,
* appBuildNumber, appUpdateId) tuple.
*/
updates: Array<AppObserveAppUpdate>;
};
export type AppObserveAppEasBuild = {
@@ -2350,11 +2257,6 @@ export type AppObserveAppUpdate = {
easBuilds: Array<AppObserveAppEasBuild>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/**
* Unique users whose most recent supported-metric event in the queried
* range matched this update at its current nesting level.
*/
lastSeenUserCount: Scalars['Int']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
@@ -2364,17 +2266,6 @@ export type AppObserveAppVersion = {
buildNumbers: Array<AppObserveAppBuildNumber>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/**
* Unique users whose most recent supported-metric event in
* `[startTime, endTime)` was on this version. Each active user
* contributes to exactly one version, so summing this field across the
* returned versions yields the total active users in the window (equal
* to `AppObserve.uniqueActiveUserCount` up to HyperLogLog noise).
* A version may be returned with `lastSeenUserCount = 0` when
* every user who touched it later moved to a newer version. Users with
* no events in the window are not counted on any version.
*/
lastSeenUserCount: Scalars['Int']['output'];
metrics: Array<AppObserveAppVersionMetric>;
uniqueUserCount: Scalars['Int']['output'];
updates: Array<AppObserveAppUpdate>;
@@ -2406,7 +2297,6 @@ export type AppObserveCustomEvent = {
eventName: Scalars['String']['output'];
expoSdkVersion?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
ingestedAt?: Maybe<Scalars['DateTime']['output']>;
properties: Array<AppObserveEventProperty>;
reactNativeVersion?: Maybe<Scalars['String']['output']>;
sessionId?: Maybe<Scalars['String']['output']>;
@@ -2504,7 +2394,6 @@ export type AppObserveCustomEventPropertyFilter = {
export type AppObserveEvent = {
__typename?: 'AppObserveEvent';
appBuildNumber: Scalars['String']['output'];
appEasBuildId?: Maybe<Scalars['String']['output']>;
appIdentifier: Scalars['String']['output'];
appName: Scalars['String']['output'];
appUpdateId?: Maybe<Scalars['String']['output']>;
@@ -4721,12 +4610,6 @@ export type CreateConvexTeamConnectionInput = {
export type CreateDeviceRunSessionInput = {
appId: Scalars['ID']['input'];
/**
* Override for the underlying turtle job run's max run time, in minutes. Must
* be non-negative and smaller than 120 (2 hours). Only customizable on paid
* plans. If omitted, the default is derived based on the job run's priority.
*/
maxRunTimeMinutes?: InputMaybe<Scalars['Int']['input']>;
/**
* The version of the package backing the device run session (e.g. "0.1.3-alpha.3").
* If omitted, consumers treat the session as pinned to "latest".
@@ -5105,11 +4988,6 @@ export type DeleteDiscordUserResult = {
id: Scalars['ID']['output'];
};
export type DeleteEmbeddedUpdateResult = {
__typename?: 'DeleteEmbeddedUpdateResult';
id: Scalars['ID']['output'];
};
export type DeleteEnvironmentSecretResult = {
__typename?: 'DeleteEnvironmentSecretResult';
id: Scalars['ID']['output'];
@@ -5359,12 +5237,6 @@ export type DeviceRunSession = {
updatedAt: Scalars['DateTime']['output'];
};
export type DeviceRunSessionFilterInput = {
platforms?: InputMaybe<Array<AppPlatform>>;
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
types?: InputMaybe<Array<DeviceRunSessionType>>;
};
export type DeviceRunSessionMutation = {
__typename?: 'DeviceRunSessionMutation';
/** Create a device run session */
@@ -6223,54 +6095,20 @@ export type EmbeddedUpdateAssetUploadSpec = {
storageKey: Scalars['String']['output'];
};
export type EmbeddedUpdateFilterInput = {
channel?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppPlatform>;
runtimeVersion?: InputMaybe<Scalars['String']['input']>;
};
export type EmbeddedUpdateMutation = {
__typename?: 'EmbeddedUpdateMutation';
/**
* Delete an embedded update by id. Best-effort: deleting an unknown id succeeds
* (mirrors background deletion jobs). The linked asset row and underlying GCS
* object are cleaned up via the entity's afterDelete trigger chain.
*/
deleteEmbeddedUpdate: DeleteEmbeddedUpdateResult;
/**
* Register an embedded bundle as the launch asset for a given app/platform/channel.
* Returns EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE if the asset has not been finalized yet,
* or EMBEDDED_UPDATE_ALREADY_EXISTS if an embedded update with this id is already registered.
* Returns EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE if the asset has not been finalized yet.
*/
uploadEmbeddedUpdate: EmbeddedUpdate;
};
export type EmbeddedUpdateMutationDeleteEmbeddedUpdateArgs = {
id: Scalars['ID']['input'];
};
export type EmbeddedUpdateMutationUploadEmbeddedUpdateArgs = {
input: UploadEmbeddedUpdateInput;
};
export type EmbeddedUpdateQuery = {
__typename?: 'EmbeddedUpdateQuery';
/**
* Look up an embedded update by its id and the owning app's id.
* Throws EMBEDDED_UPDATE_NOT_FOUND when no embedded update with this id exists on
* the given app within the caller's account.
*/
byId: EmbeddedUpdate;
};
export type EmbeddedUpdateQueryByIdArgs = {
appId: Scalars['ID']['input'];
embeddedUpdateId: Scalars['ID']['input'];
};
export enum EntityTypeName {
AccountEntity = 'AccountEntity',
AccountSsoConfigurationEntity = 'AccountSSOConfigurationEntity',
@@ -6557,8 +6395,6 @@ export type ExperimentationQuery = {
export type ExpoGoBuildQuery = {
__typename?: 'ExpoGoBuildQuery';
repackConfiguration: ExpoGoProjectConfiguration;
supportedSdkVersionKeys: Array<Scalars['String']['output']>;
supportedSdkVersions: Array<ExpoGoSdkVersion>;
};
@@ -6586,14 +6422,6 @@ export type ExpoGoRepackInput = {
sdkVersion?: InputMaybe<Scalars['String']['input']>;
};
export type ExpoGoSdkVersion = {
__typename?: 'ExpoGoSdkVersion';
isBeta: Scalars['Boolean']['output'];
isDeprecated: Scalars['Boolean']['output'];
isLatest: Scalars['Boolean']['output'];
sdkVersion: Scalars['String']['output'];
};
export type FcmSnippet = FcmSnippetLegacy | FcmSnippetV1;
export type FcmSnippetLegacy = {
@@ -7555,8 +7383,6 @@ export type JobRun = {
initiatingActor?: Maybe<Actor>;
isWaived: Scalars['Boolean']['output'];
logFileUrls: Array<Scalars['String']['output']>;
/** Max run time in seconds for this job run. */
maxRunTimeSeconds: Scalars['Int']['output'];
name: Scalars['String']['output'];
priority: JobRunPriority;
/** String describing the worker profile used to run this job run. */
@@ -7564,7 +7390,6 @@ export type JobRun = {
startedAt?: Maybe<Scalars['DateTime']['output']>;
status: JobRunStatus;
updateGroups: Array<Array<Update>>;
workflowJob?: Maybe<WorkflowJob>;
};
export type JobRunError = {
@@ -8600,7 +8425,6 @@ export type RootQuery = {
echoProject: EchoProjectQuery;
/** Top-level query object for querying Echo versions. */
echoVersion: EchoVersionQuery;
embeddedUpdates: EmbeddedUpdateQuery;
/** Top-level query object for querying Experimentation configuration. */
experimentation: ExperimentationQuery;
expoGoBuild: ExpoGoBuildQuery;
@@ -8857,7 +8681,7 @@ export type SsoUser = Actor & UserActor & {
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl: Scalars['String']['output'];
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this account */
@@ -10143,7 +9967,7 @@ export type User = Actor & UserActor & {
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl: Scalars['String']['output'];
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Get all certified second factor authentication methods */
@@ -10247,7 +10071,7 @@ export type UserActor = {
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl: Scalars['String']['output'];
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this user's personal account */
@@ -10302,8 +10126,6 @@ export type UserActorPublicData = {
firstName?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
lastName?: Maybe<Scalars['String']['output']>;
primaryAccountProfileImageUrl: Scalars['String']['output'];
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this user's personal account */
snacks: Array<Snack>;
@@ -11450,187 +11272,6 @@ export enum WorkflowArtifactStorageType {
R2 = 'R2'
}
/**
* Grouping key from the [shard N] prefix-stripped error_message. `count` is
* computed with uniqExact(test_case_result_id) so RMT pre-merge duplicates do
* not inflate it. Scope: scans ALL status='failed' rows (NOT just
* is_final_attempt=1) so failures from runs that ultimately passed on retry
* still surface.
*/
export type WorkflowDeviceTestCaseErrorPattern = {
__typename?: 'WorkflowDeviceTestCaseErrorPattern';
count: Scalars['Int']['output'];
lastSeenAt: Scalars['DateTime']['output'];
patternKey: Scalars['String']['output'];
sampleMessage: Scalars['String']['output'];
};
export type WorkflowDeviceTestCaseHistory = {
__typename?: 'WorkflowDeviceTestCaseHistory';
errorPatterns: Array<WorkflowDeviceTestCaseErrorPattern>;
recentRuns: WorkflowDeviceTestCaseRecentRunConnection;
timeSeries: Array<WorkflowDeviceTestCaseInsightsBucket>;
totals: WorkflowDeviceTestCaseInsightsTotals;
};
export type WorkflowDeviceTestCaseHistoryErrorPatternsArgs = {
first: Scalars['Int']['input'];
};
export type WorkflowDeviceTestCaseHistoryRecentRunsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
first: Scalars['Int']['input'];
};
export type WorkflowDeviceTestCaseHistoryTimeSeriesArgs = {
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
};
export type WorkflowDeviceTestCaseHistoryFiltersInput = {
gitRefs?: InputMaybe<Array<Scalars['String']['input']>>;
workflowIds?: InputMaybe<Array<Scalars['ID']['input']>>;
};
export type WorkflowDeviceTestCaseInsights = {
__typename?: 'WorkflowDeviceTestCaseInsights';
facets: WorkflowDeviceTestCaseInsightsFacets;
tests: WorkflowDeviceTestCaseStatConnection;
timeSeries: Array<WorkflowDeviceTestCaseInsightsBucket>;
totals: WorkflowDeviceTestCaseInsightsTotals;
};
export type WorkflowDeviceTestCaseInsightsTestsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
first: Scalars['Int']['input'];
search?: InputMaybe<Scalars['String']['input']>;
sortDirection?: InputMaybe<WorkflowDeviceTestCaseSortDirection>;
sortField?: InputMaybe<WorkflowDeviceTestCaseStatSortField>;
};
export type WorkflowDeviceTestCaseInsightsTimeSeriesArgs = {
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
};
/**
* Mutually exclusive bucket counts (passedClean + flaky + failed = totalRuns for the bucket).
* Buckets are aligned to the UTC start of the requested granularity interval
* (minute / hour / day).
*/
export type WorkflowDeviceTestCaseInsightsBucket = {
__typename?: 'WorkflowDeviceTestCaseInsightsBucket';
bucketStartAt: Scalars['DateTime']['output'];
failed: Scalars['Int']['output'];
flaky: Scalars['Int']['output'];
passedClean: Scalars['Int']['output'];
};
export type WorkflowDeviceTestCaseInsightsFacets = {
__typename?: 'WorkflowDeviceTestCaseInsightsFacets';
gitRefs: Array<Scalars['String']['output']>;
tags: Array<Scalars['String']['output']>;
workflows: Array<WorkflowDeviceTestCaseWorkflowFacet>;
};
export type WorkflowDeviceTestCaseInsightsFiltersInput = {
gitRefs?: InputMaybe<Array<Scalars['String']['input']>>;
statuses?: InputMaybe<Array<WorkflowDeviceTestCaseStatusFilter>>;
tags?: InputMaybe<Array<Scalars['String']['input']>>;
workflowIds?: InputMaybe<Array<Scalars['ID']['input']>>;
};
/**
* A count metric returned for the current window AND the equivalent prior window
* (start − duration → start). The frontend uses (current, previous) to compute
* trend deltas. Trend ratios are NOT pre-computed server-side — see
* WorkflowsInsightsMetric for the equivalent convention.
*
* Float (not Int) to match WorkflowsInsightsMetric and avoid the GraphQL Int32
* ceiling: 90-day uniqExact counts on a high-volume project can plausibly
* exceed 2.1B. Values are integer-valued; the frontend reads them as JS numbers.
*/
export type WorkflowDeviceTestCaseInsightsMetric = {
__typename?: 'WorkflowDeviceTestCaseInsightsMetric';
currentValue: Scalars['Float']['output'];
previousValue: Scalars['Float']['output'];
};
/**
* Same shape as WorkflowDeviceTestCaseInsightsMetric but nullable on both sides —
* used for metrics like avgDurationMs / p90DurationMs where "no data in the
* window" is meaningful and must not collapse to 0.
*/
export type WorkflowDeviceTestCaseInsightsNullableMetric = {
__typename?: 'WorkflowDeviceTestCaseInsightsNullableMetric';
currentValue?: Maybe<Scalars['Float']['output']>;
previousValue?: Maybe<Scalars['Float']['output']>;
};
export enum WorkflowDeviceTestCaseInsightsTimeSeriesGranularity {
Day = 'DAY',
Hour = 'HOUR',
Minute = 'MINUTE'
}
export type WorkflowDeviceTestCaseInsightsTimespanInput = {
end: Scalars['DateTime']['input'];
start: Scalars['DateTime']['input'];
};
/**
* Raw counts only (paired with previous-window values). Pass rate, flake rate,
* and trends are computed in the frontend from the (currentValue, previousValue)
* pair on each metric.
*
* totalRuns = passedCleanCount + flakyCount + failedCount (over is_final_attempt=1 rows).
* distinctFlakyTestCount cannot be derived in the frontend from per-test rows
* because pagination means the frontend doesn't see all test paths.
*/
export type WorkflowDeviceTestCaseInsightsTotals = {
__typename?: 'WorkflowDeviceTestCaseInsightsTotals';
avgDurationMs: WorkflowDeviceTestCaseInsightsNullableMetric;
distinctFlakyTestCount: WorkflowDeviceTestCaseInsightsMetric;
failedCount: WorkflowDeviceTestCaseInsightsMetric;
flakyCount: WorkflowDeviceTestCaseInsightsMetric;
p90DurationMs: WorkflowDeviceTestCaseInsightsNullableMetric;
passedCleanCount: WorkflowDeviceTestCaseInsightsMetric;
totalRuns: WorkflowDeviceTestCaseInsightsMetric;
};
/**
* One row per execution (is_final_attempt=1). The is_flaky boolean drives the
* FLAKY pill — no per-attempt timeline is exposed in v1.
*/
export type WorkflowDeviceTestCaseRecentRun = {
__typename?: 'WorkflowDeviceTestCaseRecentRun';
commitSha?: Maybe<Scalars['String']['output']>;
createdAt: Scalars['DateTime']['output'];
durationMs?: Maybe<Scalars['Int']['output']>;
gitRef?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
isFlaky: Scalars['Boolean']['output'];
status: WorkflowDeviceTestCaseStatus;
workflowRunId: Scalars['ID']['output'];
workflowRunName: Scalars['String']['output'];
};
export type WorkflowDeviceTestCaseRecentRunConnection = {
__typename?: 'WorkflowDeviceTestCaseRecentRunConnection';
edges: Array<WorkflowDeviceTestCaseRecentRunEdge>;
pageInfo: PageInfo;
totalCount: Scalars['Int']['output'];
};
export type WorkflowDeviceTestCaseRecentRunEdge = {
__typename?: 'WorkflowDeviceTestCaseRecentRunEdge';
cursor: Scalars['String']['output'];
node: WorkflowDeviceTestCaseRecentRun;
};
/** A device test case result from a Maestro test execution. */
export type WorkflowDeviceTestCaseResult = {
__typename?: 'WorkflowDeviceTestCaseResult';
@@ -11684,73 +11325,12 @@ export type WorkflowDeviceTestCaseResultMutationCreateWorkflowDeviceTestCaseResu
input: CreateWorkflowDeviceTestCaseResultsInput;
};
export enum WorkflowDeviceTestCaseSortDirection {
Asc = 'ASC',
Desc = 'DESC'
}
export type WorkflowDeviceTestCaseStat = {
__typename?: 'WorkflowDeviceTestCaseStat';
avgDurationMs?: Maybe<Scalars['Int']['output']>;
failedCount: Scalars['Int']['output'];
flakyCount: Scalars['Int']['output'];
lastRunAt: Scalars['DateTime']['output'];
lastRunIsFlaky: Scalars['Boolean']['output'];
lastRunStatus: WorkflowDeviceTestCaseStatus;
name: Scalars['String']['output'];
p90DurationMs?: Maybe<Scalars['Int']['output']>;
passedCleanCount: Scalars['Int']['output'];
path: Scalars['String']['output'];
totalRuns: Scalars['Int']['output'];
};
export type WorkflowDeviceTestCaseStatConnection = {
__typename?: 'WorkflowDeviceTestCaseStatConnection';
edges: Array<WorkflowDeviceTestCaseStatEdge>;
pageInfo: PageInfo;
totalCount: Scalars['Int']['output'];
};
export type WorkflowDeviceTestCaseStatEdge = {
__typename?: 'WorkflowDeviceTestCaseStatEdge';
cursor: Scalars['String']['output'];
node: WorkflowDeviceTestCaseStat;
};
export enum WorkflowDeviceTestCaseStatSortField {
Fails = 'FAILS',
Flakes = 'FLAKES',
FlakeRate = 'FLAKE_RATE',
LastRun = 'LAST_RUN',
P90Duration = 'P90_DURATION',
PassRate = 'PASS_RATE',
Runs = 'RUNS'
}
/** Status of a device test case execution. */
export enum WorkflowDeviceTestCaseStatus {
Failed = 'FAILED',
Passed = 'PASSED'
}
/**
* Mutually exclusive — matches the chart bucket semantics (passedClean / flaky / failed).
* PASSED_CLEAN = is_final_attempt=1 AND status=passed AND is_flaky=0
* FLAKY = is_final_attempt=1 AND is_flaky=1 (still a pass, just with retries)
* FAILED = is_final_attempt=1 AND status=failed
*/
export enum WorkflowDeviceTestCaseStatusFilter {
Failed = 'FAILED',
Flaky = 'FLAKY',
PassedClean = 'PASSED_CLEAN'
}
export type WorkflowDeviceTestCaseWorkflowFacet = {
__typename?: 'WorkflowDeviceTestCaseWorkflowFacet';
id: Scalars['ID']['output'];
name: Scalars['String']['output'];
};
export type WorkflowJob = {
__typename?: 'WorkflowJob';
allDeviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
@@ -12839,29 +12419,6 @@ export type EnsureDeviceRunSessionStoppedMutationVariables = Exact<{
export type EnsureDeviceRunSessionStoppedMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', ensureDeviceRunSessionStopped: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus } } };
export type GetSignedEmbeddedUpdateAssetUploadSpecMutationVariables = Exact<{
appId: Scalars['ID']['input'];
embeddedUpdateId: Scalars['ID']['input'];
contentType: Scalars['String']['input'];
}>;
export type GetSignedEmbeddedUpdateAssetUploadSpecMutation = { __typename?: 'RootMutation', embeddedUpdateAsset: { __typename?: 'EmbeddedUpdateAssetMutation', getSignedEmbeddedUpdateAssetUploadSpecifications: { __typename?: 'EmbeddedUpdateAssetUploadSpec', storageKey: string, presignedUrl: string, fields: any } } };
export type UploadEmbeddedUpdateMutationVariables = Exact<{
input: UploadEmbeddedUpdateInput;
}>;
export type UploadEmbeddedUpdateMutation = { __typename?: 'RootMutation', embeddedUpdate: { __typename?: 'EmbeddedUpdateMutation', uploadEmbeddedUpdate: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any } } };
export type DeleteEmbeddedUpdateMutationVariables = Exact<{
id: Scalars['ID']['input'];
}>;
export type DeleteEmbeddedUpdateMutation = { __typename?: 'RootMutation', embeddedUpdate: { __typename?: 'EmbeddedUpdateMutation', deleteEmbeddedUpdate: { __typename?: 'DeleteEmbeddedUpdateResult', id: string } } };
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
accountId: Scalars['String']['input'];
@@ -13351,34 +12908,6 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<DeviceRunSessionFilterInput>;
}>;
export type DeviceRunSessionsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, deviceRunSessionsPaginated: { __typename?: 'AppDeviceRunSessionsConnection', edges: Array<{ __typename?: 'AppDeviceRunSessionEdge', cursor: string, node: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewEmbeddedUpdateByIdQueryVariables = Exact<{
embeddedUpdateId: Scalars['ID']['input'];
appId: Scalars['ID']['input'];
}>;
export type ViewEmbeddedUpdateByIdQuery = { __typename?: 'RootQuery', embeddedUpdates: { __typename?: 'EmbeddedUpdateQuery', byId: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any, launchAsset: { __typename?: 'EmbeddedUpdateAsset', id: string, fileSize: number, finalFileSize?: number | null, fileSHA256: string } } } };
export type ViewEmbeddedUpdatesPaginatedQueryVariables = Exact<{
appId: Scalars['String']['input'];
first: Scalars['Int']['input'];
after?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<EmbeddedUpdateFilterInput>;
}>;
export type ViewEmbeddedUpdatesPaginatedQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, embeddedUpdatesPaginated: { __typename?: 'AppEmbeddedUpdatesConnection', edges: Array<{ __typename?: 'AppEmbeddedUpdateEdge', cursor: string, node: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any, launchAsset: { __typename?: 'EmbeddedUpdateAsset', id: string, fileSize: number, finalFileSize?: number | null, fileSHA256: string } } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
@@ -13633,11 +13162,6 @@ export type WorkflowJobByIdQueryVariables = Exact<{
export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> } } };
export type ExpoGoSupportedSdkVersionsQueryVariables = Exact<{ [key: string]: never; }>;
export type ExpoGoSupportedSdkVersionsQuery = { __typename?: 'RootQuery', expoGoBuild: { __typename?: 'ExpoGoBuildQuery', supportedSdkVersions: Array<{ __typename?: 'ExpoGoSdkVersion', sdkVersion: string, isLatest: boolean, isBeta: boolean, isDeprecated: boolean }> } };
export type ExpoGoRepackConfigurationQueryVariables = Exact<{
input: ExpoGoRepackInput;
}>;
@@ -1,56 +0,0 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
GetSignedEmbeddedUpdateAssetUploadSpecMutation,
GetSignedEmbeddedUpdateAssetUploadSpecMutationVariables,
} from '../generated';
import { withErrorHandlingAsync } from '../client';
export type EmbeddedUpdateAssetUploadSpec = {
storageKey: string;
presignedUrl: string;
fields: Record<string, string>;
};
export const EmbeddedUpdateAssetMutation = {
async getSignedUploadSpecAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
embeddedUpdateId,
contentType,
}: { appId: string; embeddedUpdateId: string; contentType: string }
): Promise<EmbeddedUpdateAssetUploadSpec> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
GetSignedEmbeddedUpdateAssetUploadSpecMutation,
GetSignedEmbeddedUpdateAssetUploadSpecMutationVariables
>(
gql`
mutation GetSignedEmbeddedUpdateAssetUploadSpec(
$appId: ID!
$embeddedUpdateId: ID!
$contentType: String!
) {
embeddedUpdateAsset {
getSignedEmbeddedUpdateAssetUploadSpecifications(
appId: $appId
embeddedUpdateId: $embeddedUpdateId
contentType: $contentType
) {
storageKey
presignedUrl
fields
}
}
}
`,
{ appId, embeddedUpdateId, contentType }
)
.toPromise()
);
return data.embeddedUpdateAsset.getSignedEmbeddedUpdateAssetUploadSpecifications;
},
};
@@ -1,89 +0,0 @@
import { CombinedError } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeleteEmbeddedUpdateMutation,
DeleteEmbeddedUpdateMutationVariables,
UploadEmbeddedUpdateInput,
UploadEmbeddedUpdateMutation,
UploadEmbeddedUpdateMutationVariables,
} from '../generated';
import { withErrorHandlingAsync } from '../client';
export type EmbeddedUpdateResult = {
id: string;
platform: AppPlatform;
runtimeVersion: string;
channel: string;
createdAt: string;
};
export function isEmbeddedUpdateAssetNotAvailableError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(
e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE'
)
);
}
export function isEmbeddedUpdateAlreadyExistsError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_ALREADY_EXISTS')
);
}
export const EmbeddedUpdateMutation = {
async uploadEmbeddedUpdateAsync(
graphqlClient: ExpoGraphqlClient,
input: UploadEmbeddedUpdateInput
): Promise<EmbeddedUpdateResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UploadEmbeddedUpdateMutation, UploadEmbeddedUpdateMutationVariables>(
gql`
mutation UploadEmbeddedUpdate($input: UploadEmbeddedUpdateInput!) {
embeddedUpdate {
uploadEmbeddedUpdate(input: $input) {
id
platform
runtimeVersion
channel
createdAt
}
}
}
`,
{ input }
)
.toPromise()
);
return data.embeddedUpdate.uploadEmbeddedUpdate;
},
async deleteEmbeddedUpdateAsync(
graphqlClient: ExpoGraphqlClient,
{ id }: { id: string }
): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteEmbeddedUpdateMutation, DeleteEmbeddedUpdateMutationVariables>(
gql`
mutation DeleteEmbeddedUpdate($id: ID!) {
embeddedUpdate {
deleteEmbeddedUpdate(id: $id) {
id
}
}
}
`,
{ id }
)
.toPromise()
);
return data.embeddedUpdate.deleteEmbeddedUpdate;
},
};
@@ -1,33 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EmbeddedUpdateAssetMutation } from '../EmbeddedUpdateAssetMutation';
function makeGraphqlClient(data: unknown): ExpoGraphqlClient {
return {
mutation: jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
}),
} as unknown as ExpoGraphqlClient;
}
describe('EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync', () => {
it('returns the upload spec from the GraphQL response', async () => {
const expected = {
storageKey: 'app-id/update-id',
presignedUrl: 'https://storage.example.com/upload',
fields: { key: 'value' },
};
const client = makeGraphqlClient({
embeddedUpdateAsset: {
getSignedEmbeddedUpdateAssetUploadSpecifications: expected,
},
});
const result = await EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync(client, {
appId: 'app-id',
embeddedUpdateId: 'update-id',
contentType: 'application/javascript',
});
expect(result).toEqual(expected);
});
});
@@ -1,123 +0,0 @@
import { CombinedError } from '@urql/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../generated';
import {
EmbeddedUpdateMutation,
isEmbeddedUpdateAlreadyExistsError,
isEmbeddedUpdateAssetNotAvailableError,
} from '../EmbeddedUpdateMutation';
function makeGraphqlClient(data: unknown): ExpoGraphqlClient {
return {
mutation: jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
}),
} as unknown as ExpoGraphqlClient;
}
function makeCombinedError(errorCode: string): CombinedError {
return new CombinedError({
graphQLErrors: [{ message: 'error', extensions: { errorCode } }],
});
}
describe('isEmbeddedUpdateAssetNotAvailableError', () => {
it('returns true for CombinedError with EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE errorCode', () => {
expect(
isEmbeddedUpdateAssetNotAvailableError(
makeCombinedError('EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE')
)
).toBe(true);
});
it('returns false for CombinedError with a different errorCode', () => {
expect(isEmbeddedUpdateAssetNotAvailableError(makeCombinedError('SOME_OTHER_ERROR'))).toBe(
false
);
});
it('returns false for CombinedError with no extensions', () => {
expect(
isEmbeddedUpdateAssetNotAvailableError(
new CombinedError({ graphQLErrors: [{ message: 'oops' }] })
)
).toBe(false);
});
it('returns false for a plain Error', () => {
expect(isEmbeddedUpdateAssetNotAvailableError(new Error('plain error'))).toBe(false);
});
it('returns false for null and non-error values', () => {
expect(isEmbeddedUpdateAssetNotAvailableError(null)).toBe(false);
expect(isEmbeddedUpdateAssetNotAvailableError('string')).toBe(false);
expect(isEmbeddedUpdateAssetNotAvailableError(42)).toBe(false);
});
});
describe('isEmbeddedUpdateAlreadyExistsError', () => {
it('returns true for CombinedError with EMBEDDED_UPDATE_ALREADY_EXISTS errorCode', () => {
expect(
isEmbeddedUpdateAlreadyExistsError(makeCombinedError('EMBEDDED_UPDATE_ALREADY_EXISTS'))
).toBe(true);
});
it('returns false for CombinedError with a different errorCode', () => {
expect(isEmbeddedUpdateAlreadyExistsError(makeCombinedError('SOME_OTHER_ERROR'))).toBe(false);
});
it('returns false for a plain Error', () => {
expect(isEmbeddedUpdateAlreadyExistsError(new Error('plain error'))).toBe(false);
});
});
describe('EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync', () => {
it('returns the embedded update from the GraphQL response', async () => {
const expected = {
id: 'update-1',
platform: AppPlatform.Android,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
};
const client = makeGraphqlClient({
embeddedUpdate: { uploadEmbeddedUpdate: expected },
});
const result = await EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync(client, {
appId: 'app-1',
platform: AppPlatform.Android,
runtimeVersion: '1.0.0',
channel: 'production',
embeddedUpdateId: 'update-1',
turtleBuildId: null,
});
expect(result).toEqual(expected);
});
});
describe('EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync', () => {
it('returns the deleted id from the GraphQL response', async () => {
const client = makeGraphqlClient({
embeddedUpdate: { deleteEmbeddedUpdate: { id: 'update-1' } },
});
const result = await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(client, {
id: 'update-1',
});
expect(result).toEqual({ id: 'update-1' });
});
it('passes the id through to the mutation variables', async () => {
const client = makeGraphqlClient({
embeddedUpdate: { deleteEmbeddedUpdate: { id: 'update-2' } },
});
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(client, { id: 'update-2' });
expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { id: 'update-2' });
});
});
@@ -2,13 +2,7 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
DeviceRunSessionByIdQuery,
DeviceRunSessionByIdQueryVariables,
DeviceRunSessionFilterInput,
DeviceRunSessionsByAppIdQuery,
DeviceRunSessionsByAppIdQueryVariables,
} from '../generated';
import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables } from '../generated';
export const DeviceRunSessionQuery = {
async byIdAsync(
@@ -64,74 +58,4 @@ export const DeviceRunSessionQuery = {
);
return data.deviceRunSessions.byId;
},
async listByAppIdAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
first,
after,
filter,
}: {
appId: string;
first?: number;
after?: string;
filter?: DeviceRunSessionFilterInput;
}
): Promise<DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated']> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<DeviceRunSessionsByAppIdQuery, DeviceRunSessionsByAppIdQueryVariables>(
gql`
query DeviceRunSessionsByAppId(
$appId: String!
$first: Int
$after: String
$filter: DeviceRunSessionFilterInput
) {
app {
byId(appId: $appId) {
id
deviceRunSessionsPaginated(first: $first, after: $after, filter: $filter) {
edges {
cursor
node {
id
status
type
platform
createdAt
startedAt
finishedAt
app {
id
slug
ownerAccount {
id
name
}
}
turtleJobRun {
id
status
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
`,
{ appId, first, after, filter },
{ requestPolicy: 'network-only' }
)
.toPromise()
);
return data.app.byId.deviceRunSessionsPaginated;
},
};
@@ -1,118 +0,0 @@
import { CombinedError } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Connection } from '../../utils/relay';
import {
EmbeddedUpdateFilterInput,
ViewEmbeddedUpdateByIdQuery,
ViewEmbeddedUpdateByIdQueryVariables,
ViewEmbeddedUpdatesPaginatedQuery,
ViewEmbeddedUpdatesPaginatedQueryVariables,
} from '../generated';
import { withErrorHandlingAsync } from '../client';
export function isEmbeddedUpdateNotFoundError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_NOT_FOUND')
);
}
export type EmbeddedUpdateFragment = ViewEmbeddedUpdateByIdQuery['embeddedUpdates']['byId'];
export type EmbeddedUpdateFilter = EmbeddedUpdateFilterInput;
export const EmbeddedUpdateQuery = {
async viewByIdAsync(
graphqlClient: ExpoGraphqlClient,
{ embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string }
): Promise<EmbeddedUpdateFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ViewEmbeddedUpdateByIdQuery, ViewEmbeddedUpdateByIdQueryVariables>(
gql`
query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) {
embeddedUpdates {
byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) {
id
platform
runtimeVersion
channel
createdAt
launchAsset {
id
fileSize
finalFileSize
fileSHA256
}
}
}
}
`,
{ embeddedUpdateId, appId },
{ additionalTypenames: ['EmbeddedUpdate'] }
)
.toPromise()
);
return data.embeddedUpdates.byId;
},
async viewPaginatedAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
filter,
first,
after,
}: { appId: string; filter?: EmbeddedUpdateFilter; first: number; after?: string }
): Promise<Connection<EmbeddedUpdateFragment>> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ViewEmbeddedUpdatesPaginatedQuery, ViewEmbeddedUpdatesPaginatedQueryVariables>(
gql`
query ViewEmbeddedUpdatesPaginated(
$appId: String!
$first: Int!
$after: String
$filter: EmbeddedUpdateFilterInput
) {
app {
byId(appId: $appId) {
id
embeddedUpdatesPaginated(first: $first, after: $after, filter: $filter) {
edges {
cursor
node {
id
platform
runtimeVersion
channel
createdAt
launchAsset {
id
fileSize
finalFileSize
fileSHA256
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
`,
{ appId, first, after, filter },
{ additionalTypenames: ['EmbeddedUpdate'] }
)
.toPromise()
);
return data.app.byId.embeddedUpdatesPaginated;
},
};
@@ -6,12 +6,7 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { withErrorHandlingAsync } from '../client';
import {
ExpoGoProjectConfiguration,
ExpoGoRepackConfigurationQuery,
ExpoGoRepackConfigurationQueryVariables,
ExpoGoRepackInput,
ExpoGoSdkVersion,
ExpoGoSupportedSdkVersionsQuery,
ExpoGoSupportedSdkVersionsQueryVariables,
WorkflowRunByIdQuery,
WorkflowRunByIdQueryVariables,
WorkflowRunByIdWithJobsQuery,
@@ -24,38 +19,17 @@ import { WorkflowJobFragmentNode } from '../types/WorkflowJob';
import { WorkflowRunFragmentNode } from '../types/WorkflowRun';
export const WorkflowRunQuery = {
async expoGoSupportedSdkVersionsAsync(
graphqlClient: ExpoGraphqlClient
): Promise<ExpoGoSdkVersion[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ExpoGoSupportedSdkVersionsQuery, ExpoGoSupportedSdkVersionsQueryVariables>(
gql`
query ExpoGoSupportedSdkVersions {
expoGoBuild {
supportedSdkVersions {
sdkVersion
isLatest
isBeta
isDeprecated
}
}
}
`,
{},
{ requestPolicy: 'network-only' }
)
.toPromise()
);
return data.expoGoBuild.supportedSdkVersions;
},
async expoGoRepackConfigurationAsync(
graphqlClient: ExpoGraphqlClient,
input: ExpoGoRepackInput
): Promise<ExpoGoProjectConfiguration> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ExpoGoRepackConfigurationQuery, ExpoGoRepackConfigurationQueryVariables>(
.query<
{ expoGoBuild: { repackConfiguration: ExpoGoProjectConfiguration } },
{ input: ExpoGoRepackInput }
>(
/* eslint-disable graphql/template-strings */
gql`
query ExpoGoRepackConfiguration($input: ExpoGoRepackInput!) {
expoGoBuild {
@@ -69,6 +43,7 @@ export const WorkflowRunQuery = {
}
}
`,
/* eslint-enable graphql/template-strings */
{ input },
{ requestPolicy: 'network-only' }
)
@@ -1,178 +0,0 @@
import { CombinedError } from '@urql/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../generated';
import { EmbeddedUpdateQuery, isEmbeddedUpdateNotFoundError } from '../EmbeddedUpdateQuery';
function makeGraphqlClient(data: unknown): ExpoGraphqlClient {
return {
query: jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
}),
} as unknown as ExpoGraphqlClient;
}
function makeCombinedError(errorCode: string): CombinedError {
return new CombinedError({
graphQLErrors: [{ message: 'error', extensions: { errorCode } }],
});
}
describe('isEmbeddedUpdateNotFoundError', () => {
it('returns true for CombinedError with EMBEDDED_UPDATE_NOT_FOUND errorCode', () => {
expect(isEmbeddedUpdateNotFoundError(makeCombinedError('EMBEDDED_UPDATE_NOT_FOUND'))).toBe(
true
);
});
it('returns false for CombinedError with a different errorCode', () => {
expect(isEmbeddedUpdateNotFoundError(makeCombinedError('SOME_OTHER_ERROR'))).toBe(false);
});
it('returns false for CombinedError with no extensions', () => {
expect(
isEmbeddedUpdateNotFoundError(new CombinedError({ graphQLErrors: [{ message: 'oops' }] }))
).toBe(false);
});
it('returns false for a plain Error', () => {
expect(isEmbeddedUpdateNotFoundError(new Error('plain error'))).toBe(false);
});
it('returns false for null and non-error values', () => {
expect(isEmbeddedUpdateNotFoundError(null)).toBe(false);
expect(isEmbeddedUpdateNotFoundError('string')).toBe(false);
expect(isEmbeddedUpdateNotFoundError(42)).toBe(false);
});
});
describe('EmbeddedUpdateQuery.viewByIdAsync', () => {
it('returns the embedded update from the GraphQL response', async () => {
const expected = {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: 768,
fileSHA256: 'abc123',
},
};
const client = makeGraphqlClient({
embeddedUpdates: { byId: expected },
});
const result = await EmbeddedUpdateQuery.viewByIdAsync(client, {
embeddedUpdateId: 'update-1',
appId: 'app-1',
});
expect(result).toEqual(expected);
});
it('passes the embeddedUpdateId and appId to the query', async () => {
const client = makeGraphqlClient({
embeddedUpdates: {
byId: {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: null,
fileSHA256: 'abc',
},
},
},
});
await EmbeddedUpdateQuery.viewByIdAsync(client, {
embeddedUpdateId: 'update-1',
appId: 'app-1',
});
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{ embeddedUpdateId: 'update-1', appId: 'app-1' },
expect.objectContaining({ additionalTypenames: ['EmbeddedUpdate'] })
);
});
});
describe('EmbeddedUpdateQuery.viewPaginatedAsync', () => {
function makeConnection(): {
edges: { cursor: string; node: any }[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
} {
return {
edges: [
{
cursor: 'c1',
node: {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: null,
fileSHA256: 'abc',
},
},
},
],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
};
}
it('returns the connection from the GraphQL response', async () => {
const connection = makeConnection();
const client = makeGraphqlClient({
app: { byId: { id: 'app-1', embeddedUpdatesPaginated: connection } },
});
const result = await EmbeddedUpdateQuery.viewPaginatedAsync(client, {
appId: 'app-1',
first: 25,
});
expect(result).toEqual(connection);
});
it('forwards filter / first / after to the query variables', async () => {
const client = makeGraphqlClient({
app: { byId: { id: 'app-1', embeddedUpdatesPaginated: makeConnection() } },
});
await EmbeddedUpdateQuery.viewPaginatedAsync(client, {
appId: 'app-1',
first: 10,
after: 'cursor-99',
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.0.0', channel: 'production' },
});
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{
appId: 'app-1',
first: 10,
after: 'cursor-99',
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.0.0', channel: 'production' },
},
expect.objectContaining({ additionalTypenames: ['EmbeddedUpdate'] })
);
});
});
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more