Compare commits

...
14 Commits
Author SHA1 Message Date
Expo CI 890387b3b6 v20.0.0 2026-05-29 00:01:30 +00:00
Douglas Lowder b93c550d12 [eas-cli] Unhide observe commands (#3793)
# Why

Unhide the commands when ready.

# How

Remove `hidden = true` lines, clean up outdated warnings.

# Test Plan

CI should pass.
2026-05-28 16:55:18 -07:00
Mustafa Abban e648961eca [worker] Fail silently on cache archive errors in CacheManager (#3802) 2026-05-28 16:55:36 -04:00
Gabe Debes 15bbb3150d [eas-cli] Add eas update:embedded:upload command (#3720)
* [eas-cli] Add eas update:embedded:upload command

* Add tests for json output flag and mutation wrappers

* Apply prettier formatting to mutation test

* Invoke upload progress callback in test to cover no-op

* Use fs-extra so manifest read and bundle existence check can be awaited

* Drop misleading already-registered warn from upload

* Recognize EMBEDDED_UPDATE_ALREADY_EXISTS on upload and warn

* Drop already-exists CLI warn, server message is clear on its own

* Surface EMBEDDED_UPDATE_ALREADY_EXISTS as a single clean error on upload

* Flatten retry catch with early continue
2026-05-28 09:35:23 -07:00
Szymon Dziedzic 45c4cf3c32 [build-tools] Use h264 codec for serve-sim stream (#3800)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Try if h264 codec will improve stream perf for serve-sim

# How

Add `--codec h264` to the single `serve-sim` invocation in `startServeSimWithTunnelAsync` ([packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts](https://github.com/expo/eas-cli/blob/main/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts)). All three callers — `start_agent_device_remote_session`, `start_argent_remote_session`, and `start_serve_sim_remote_session` — route through this helper, so the flag applies everywhere `serve-sim` is launched.

# Test Plan

- `yarn typecheck` on `build-tools` — only the two pre-existing `refreshAdHocProvisioningProfile` errors on `main`, unrelated to this change.
- Manual verification will happen once the step runs end-to-end on a turtle worker; the change is a single CLI flag added to an existing argument list.
2026-05-28 15:13:35 +02:00
Stanisław Chmiela 0ab2a04b67 [build-tools] Support absolute artifact glob paths (#3788)
See https://app.datadoghq.com/logs?query=findArtifacts&agg_m=count&agg_m_source=base&agg_q=status%2Cservice&agg_q_source=base%2Cbase&agg_t=count&clustering_pattern_field_path=message&cols=host%2Cservice&messageDisplay=inline&refresh_mode=sliding&sort_m=%2C&sort_m_source=%2C&sort_t=%2C&storage=hot&stream_sort=desc&top_n=10%2C10&top_o=top%2Ctop&viz=pattern&x_missing=true%2Ctrue&from_ts=1779570516711&to_ts=1779743316711&live=true

---

## Summary
- applies the validated absolute-glob artifact lookup behavior directly
- keeps existing exact absolute path handling intact
- adds unit coverage for absolute glob artifact patterns

## Why
The dry-run Sentry instrumentation validated that absolute artifact glob support is the intended behavior. This PR removes the dry-run path and uses the supported lookup logic directly.

## Validation
- yarn oxfmt packages/build-tools/src/utils/artifacts.ts packages/build-tools/src/utils/__tests__/artifacts.test.ts
- yarn --cwd packages/build-tools test --runTestsByPath src/utils/__tests__/artifacts.test.ts
2026-05-28 14:32:31 +02:00
Stanisław Chmiela 544739a613 [build-tools] Respect EXPO precompiled opt-out for iOS pods (#3799)
### Motivation
- Ensure `EXPO_USE_PRECOMPILED_MODULES=0` in the build environment always wins and is not overwritten by `EAS_USE_PRECOMPILED_MODULES=1` to avoid surprising behavior for customers disabling precompiled modules. 
- Make the precedence explicit and observable via logs so maintainers can reason about the opt-out path.

### Description
- Added an early guard in `resolvePrecompiledModulesPodInstallEnvAsync` to return early when `ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0'` and log the opt-out message. (`packages/build-tools/src/ios/pod.ts`).
- Logged the presence of `EXPO_USE_PRECOMPILED_MODULES=0` with a clear message to explain why precompiled env vars are not injected. (`packages/build-tools/src/ios/pod.ts`).
- Extended the test helper to allow passing `EXPO_USE_PRECOMPILED_MODULES` via the build context environment and added a regression test that verifies `EXPO_USE_PRECOMPILED_MODULES=0` is preserved and that no base URL is set when both flags are present. (`packages/build-tools/src/ios/__tests__/pod.test.ts`).

### Testing
- Ran formatter: `npx oxfmt packages/build-tools/src/ios/pod.ts packages/build-tools/src/ios/__tests__/pod.test.ts` which completed successfully. 
- Ran the unit test file: `cd packages/build-tools && yarn test src/ios/__tests__/pod.test.ts` which failed in this environment due to missing workspace module resolution for `@expo/eas-build-job` and `@expo/turtle-spawn` (test runner error, not a behavior regression).

------
[Codex Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a180a1026848328b76bf4240a5872b1)
2026-05-28 11:50:29 +02:00
Szymon DziedzicandClaude Opus 4.7 86a891b377 [build-tools] Use ngrok tunnels for remote device run sessions (#3796)
* [build-tools] Use ngrok tunnels for remote device run sessions

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* [build-tools] Use in-process @ngrok/ngrok SDK for our tunnels

Replace the spawned ngrok CLI (binary install + stdout URL parsing +
web_addr workaround) with the in-process @ngrok/ngrok SDK for the
agent-device and argent tunnels. The ngrok CLI is still installed for
serve-sim, which shells out to it for --tunnel-provider ngrok.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* [build-tools] Drop ngrok CLI install; serve-sim uses the SDK

serve-sim now embeds the @ngrok/ngrok SDK for --tunnel-provider ngrok, so
the ngrok agent no longer needs to be on PATH. No tunnel binary is
installed anywhere now: our own tunnels and serve-sim both run the agent
in-process. Removes ensureNgrokCliInstalledAsync and its callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* [build-tools] Address review feedback

Simplify the "env var not set" SystemError messages (drop "created by the
API server" / the domain example), reword the fail-fast comments, and make
matchLabeledUrl take an options object.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 15:18:36 +02:00
Gabe Debes d51ca46f9c [build-tools] Fix iOS Simulator readiness hang on Xcode 26.4 (#3794)
* [build-tools] Fix iOS Simulator readiness hang on Xcode 26.4

* [build-tools] Add changelog entry

* [build-tools] Use os.tmpdir() and node:fs for readiness screenshot

* [build-tools] Add unit test for Simulator readiness screenshot
2026-05-27 00:23:47 -07:00
Gabe DebesandStanisław Chmiela cfc2c76ae4 [eas-cli] eas go: prompt for SDK version interactively (#3768)
* [eas-cli] eas go: use SDK version from current project when available

* [eas-cli] eas go: prompt for SDK version interactively

* Regenerate GQL types for ExpoGoBuild supportedSdkVersions

* Update packages/eas-cli/src/commands/go.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* Address review feedback on selectSdkVersionAsync

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-05-26 15:20:27 -07:00
Kadi Kraman 489f38a6e5 [eas-cli] improve naming for observe commands (#3778)
* Rename /metrics -> /metrics-summary

* Rename events -> metrics

* Rename logs -> events

* Add changelog

* Add update download to metric names

* Fix warnings

* Add changelog PR number

* Format

* Rename metric-summary -> metrics-summary
2026-05-26 11:23:20 +01:00
Ash Wu 5f26518feb [build-tools] handle custom Gymfile err in xcactivitylog (#3790)
Signed-off-by: Ash Wu <[email protected]>
2026-05-26 14:09:32 +08:00
Stanisław Chmiela 2fc5f203b6 [build-tools] Log artifact dry-run results to Datadog (#3791)
## Summary
- adds `Datadog.log` for posting build-scoped log events to Datadog
- moves the Gradle cache restore log emission onto the shared Datadog helper
- replaces artifact dry-run Sentry reporting with Datadog logs for match, mismatch, and error outcomes

## Why
The absolute artifact glob validation should show both positive and negative dry-run outcomes. Sentry only surfaced mismatches/errors, while Datadog logs let us count successful matches as well.

## Validation

CI should pass. Going to review Datadog logs post-merge.
2026-05-25 18:01:39 +02:00
Expo CI 4a18363061 update CHANGELOG.md 2026-05-25 13:46:03 +00:00
62 changed files with 3167 additions and 1487 deletions

No files matched your search

+15 -4
View File
@@ -6,16 +6,27 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 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))
### 🧹 Chores
## [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))
### 🐛 Bug fixes
### 🧹 Chores
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "19.1.0",
"version": "20.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "19.1.0",
"version": "20.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -37,19 +37,20 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.1.0",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "19.0.0",
"@expo/logger": "20.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": "19.1.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/steps": "20.0.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.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",
@@ -61,10 +61,43 @@ describe('Datadog singleton', () => {
);
});
it('POSTs logs to the turtle build logs endpoint', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
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('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();
});
@@ -92,6 +125,29 @@ describe('Datadog singleton', () => {
);
});
it('swallows log upload failures', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
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('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
@@ -107,14 +163,24 @@ describe('Datadog singleton', () => {
});
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).toHaveBeenCalledWith(
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
'https://api.expo.test/v2/turtle-builds/logs',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
});
it('flushes pending metric uploads', async () => {
@@ -143,6 +209,33 @@ 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/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
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);
});
});
async function flushPromises(): Promise<void> {
+4 -1
View File
@@ -181,13 +181,16 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
}
try {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await parseAndReportXcactivitylog({
const { skipped } = 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();
+33 -3
View File
@@ -8,7 +8,7 @@ type DatadogSetupOptions = {
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingMetricUploads: Promise<unknown>[] = [];
let pendingUploads: Promise<unknown>[] = [];
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
@@ -45,10 +45,40 @@ export const Datadog = {
});
});
pendingMetricUploads.push(uploadPromise);
pendingUploads.push(uploadPromise);
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const log = {
buildId: turtleBuildId,
message,
tags,
};
const uploadPromise = turtleFetch(
new URL('turtle-builds/logs', expoApiV2BaseUrl).toString(),
'POST',
{
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
).catch(err => {
Sentry.capture('Failed to report turtle build log', err, {
extras: { log },
});
});
pendingUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
await Promise.allSettled(pendingMetricUploads);
await Promise.allSettled(pendingUploads);
},
};
@@ -26,10 +26,12 @@ 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')]: '',
@@ -57,6 +59,9 @@ 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(),
@@ -176,4 +181,23 @@ 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();
});
});
+5
View File
@@ -46,6 +46,11 @@ 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 {};
}
@@ -19,6 +19,7 @@ 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';
@@ -282,22 +283,10 @@ export async function restoreGradleCacheAsync({
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
);
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 {}
Datadog.log(`Gradle cache restored (${hitType})`, {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
});
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
@@ -13,13 +13,14 @@ import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import {
ensureCloudflaredInstalledAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
waitForMatchInOutputAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
@@ -44,10 +45,13 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// 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.
// 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 { runtimePlatform } = global;
@@ -60,13 +64,6 @@ 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}.`
@@ -98,19 +95,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
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',
const agentDeviceRemoteSessionUrl = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
@@ -119,6 +109,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -13,13 +13,14 @@ import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
ensureCloudflaredInstalledAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
waitForMatchInOutputAsync,
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
@@ -45,10 +46,13 @@ export function createStartArgentRemoteSessionBuildFunction(
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// 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.
// 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';
@@ -62,13 +66,6 @@ export function createStartArgentRemoteSessionBuildFunction(
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
logger.info('Ensuring cloudflared is installed.');
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
});
// Stale state from a previous run would mask the new server's port.
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
@@ -92,19 +89,12 @@ export function createStartArgentRemoteSessionBuildFunction(
});
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
logger.info(`Starting cloudflared tunnel to http://localhost:${toolServerPort}.`);
const cloudflared = spawnDetached({
command: cloudflaredCommand,
args: ['tunnel', '--url', `http://localhost:${toolServerPort}`],
env,
});
logger.info('Waiting for a public tunnel URL.');
const toolsUrl = await waitForMatchInOutputAsync({
process: cloudflared,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
const toolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
logger.info(`Tunnel is ready at ${toolsUrl}.`);
@@ -112,6 +102,7 @@ export function createStartArgentRemoteSessionBuildFunction(
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -4,7 +4,7 @@ import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
ensureBrewPackageInstalledAsync,
getNgrokTunnelDomainOrThrow,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
@@ -23,16 +23,15 @@ 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,
@@ -200,13 +200,21 @@ 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 {
@@ -234,13 +242,14 @@ describe('parseAndReportXcactivitylog', () => {
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
const result = 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,
@@ -378,7 +387,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
@@ -409,7 +418,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
@@ -437,7 +446,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
@@ -486,6 +495,108 @@ describe('parseAndReportXcactivitylog', () => {
);
});
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')
);
});
it('reports phase "downloading_xclogparser" when the direct download fails', async () => {
const logger = createMockLogger();
mockFilesystem();
@@ -501,7 +612,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
@@ -530,7 +641,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: false });
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
@@ -16,9 +16,9 @@ const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* 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.
* 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.
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
@@ -34,10 +34,38 @@ export async function parseAndReportXcactivitylog({
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<void> {
}): Promise<{ skipped: boolean }> {
let tempDir: string | undefined;
let phase = 'creating_temp_directory';
let phase = 'checking_xcactivitylog_existence';
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';
@@ -79,6 +107,7 @@ 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}"`;
@@ -91,6 +120,7 @@ export async function parseAndReportXcactivitylog({
stdout: err?.stdout?.slice(-4000),
},
});
return { skipped: true };
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
@@ -1,17 +1,15 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
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 os from 'node:os';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
@@ -31,13 +29,37 @@ 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 created by the API server, ' +
'This step must run as part of a device run session ' +
'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,
@@ -62,22 +84,6 @@ 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;
};
@@ -115,10 +121,12 @@ export function spawnDetached({
}
export async function startServeSimWithTunnelAsync({
baseDomain,
env,
logger,
timeoutMs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
@@ -129,12 +137,16 @@ export async function startServeSimWithTunnelAsync({
args: [
'serve-sim-szdziedzic@latest',
'--tunnel',
'--tunnel-protocol',
'quic',
'--tunnel-provider',
'ngrok',
'--tunnel-domain',
baseDomain,
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
'--codec',
'h264',
],
env,
});
@@ -143,8 +155,8 @@ export async function startServeSimWithTunnelAsync({
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = serveSim.getOutput();
const previewUrl = matchLabeledUrl(output, 'Tunnel');
const streamUrl = matchLabeledUrl(output, 'Stream');
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
if (previewUrl && streamUrl) {
return { previewUrl, streamUrl };
}
@@ -155,90 +167,49 @@ export async function startServeSimWithTunnelAsync({
);
}
function matchLabeledUrl(content: string, label: string): string | null {
const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
const match = labelPattern.exec(content);
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);
return match ? match[1] : null;
}
export async function ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
baseDomain,
authtoken,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
port: number;
subdomainPrefix: string;
baseDomain: string;
authtoken: string;
logger: bunyan;
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
return 'cloudflared';
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.`);
}
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;
return url;
}
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;
}
}
export 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>'}`
);
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function waitForFileAsync<T>({
@@ -122,9 +122,10 @@ 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', '/dev/null'], {
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
env,
});
},
@@ -136,6 +137,7 @@ 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
@@ -0,0 +1,44 @@
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 }
);
});
});
});
@@ -1,20 +1,13 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import { Sentry } from '../../sentry';
import { findArtifacts } from '../artifacts';
jest.mock('fs');
jest.mock('../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe(findArtifacts, () => {
beforeEach(async () => {
vol.reset();
jest.clearAllMocks();
});
test('with correct path', async () => {
@@ -76,7 +69,6 @@ describe(findArtifacts, () => {
});
expect(loggerMock.error).toHaveBeenCalledTimes(0);
expect(paths.length).toBe(2);
expect(Sentry.capture).not.toHaveBeenCalled();
});
test('with absolute glob pattern', async () => {
@@ -87,33 +79,16 @@ describe(findArtifacts, () => {
info: jest.fn(),
error: jest.fn(),
};
await expect(
findArtifacts({
rootDir: '/Users/expo/build',
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
logger: loggerMock as any,
})
).rejects.toThrow(
'There are no files matching pattern "/tmp/maestro_xctestrunner_xcodebuild_output*"'
);
expect(loggerMock.error).toHaveBeenCalledTimes(0);
expect(Sentry.capture).toHaveBeenCalledWith(expect.any(Error), {
tags: {
source: 'find-artifacts',
reason: 'absolute_path',
},
extras: {
rootDir: '/Users/expo/build',
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
currentCount: 0,
dryRunCount: 2,
currentSample: [],
dryRunSample: [
'/tmp/maestro_xctestrunner_xcodebuild_output123',
'/tmp/maestro_xctestrunner_xcodebuild_output456',
],
},
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 () => {
+6 -83
View File
@@ -6,7 +6,6 @@ import path from 'path';
import promiseLimit from 'promise-limit';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
export class FindArtifactsError extends Error {}
@@ -20,16 +19,12 @@ export async function findArtifacts({
/** If provided, will log error suggesting possible files to upload. */
logger: bunyan | null;
}): Promise<string[]> {
const files = path.isAbsolute(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
await maybeReportAbsoluteGlobDryRunMismatchAsync({
rootDir,
patternOrPath,
files,
});
const files =
path.isAbsolute(patternOrPath) && !fg.isDynamicPattern(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}"`);
@@ -54,78 +49,6 @@ export async function findArtifacts({
});
}
async function findArtifactsWithAbsoluteGlobSupportDryRunAsync(
rootDir: string,
patternOrPath: string
): Promise<string[]> {
return path.isAbsolute(patternOrPath) && !fg.isDynamicPattern(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
}
async function maybeReportAbsoluteGlobDryRunMismatchAsync({
rootDir,
patternOrPath,
files,
}: {
rootDir: string;
patternOrPath: string;
files: string[];
}): Promise<void> {
if (!path.isAbsolute(patternOrPath)) {
return;
}
try {
const filesWithAbsoluteGlobSupport = await findArtifactsWithAbsoluteGlobSupportDryRunAsync(
rootDir,
patternOrPath
);
if (areArtifactListsEqual(files, filesWithAbsoluteGlobSupport)) {
return;
}
Sentry.capture(new Error('findArtifacts output changed for an absolute path'), {
tags: {
source: 'find-artifacts',
reason: 'absolute_path',
},
extras: {
rootDir,
patternOrPath,
currentCount: files.length,
dryRunCount: filesWithAbsoluteGlobSupport.length,
currentSample: files.slice(0, 20),
dryRunSample: filesWithAbsoluteGlobSupport.slice(0, 20),
},
});
} catch (err: any) {
Sentry.capture(err, {
tags: {
source: 'find-artifacts',
reason: 'absolute_path_dry_run_failed',
},
extras: {
rootDir,
patternOrPath,
currentCount: files.length,
currentSample: files.slice(0, 20),
},
});
}
}
function areArtifactListsEqual(first: string[], second: string[]): boolean {
if (first.length !== second.length) {
return false;
}
const sortedFirst = [...first].sort();
const sortedSecond = [...second].sort();
return sortedFirst.every((artifactPath, index) => artifactPath === sortedSecond[index]);
}
async function logMissingFileError(artifactPath: string, buildLogger: bunyan): Promise<void> {
let currentPath = artifactPath;
while (!(await fs.pathExists(currentPath))) {
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "19.1.0",
"version": "20.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": "19.1.0"
"@expo/steps": "20.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "19.0.0",
"version": "20.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": "19.0.0",
"version": "20.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": "19.1.0",
"version": "20.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": "19.0.0",
"@expo/logger": "20.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/turtle-spawn": "20.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+301 -100
View File
File diff suppressed because it is too large. Load diff
+160 -1
View File
@@ -32918,6 +32918,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "maxRunTimeMinutes",
"description": "Override for the underlying turtle job run's max run time, in minutes. Must\nbe non-negative and smaller than 120 (2 hours). Only customizable on paid\nplans. If omitted, the default is derived based on the job run's priority.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "packageVersion",
"description": "The version of the package backing the device run session (e.g. \"0.1.3-alpha.3\").\nIf omitted, consumers treat the session as pinned to \"latest\".",
@@ -43278,7 +43290,7 @@
"fields": [
{
"name": "uploadEmbeddedUpdate",
"description": "Register an embedded bundle as the launch asset for a given app/platform/channel.\nReturns EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE if the asset has not been finalized yet.",
"description": "Register an embedded bundle as the launch asset for a given app/platform/channel.\nReturns EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE if the asset has not been finalized yet,\nor EMBEDDED_UPDATE_ALREADY_EXISTS if an embedded update with this id is already registered.",
"args": [
{
"name": "input",
@@ -45372,6 +45384,54 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "supportedSdkVersionKeys",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "supportedSdkVersions",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ExpoGoSdkVersion",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -45556,6 +45616,81 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ExpoGoSdkVersion",
"description": null,
"fields": [
{
"name": "isBeta",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isDeprecated",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isLatest",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sdkVersion",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "UNION",
"name": "FcmSnippet",
@@ -52830,6 +52965,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "maxRunTimeSeconds",
"description": "Max run time in seconds for this job run.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": null,
@@ -52937,6 +53084,18 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "workflowJob",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "WorkflowJob",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "19.1.0",
"version": "20.0.0",
"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": "19.1.0",
"@expo/eas-json": "19.1.0",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-json": "20.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "19.0.0",
"@expo/logger": "20.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": "19.1.0",
"@expo/steps": "20.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -6,6 +6,7 @@ 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';
@@ -50,6 +51,7 @@ 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);
@@ -146,4 +148,50 @@ describe('Go command', () => {
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();
});
});
+36 -2
View File
@@ -26,7 +26,7 @@ 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 } from '../prompts';
import { confirmAsync, selectAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
@@ -210,7 +210,10 @@ export default class Go extends EasCommand {
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
);
}
const sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
let sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
if (!sdkVersion) {
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
}
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
@@ -301,6 +304,37 @@ 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
@@ -1,31 +1,51 @@
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 {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
buildObserveCustomEventNamesJson,
buildObserveCustomEventsEmptyWithSuggestionsJson,
buildObserveCustomEventsEmptyWithSuggestionsTable,
buildObserveCustomEventsJson,
} from '../../../observe/formatCustomEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvents from '../events';
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('../../../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 mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
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);
@@ -36,10 +56,11 @@ describe(ObserveEvents, () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
});
function createCommand(argv: string[]): ObserveEvents {
@@ -52,38 +73,80 @@ describe(ObserveEvents, () => {
return command;
}
it('uses --days to compute start/end time range', async () => {
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(['tti', '--days', '7']);
const command = createCommand(['--days', '7', '--platform', 'ios']);
await command.runAsync();
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
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 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([
'tti',
'my_event',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
@@ -91,109 +154,86 @@ describe(ObserveEvents, () => {
]);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
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('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(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
const command = createCommand([
'my_event',
'--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']);
it('passes --limit to fetchObserveCustomEventsAsync', async () => {
const command = createCommand(['my_event', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--after', 'cursor-xyz']);
it('passes --after cursor', async () => {
const command = createCommand(['my_event', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
const options = mockFetchObserveCustomEventsAsync.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']);
it('passes --platform ios as AppObservePlatform.Ios', async () => {
const command = createCommand(['my_event', '--platform', 'ios']);
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']);
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];
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
const command = createCommand(['tti', '--platform', 'android']);
it('passes --app-version', async () => {
const command = createCommand(['my_event', '--app-version', '2.1.0']);
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];
const options = mockFetchObserveCustomEventsAsync.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']);
it('passes --update-id', async () => {
const command = createCommand(['my_event', '--update-id', 'update-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
const options = mockFetchObserveCustomEventsAsync.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']);
it('passes --session-id', async () => {
const command = createCommand(['my_event', '--session-id', 'session-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
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', async () => {
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => {
const mockEvents = [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
eventName: 'my_event',
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -203,78 +243,139 @@ describe(ObserveEvents, () => {
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
properties: [],
},
];
mockFetchObserveEventsAsync.mockResolvedValue({
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['tti', '--json', '--non-interactive']);
const command = createCommand(['my_event', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
);
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('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,
});
});
it('throws in non-interactive mode when no metric is provided', async () => {
const command = createCommand(['--non-interactive']);
const command = createCommand(['my_event']);
await command.runAsync();
await expect(command.runAsync()).rejects.toThrow(
'metric argument is required in non-interactive mode'
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).toHaveBeenCalledWith(
'my_event',
mockNames,
expect.objectContaining({ isTruncated: false })
);
});
});
describe(resolveOrderBy, () => {
it('resolves lowercase "slowest" to MetricValue DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
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('resolves lowercase "fastest" to MetricValue ASC', () => {
expect(resolveOrderBy('fastest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
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('resolves lowercase "newest" to Timestamp DESC', () => {
expect(resolveOrderBy('newest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
});
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('resolves lowercase "oldest" to Timestamp ASC', () => {
expect(resolveOrderBy('oldest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
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,381 +0,0 @@
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();
});
});
@@ -0,0 +1,228 @@
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,29 +1,31 @@
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 {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetrics from '../metrics';
jest.mock('../../../observe/fetchMetrics', () => {
const actual = jest.requireActual('../../../observe/fetchMetrics');
jest.mock('../../../observe/fetchEvents', () => {
const actual = jest.requireActual('../../../observe/fetchEvents');
return {
...actual,
fetchObserveMetricsAsync: jest.fn(),
fetchObserveEventsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatMetrics', () => ({
...jest.requireActual('../../../observe/formatMetrics'),
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
buildObserveEventsJson: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
@@ -34,17 +36,15 @@ describe(ObserveMetrics, () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveMetricsAsync.mockResolvedValue({
metricsMap: new Map(),
buildNumbersMap: new Map(),
updateIdsMap: new Map(),
totalEventCounts: new Map(),
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
function createCommand(argv: string[]): ObserveMetrics {
const command = new ObserveMetrics(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
@@ -52,61 +52,38 @@ describe(ObserveMetrics, () => {
return command;
}
it('fetches metrics with default parameters (both platforms)', 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([]);
const command = createCommand(['tti', '--days', '7']);
await command.runAsync();
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
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('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 () => {
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([]);
const command = createCommand(['tti']);
await command.runAsync();
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');
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([
'tti',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
@@ -114,111 +91,190 @@ describe(ObserveMetrics, () => {
]);
await command.runAsync();
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');
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('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 () => {
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(['--days', '7']);
const command = createCommand(['tti', '--start', '2025-01-01T00:00:00.000Z']);
await command.runAsync();
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');
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(['--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 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(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
const command = createCommand(['tti', '--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([]);
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
const command = createCommand(['tti', '--platform', 'ios']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median', 'eventCount'],
expect.objectContaining({ daysBack: 60 })
);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
const command = createCommand([
'--json',
'--non-interactive',
'--stat',
'min',
'--stat',
'average',
]);
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']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['min', 'average'],
expect.any(Map),
expect.any(Map),
expect.any(Map)
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
);
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('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'
);
});
});
it('throws on invalid --end date', () => {
expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"');
describe(resolveOrderBy, () => {
it('resolves lowercase "slowest" to MetricValue DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('accepts valid ISO date in --start', () => {
expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow();
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,
});
});
});
+89 -63
View File
@@ -1,16 +1,11 @@
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 {
EventsOrderPreset,
fetchObserveEventsAsync,
fetchTotalEventCountAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
@@ -19,35 +14,33 @@ import {
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 {
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 { selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
export default class ObserveEvents extends EasCommand {
static override hidden = true;
static override description = 'display individual app performance events ordered by metric value';
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 args = {
metric: Args.string({
description: 'Metric to query (e.g. tti, cold_launch)',
eventName: Args.string({
description: 'Event name to filter by',
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({
@@ -57,6 +50,14 @@ 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,
};
@@ -73,6 +74,12 @@ 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,
@@ -83,65 +90,84 @@ 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);
const [{ events, pageInfo }, totalEventCount] = await Promise.all([
fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
if (!args.eventName && !flags['all-events']) {
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
}),
fetchTotalEventCountAsync(
graphqlClient,
projectId,
metricName,
platforms,
});
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
),
]);
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(buildObserveEventsJson(events, pageInfo));
printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveEventsTable(events, pageInfo, {
metricName,
buildObserveCustomEventsTable(events, pageInfo, {
eventName: args.eventName,
daysBack,
startTime,
endTime,
totalEventCount,
})
);
}
@@ -1,178 +0,0 @@
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,
})
);
}
}
}
@@ -0,0 +1,135 @@
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,63 +1,61 @@
import { Flags } from '@oclif/core';
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 { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
EventsOrderPreset,
fetchObserveEventsAsync,
fetchTotalEventCountAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import {
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
resolveStatKey,
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { appPlatformsFromFlag } 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_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',
];
const DEFAULT_EVENTS_LIMIT = 10;
export default class ObserveMetrics extends EasCommand {
static override hidden = true;
static override description = 'display app performance metrics grouped by app version';
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 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,
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,
})(),
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_EVENTS_LIMIT,
limit: 100,
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveUpdateIdFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -72,7 +70,7 @@ export default class ObserveMetrics extends EasCommand {
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetrics);
const { flags, args } = await this.parse(ObserveMetrics);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
@@ -84,52 +82,63 @@ export default class ObserveMetrics extends EasCommand {
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
const metricNames = flags.metric?.length
? flags.metric.map(resolveMetricName)
: DEFAULT_METRICS;
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);
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
await fetchObserveMetricsAsync(
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(
graphqlClient,
projectId,
metricNames,
metricName,
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
)
);
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
Log.addNewLineIfNone();
Log.log(
buildObserveMetricsTable(metricsMap, metricNames, stats, {
buildObserveEventsTable(events, pageInfo, {
metricName,
daysBack,
buildNumbersMap,
totalEventCounts,
startTime,
endTime,
totalEventCount,
})
);
}
@@ -34,7 +34,6 @@ 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';
@@ -93,8 +92,6 @@ 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,7 +14,6 @@ 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 = {
@@ -46,8 +45,6 @@ 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);
@@ -0,0 +1,337 @@
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);
});
});
});
@@ -0,0 +1,177 @@
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,16 +1,46 @@
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,6 +16,7 @@ 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)
)
) {
+42 -1
View File
@@ -4679,6 +4679,12 @@ 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".
@@ -6174,7 +6180,8 @@ export type EmbeddedUpdateMutation = {
__typename?: 'EmbeddedUpdateMutation';
/**
* 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.
* 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.
*/
uploadEmbeddedUpdate: EmbeddedUpdate;
};
@@ -6470,6 +6477,8 @@ export type ExperimentationQuery = {
export type ExpoGoBuildQuery = {
__typename?: 'ExpoGoBuildQuery';
repackConfiguration: ExpoGoProjectConfiguration;
supportedSdkVersionKeys: Array<Scalars['String']['output']>;
supportedSdkVersions: Array<ExpoGoSdkVersion>;
};
@@ -6497,6 +6506,14 @@ 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 = {
@@ -7458,6 +7475,8 @@ 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?: Maybe<Scalars['Int']['output']>;
name: Scalars['String']['output'];
priority: JobRunPriority;
/** String describing the worker profile used to run this job run. */
@@ -7465,6 +7484,7 @@ export type JobRun = {
startedAt?: Maybe<Scalars['DateTime']['output']>;
status: JobRunStatus;
updateGroups: Array<Array<Update>>;
workflowJob?: Maybe<WorkflowJob>;
};
export type JobRunError = {
@@ -12494,6 +12514,22 @@ 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 CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
accountId: Scalars['String']['input'];
@@ -13247,6 +13283,11 @@ 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;
}>;
@@ -0,0 +1,56 @@
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;
},
};
@@ -0,0 +1,64 @@
import { CombinedError } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
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;
},
};
@@ -0,0 +1,33 @@
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);
});
});
@@ -0,0 +1,99 @@
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);
});
});
@@ -6,7 +6,12 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { withErrorHandlingAsync } from '../client';
import {
ExpoGoProjectConfiguration,
ExpoGoRepackConfigurationQuery,
ExpoGoRepackConfigurationQueryVariables,
ExpoGoRepackInput,
ExpoGoSdkVersion,
ExpoGoSupportedSdkVersionsQuery,
ExpoGoSupportedSdkVersionsQueryVariables,
WorkflowRunByIdQuery,
WorkflowRunByIdQueryVariables,
WorkflowRunByIdWithJobsQuery,
@@ -19,17 +24,38 @@ 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<
{ expoGoBuild: { repackConfiguration: ExpoGoProjectConfiguration } },
{ input: ExpoGoRepackInput }
>(
/* eslint-disable graphql/template-strings */
.query<ExpoGoRepackConfigurationQuery, ExpoGoRepackConfigurationQueryVariables>(
gql`
query ExpoGoRepackConfiguration($input: ExpoGoRepackInput!) {
expoGoBuild {
@@ -43,7 +69,6 @@ export const WorkflowRunQuery = {
}
}
`,
/* eslint-enable graphql/template-strings */
{ input },
{ requestPolicy: 'network-only' }
)
@@ -69,7 +69,7 @@ Jan 14, 2025, 08:15:00.000 AM checkout 1.1.0 (38) Android 14 Pixel 8 PL
it('returns yellow warning for empty events', () => {
const output = buildObserveCustomEventsTable([], noNextPage);
expect(output).toContain('No custom events found.');
expect(output).toContain('No events found.');
});
it('shows event name in summary header when an event name option is provided', () => {
@@ -82,13 +82,13 @@ Jan 14, 2025, 08:15:00.000 AM checkout 1.1.0 (38) Android 14 Pixel 8 PL
expect(output).toContain('login events for the last 30 days');
});
it('shows the generic "Custom events" subject when no event name option is provided', () => {
it('shows the generic "Events" subject when no event name option is provided', () => {
const events = [makeCustomEvent()];
const output = buildObserveCustomEventsTable(events, noNextPage, {
daysBack: 30,
});
expect(output).toContain('Custom events for the last 30 days');
expect(output).toContain('Events for the last 30 days');
});
it('shows date range in summary header when start/end provided', () => {
@@ -294,7 +294,7 @@ describe(buildObserveCustomEventsJson, () => {
describe(buildObserveCustomEventNamesTable, () => {
it('returns yellow warning when names list is empty', () => {
const output = buildObserveCustomEventNamesTable([]);
expect(output).toContain('No custom event names found.');
expect(output).toContain('No event names found.');
});
it('shows event names with counts', () => {
@@ -336,7 +336,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable('login', []);
expect(output).toContain('No events found matching "login"');
expect(output).toContain('No custom event names found in this time range.');
expect(output).toContain('No event names found in this time range.');
});
it('appends a truncation notice when isTruncated is set', () => {
@@ -25,6 +25,10 @@ describe(resolveMetricName, () => {
expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time');
});
it('resolves short alias "update_download" to full metric name', () => {
expect(resolveMetricName('update_download')).toBe('expo.updates.download_time');
});
it('passes through full metric names unchanged', () => {
expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti');
expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe(
@@ -73,6 +77,7 @@ describe(getMetricDisplayName, () => {
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('Startup TTI');
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('Startup TTR');
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
expect(getMetricDisplayName('expo.updates.download_time')).toBe('Update Download');
});
it('returns short display name for known navigation metrics', () => {
@@ -54,7 +54,7 @@ export function buildObserveCustomEventsTable(
options?: BuildCustomEventsTableOptions
): string {
if (events.length === 0) {
return chalk.yellow('No custom events found.');
return chalk.yellow('No events found.');
}
const showEventName = !options?.eventName;
@@ -88,7 +88,7 @@ export function buildObserveCustomEventsTable(
options.totalEventCount != null
? ` — ${options.totalEventCount.toLocaleString()} total events`
: '';
const subject = options.eventName ? `${options.eventName} events` : 'Custom events';
const subject = options.eventName ? `${options.eventName} events` : 'Events';
lines.push(chalk.bold(`${subject} ${timeDesc}${totalDesc}`.trim()), '');
}
@@ -156,7 +156,7 @@ export function buildObserveCustomEventsEmptyWithSuggestionsTable(
lines.push(chalk.yellow(`No events found matching "${eventName}" ${timeDesc}.`.trim()));
if (names.length === 0) {
lines.push('', chalk.yellow('No custom event names found in this time range.'));
lines.push('', chalk.yellow('No event names found in this time range.'));
return lines.join('\n');
}
@@ -203,7 +203,7 @@ export function buildObserveCustomEventNamesTable(
options?: BuildCustomEventNamesTableOptions
): string {
if (names.length === 0) {
return chalk.yellow('No custom event names found.');
return chalk.yellow('No event names found.');
}
const headers = ['Event Name', 'Count'];
@@ -213,7 +213,7 @@ export function buildObserveCustomEventNamesTable(
if (options) {
const timeDesc = buildTimeRangeDescription(options);
const subject = 'Custom event names';
const subject = 'Event names';
lines.push(chalk.bold(`${subject} ${timeDesc}`.trim()), '');
}
@@ -6,6 +6,7 @@ export const METRIC_ALIASES: Record<string, string> = {
cold_launch: 'expo.app_startup.cold_launch_time',
warm_launch: 'expo.app_startup.warm_launch_time',
bundle_load: 'expo.app_startup.bundle_load_time',
update_download: 'expo.updates.download_time',
};
export const NAVIGATION_METRIC_ALIASES: Record<string, string> = {
@@ -23,6 +24,7 @@ export const METRIC_SHORT_NAMES: Record<string, string> = {
'expo.app_startup.tti': 'Startup TTI',
'expo.app_startup.ttr': 'Startup TTR',
'expo.app_startup.bundle_load_time': 'Bundle Load',
'expo.updates.download_time': 'Update Download',
'expo.navigation.cold_ttr': 'Nav Cold TTR',
'expo.navigation.warm_ttr': 'Nav Warm TTR',
'expo.navigation.tti': 'Nav TTI',
@@ -0,0 +1,35 @@
import fs from 'fs-extra';
import * as uuid from 'uuid';
export async function readEmbeddedManifestAsync(manifestPath: string): Promise<{ id: string }> {
let parsed: unknown;
try {
parsed = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
} catch {
throw new Error(
`Could not read or parse manifest at "${manifestPath}". ` +
`Check the file exists, is readable, and contains valid JSON.`
);
}
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as Record<string, unknown>).id !== 'string'
) {
throw new Error(
`Manifest at "${manifestPath}" is missing the required "id" field. ` +
`Make sure you're pointing at the app.manifest generated by expo-updates during your native build, not a different JSON file.`
);
}
const id = (parsed as Record<string, string>).id;
if (!uuid.validate(id)) {
throw new Error(
`Manifest at "${manifestPath}" has an invalid "id" field ("${id}" is not a UUID). ` +
`Make sure you're pointing at the app.manifest generated by expo-updates during your native build.`
);
}
return { id };
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "19.1.0",
"version": "20.0.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "19.1.0",
"@expo/eas-build-job": "20.0.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "19.1.0",
"version": "20.0.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,11 +26,11 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "19.1.0",
"@expo/eas-build-job": "19.1.0",
"@expo/logger": "19.0.0",
"@expo/build-tools": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "19.0.0",
"@expo/turtle-spawn": "20.0.0",
"bunyan": "^1.8.15",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "19.0.0",
"version": "20.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "19.1.0",
"version": "20.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "19.1.0",
"@expo/logger": "19.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fs-extra": "^11.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "19.0.0",
"version": "20.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "19.0.0",
"version": "20.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "19.0.0",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "19.1.0",
"version": "20.0.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,18 +19,18 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "19.1.0",
"@expo/build-tools": "20.0.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.1.0",
"@expo/logger": "19.0.0",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/logger": "20.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "19.1.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/steps": "20.0.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
+29 -19
View File
@@ -31,15 +31,20 @@ export class GCSCacheManager implements CacheManager {
ctx.logger.info(`- ${filePath}`);
});
const archivePath = path.join(ctx.workingdir, 'cache-save.tar.gz');
await tar.create(
{
file: archivePath,
cwd: '/',
gzip: true,
preservePaths: true,
},
paths
);
try {
await tar.create(
{
file: archivePath,
cwd: '/',
gzip: true,
preservePaths: true,
},
paths
);
} catch (err: any) {
ctx.logger.error({ err }, 'Failed to create cache archive');
return;
}
const archiveSize = (await fs.stat(archivePath)).size;
const { maxSizeBytes } = this.allowedContentLengthRange;
@@ -88,16 +93,21 @@ export class GCSCacheManager implements CacheManager {
return;
}
await tar.extract(
{
file: archivePath,
cwd: '/',
preserveOwner: true,
preservePaths: true,
keep: true, // do not override existing files
},
paths
);
try {
await tar.extract(
{
file: archivePath,
cwd: '/',
preserveOwner: true,
preservePaths: true,
keep: true, // do not override existing files
},
paths
);
} catch (err: any) {
ctx.logger.error({ err }, 'Failed to extract cache archive');
this.skipCacheUpdate = true;
}
}
private getPaths(ctx: BuildContext<BuildJob>): string[] {
Generated
+176 -36
View File
@@ -2856,26 +2856,27 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:19.1.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:20.0.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/downloader": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:19.0.0"
"@expo/logger": "npm:20.0.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.2.0"
"@expo/repack-app": "npm:~0.4.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:19.1.0"
"@expo/template-file": "npm:19.0.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@expo/steps": "npm:20.0.0"
"@expo/template-file": "npm:20.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@expo/xcpretty": "npm:^4.3.1"
"@google-cloud/storage": "npm:^7.11.2"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
"@types/fs-extra": "npm:^11.0.4"
"@types/jest": "npm:^29.5.12"
@@ -3096,7 +3097,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:19.0.0, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:20.0.0, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -3112,13 +3113,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:19.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:20.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:19.0.0"
"@expo/logger": "npm:20.0.0"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
jest: "npm:^29.7.0"
@@ -3131,12 +3132,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:19.1.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:20.0.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/eas-build-job": "npm:20.0.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -3273,7 +3274,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:19.0.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:20.0.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -3484,12 +3485,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:19.1.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:20.0.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:19.1.0"
"@expo/logger": "npm:19.0.0"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/logger": "npm:20.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -3514,7 +3515,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:19.0.0, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:20.0.0, @expo/template-file@workspace:packages/template-file":
version: 0.0.0-use.local
resolution: "@expo/template-file@workspace:packages/template-file"
dependencies:
@@ -3537,11 +3538,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:19.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:20.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:19.0.0"
"@expo/logger": "npm:20.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -3557,18 +3558,18 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:19.1.0"
"@expo/build-tools": "npm:20.0.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/logger": "npm:19.0.0"
"@expo/downloader": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/logger": "npm:20.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:19.1.0"
"@expo/template-file": "npm:19.0.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@expo/steps": "npm:20.0.0"
"@expo/template-file": "npm:20.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -5505,6 +5506,145 @@ __metadata:
languageName: node
linkType: hard
"@ngrok/ngrok-android-arm64@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-android-arm64@npm:1.7.0"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@ngrok/ngrok-darwin-arm64@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-darwin-arm64@npm:1.7.0"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@ngrok/ngrok-darwin-universal@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-darwin-universal@npm:1.7.0"
conditions: os=darwin
languageName: node
linkType: hard
"@ngrok/ngrok-darwin-x64@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-darwin-x64@npm:1.7.0"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@ngrok/ngrok-freebsd-x64@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-freebsd-x64@npm:1.7.0"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@ngrok/ngrok-linux-arm-gnueabihf@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-linux-arm-gnueabihf@npm:1.7.0"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@ngrok/ngrok-linux-arm64-gnu@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-linux-arm64-gnu@npm:1.7.0"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@ngrok/ngrok-linux-arm64-musl@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-linux-arm64-musl@npm:1.7.0"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@ngrok/ngrok-linux-x64-gnu@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-linux-x64-gnu@npm:1.7.0"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@ngrok/ngrok-linux-x64-musl@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-linux-x64-musl@npm:1.7.0"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@ngrok/ngrok-win32-arm64-msvc@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-win32-arm64-msvc@npm:1.7.0"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@ngrok/ngrok-win32-ia32-msvc@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-win32-ia32-msvc@npm:1.7.0"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@ngrok/ngrok-win32-x64-msvc@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok-win32-x64-msvc@npm:1.7.0"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@ngrok/ngrok@npm:1.7.0":
version: 1.7.0
resolution: "@ngrok/ngrok@npm:1.7.0"
dependencies:
"@ngrok/ngrok-android-arm64": "npm:1.7.0"
"@ngrok/ngrok-darwin-arm64": "npm:1.7.0"
"@ngrok/ngrok-darwin-universal": "npm:1.7.0"
"@ngrok/ngrok-darwin-x64": "npm:1.7.0"
"@ngrok/ngrok-freebsd-x64": "npm:1.7.0"
"@ngrok/ngrok-linux-arm-gnueabihf": "npm:1.7.0"
"@ngrok/ngrok-linux-arm64-gnu": "npm:1.7.0"
"@ngrok/ngrok-linux-arm64-musl": "npm:1.7.0"
"@ngrok/ngrok-linux-x64-gnu": "npm:1.7.0"
"@ngrok/ngrok-linux-x64-musl": "npm:1.7.0"
"@ngrok/ngrok-win32-arm64-msvc": "npm:1.7.0"
"@ngrok/ngrok-win32-ia32-msvc": "npm:1.7.0"
"@ngrok/ngrok-win32-x64-msvc": "npm:1.7.0"
dependenciesMeta:
"@ngrok/ngrok-android-arm64":
optional: true
"@ngrok/ngrok-darwin-arm64":
optional: true
"@ngrok/ngrok-darwin-universal":
optional: true
"@ngrok/ngrok-darwin-x64":
optional: true
"@ngrok/ngrok-freebsd-x64":
optional: true
"@ngrok/ngrok-linux-arm-gnueabihf":
optional: true
"@ngrok/ngrok-linux-arm64-gnu":
optional: true
"@ngrok/ngrok-linux-arm64-musl":
optional: true
"@ngrok/ngrok-linux-x64-gnu":
optional: true
"@ngrok/ngrok-linux-x64-musl":
optional: true
"@ngrok/ngrok-win32-arm64-msvc":
optional: true
"@ngrok/ngrok-win32-ia32-msvc":
optional: true
"@ngrok/ngrok-win32-x64-msvc":
optional: true
checksum: 10c0/67ad258efd5797bb1bcbe75344d6fa930eab69ac7c6d72c4d9ed1e507c2e2953f5e856d803d23eeec22227adeb66f959982709dc0c73d3cb76630ac450980a06
languageName: node
linkType: hard
"@noble/hashes@npm:^1.1.5":
version: 1.8.0
resolution: "@noble/hashes@npm:1.8.0"
@@ -10348,7 +10488,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:19.1.0"
"@expo/steps": "npm:20.0.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10961,11 +11101,11 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:19.1.0"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/logger": "npm:19.0.0"
"@expo/build-tools": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/logger": "npm:20.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:19.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@types/bunyan": "npm:^1.8.11"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
@@ -11019,11 +11159,11 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/eas-json": "npm:19.1.0"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/eas-json": "npm:20.0.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:19.0.0"
"@expo/logger": "npm:20.0.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -11035,7 +11175,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:19.1.0"
"@expo/steps": "npm:20.0.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"