Compare commits

...
Author SHA1 Message Date
evanbacon c8d4262aa7 Delete remote previews when removed from config
Update PreviewsTask to only sync/upload preview sets when previews are configured and to delete remote previews that are no longer present in the config. The task now retrieves existing preview sets, syncs configured preview types, and iterates existing sets to remove previews whose types were removed from the config (using preview.deleteAsync with logging). Add a unit test to cover deletion of remote previews and a small typing fix in the nock mock (cast adapter to any) to satisfy TypeScript.
2026-04-17 10:36:40 -07:00
Mustafa Abban b22d89e41f [build-tools] remove experimental prefix from gradle cache env var (#3613) 2026-04-16 15:34:28 -04:00
Szymon Świerk 81fe0f237f extend the app_store_connect.build_upload interpolation context (#3599) 2026-04-16 15:50:39 +02:00
Douglas Lowder b9ddc04cf7 [eas-cli] Improve observe:metrics handling of app version update IDs (#3609)
<!-- 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

Currently the output of observe:metrics is unusable if an app version has many updates. Since the metrics are not broken out by update, it makes sense to make two changes:

- Remove update IDs from the display table
- Add the update IDs to the JSON output as an array, in case they are needed as input to other commands, or to AI

Additionally, made a change so that if a project ID is manually passed in, the observe commands do not unnecessarily try to create a new EAS project where none is needed.

# Test Plan

- Unit tests pass
- Tested manually against existing projects with metrics
2026-04-15 12:04:40 -07:00
Expo CI d6a8a4f694 update CHANGELOG.md 2026-04-14 18:51:07 +00:00
Expo CI 7f163e3e52 v18.7.0 2026-04-14 18:48:31 +00:00
Matheus Michels f821cb176c [eas-cli] Add --skip-bundler to eas build:dev (#3604)
* [eas-cli] Add skip-bundler to build:dev

* [eas-cli] Drop build:dev skip-bundler test

* [eas-cli] Tweak skip-bundler wording

* [eas-cli] Update changelog entry for skip-bundler

* [eas-cli] Drop README update for skip-bundler
2026-04-14 19:02:51 +01:00
Gabe Debes 3a70a5d9b8 [build-tools] Early stop eas/start_android_emulator when Android emulator host setup is invalid (#3580)
* [build-tools] early stop with improved error message on start android emulator when not on nested virtualization enabled host

* Add changelog entry

* Move towards cpuinfo approach

* Align var names

* fmt

* [build-tools] Use runtimePlatform for Android emulator virt checks

Align Android emulator nested-virtualization detection with step runtime metadata instead of Node process platform, and expand tests to assert early failure on non-Linux runtime platforms.

* [build-tools] Throw UserError for emulator virt precheck failure

Return a typed user-facing error when Android emulator virtualization requirements are not met so this early-stop path avoids noisy unknown-error stack output.

* [build-tools] move check towards begging of function

* Update error message

* Update CHANGELOG for recent build-tools changes

* Fix blank line on changelog

* tightening upon feedback, move to emulator accel check instead of cpuinfo

* Update CHANGELOG

* format fix

* test: restore startAndroidEmulator helper signature

Keep the startAndroidEmulator test helper aligned with main by removing the temporary runtime platform override from createStep.

* refactor build-tools emulator host support check naming

* format startAndroidEmulator test

* Update err code to align with rename

* Add docsUrl to user error

* Display inline link for now
2026-04-13 12:36:21 -07:00
Gabe Debes 4d74298bb7 [build-tools] add read app config and package.json functions (#3585)
add new eas/read_package_json and eas/read_app_config workflow functions
2026-04-10 14:58:32 -07:00
Expo CI 3155fc26e7 update CHANGELOG.md 2026-04-10 20:55:15 +00:00
Expo CI 0fb6dfc334 v18.6.0 2026-04-10 20:52:25 +00:00
Douglas Lowder dd84646768 [eas-cli] Add observe namespace and commands (#3564)
This is a merge of @Ubax 's changes from #3431 and #3432 , with refactoring to align with the recent OCLIF upgrade and some other adjustments:

- a new command `observe:versions` to query statistics for app versions after the www change in https://github.com/expo/universe/pull/26373
- changes to `observe:metrics` and `observe:events` to use the new app versions, following the www change in https://github.com/expo/universe/pull/26391
- commands are hidden for now

# Why

Provide CLI commands to display Observe metrics and events.

```
USAGE
  $ eas observe:metrics [--platform android|ios] [--metric tti|ttr|cold_launch|warm_launch|bundle_load...] [--stat
    min|median|max|average|p80|p90|p99|eventCount...] [--start <value> | --days <value>] [--end <value> | ]
    [--project-id <value>] [--json] [--non-interactive]

FLAGS
  --days=<value>        Show metrics from the last N days (mutually exclusive with --start/--end)
  --end=<value>         End of time range for metrics data (ISO date).
  --json                Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --metric=<option>...  Metric name to display (can be specified multiple times).
                        <options: tti|ttr|cold_launch|warm_launch|bundle_load>
  --non-interactive     Run the command in non-interactive mode.
  --platform=<option>   Filter by platform
                        <options: android|ios>
  --project-id=<value>  EAS project ID (defaults to the project ID of the current directory)
  --start=<value>       Start of time range for metrics data (ISO date).
  --stat=<option>...    Statistic to display per metric (can be specified multiple times)
                        <options: min|median|max|average|p80|p90|p99|eventCount>

DESCRIPTION
  display app performance metrics grouped by app version

---------------------------

USAGE
  $ eas observe:events [METRIC] [--sort slowest|fastest|newest|oldest] [--platform android|ios] [--after <value>]
    [--limit <value>] [--start <value> | --days <value>] [--end <value> | ] [--app-version <value>] [--update-id
    <value>] [--project-id <value>] [--json] [--non-interactive]

ARGUMENTS
  [METRIC]  (tti|ttr|cold_launch|warm_launch|bundle_load) Metric to query (e.g. tti, cold_launch). Defaults to tti.

FLAGS
  --after=<value>        Cursor for pagination. Use the endCursor from a previous query to fetch the next page.
  --app-version=<value>  Filter by app version
  --days=<value>         Show events from the last N days (mutually exclusive with --start/--end)
  --end=<value>          End of time range (ISO date)
  --json                 Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --limit=<value>        The number of items to fetch each query. Defaults to 10 and is capped at 100.
  --non-interactive      Run the command in non-interactive mode.
  --platform=<option>    Filter by platform
                         <options: android|ios>
  --project-id=<value>   EAS project ID (defaults to the project ID of the current directory)
  --sort=<option>        [default: oldest] Sort order for events
                         <options: slowest|fastest|newest|oldest>
  --start=<value>        Start of time range (ISO date)
  --update-id=<value>    Filter by EAS update ID

DESCRIPTION
  display individual app performance events ordered by metric value

DESCRIPTION
  display individual app performance events ordered by metric value

--------------------------------------

USAGE
  $ eas observe:versions [--platform android|ios] [--start <value> | --days <value>] [--end <value> | ] [--project-id
    <value>] [--json] [--non-interactive]

FLAGS
  --days=<value>        Show versions from the last N days (mutually exclusive with --start/--end)
  --end=<value>         End of time range (ISO date)
  --json                Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --non-interactive     Run the command in non-interactive mode.
  --platform=<option>   Filter by platform
                        <options: android|ios>
  --project-id=<value>  EAS project ID (defaults to the project ID of the current directory)
  --start=<value>       Start of time range (ISO date)

DESCRIPTION
  display app versions with build and update details
```

# How

New commands and GraphQL queries.

# Test Plan

New unit tests added in `fetchEvents-test.ts`
2026-04-10 13:41:04 -07:00
Phil Pluckthun c3fbf63a71 fix(eas-cli): Abort early on asset upload errors (#3600) 2026-04-10 19:28:36 +01:00
Szymon Dziedzic 7c0d7b60c7 [ENG-17052][build-tools] Add working_directory input to eas/build function group (#3582) 2026-04-10 11:58:56 +00:00
Szymon Dziedzic 5a8e1b4ba3 [ENG-14822][steps] Coerce env in steps to string (#3583) 2026-04-10 12:18:21 +02:00
Szymon Świerk 12b648a603 [eas-build-job] Add missing events support to app_store_connect workflows interpolation context (#3587)
* add missing events support to app store connect interpolation context

* tests
2026-04-10 10:35:03 +02:00
Evan BaconandClaude Opus 4.6 8c170af829 feat(metadata): add app clips support (#3590)
* Add App Clip metadata support

Introduce App Clip support across metadata pipeline: add JSON schema definitions for AppleAppClip and related types; extend Apple types with AppClip interfaces and action enum; add reader accessors (getAppClip*, getAppClipLocalizedInfo) and writer mutators (setAppClipDefaultExperience, setAppClipReviewDetail, setAppClipLocalizedInfo). Implement AppClipTask to prepare/download/upload App Clip default experience, localizations, header images and review invocation URLs, including helper functions for syncing and downloading header images. Wire the task into the tasks index, include AppClipData in AppleData, and add unit tests for reader, writer, and task behavior.

* Preserve placeholder assets and app clip fallback

Fix race/regression issues when App Store Connect records are not yet rendered: - ScreenshotsTask and PreviewsTask now preserve placeholder local paths when imageAsset/videoUrl is null (AWAITING_UPLOAD) so pulled config doesn't drop entries and users can recover by adding files or removing entries. - PreviewsTask/ScreenshotsTask use the downloaded path when available, otherwise build a deterministic store path (with filename or index-based fallback). - AppClipTask.downloadAsync now falls back to the template experience when the current version has no default experience.

Also adds/updates unit tests for app clip behaviors (fallback to template, preserving header image path, skipping no-op PATCHes and review detail updates, and aborting when no editable version), screenshots (placeholder paths and index-based filename fallback), and previews (preserving placeholder entries when videoUrl is null). These changes address intermittent failures and improve round-trip push/pull resilience.

* [eas-cli] Bump @expo/apple-utils to 2.1.19

Brings in the fix for image and video uploads via metadata:push. The
asset client was inheriting Bearer token injection from the App Store
Connect API client, which caused S3 presigned URL uploads to be silently
mishandled by Apple's CDN — leaving screenshots, previews, and App Clip
header images stuck in AWAITING_UPLOAD state.

Also adds new App Clip models (AppClipDefaultExperience,
AppClipDefaultExperienceLocalization, AppClipHeaderImage,
AppClipAppStoreReviewDetail) used by the new App Clip metadata support.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 12:31:22 -07:00
Byron Karlen ad4c5299e3 [auth] logout changes (#3555) 2026-04-09 08:44:14 -07:00
Evan BaconandClaude Opus 4.6 dee07ae0a2 [eas-cli] Bump @expo/apple-utils to 2.1.18 (#3588)
* Sync Apple metadata schema, docs, and flags

Add new Apple advisory fields to metadata-0.json (advertising, ageAssurance, ageRatingOverrideV2, developerAgeRatingInfoUrl, gunsOrOtherWeapons, healthOrWellnessTopics, messagingAndChat, parentalControls, userGeneratedContent) and an AppleAgeRatingOverrideV2 enum. Introduce CLAUDE.md documenting how to keep schema and reader/writer in sync. Replace manual 'non-interactive' flags in metadata push/pull with the shared EASNonInteractiveFlag. Add a unit test that validates AppleConfigWriter output against the JSON schema (imports validateConfig) as a drift guard to surface mismatches between writer output and the schema early. These changes prevent confusing validation errors when pulling/pushing metadata by keeping schema, docs, CLI flags, and tests aligned.

* [eas-cli] Bump @expo/apple-utils to 2.1.18

Fixes `eas metadata:push` failing in the age-rating phase with
"'gamblingAndContests' is not an attribute on the resource
'ageRatingDeclarations'". The published 2.1.17 bundle still emitted
that field at runtime even though its `.d.ts` typed it as `never`,
because @expo/apple-utils' prepare script regenerated types from source
but bundled @expo/app-store from a stale `build/` directory.
2.1.18 rebuilds @expo/app-store as part of the prepare flow.

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

* Fix changelog PR link

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 08:34:49 -07:00
Evan BaconandClaude Opus 4.6 4ad12340f2 Add new Apple metadata attributes (#3584)
* Add new Apple metadata attributes

Extend AppleConfigReader and AppleConfigWriter to include additional app rating/content fields (advertising, ageAssurance, ageRatingOverrideV2, developerAgeRatingInfoUrl, gunsOrOtherWeapons, healthOrWellnessTopics, messagingAndChat, parentalControls, userGeneratedContent) with sensible defaults (false/null or Rating.NONE). This ensures these attributes are preserved when reading and writing Apple metadata.

* update package

* Add missing fields to test fixtures and CHANGELOG entry

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 07:45:54 -07:00
c6d4faefe7 [build-tools][eas-cli] Support iOS Development provisioning profiles (#3496)
* [build-tools] Detect iOS Development provisioning profiles and set correct code signing identity

Add DEVELOPMENT distribution type to distinguish Development profiles
from Ad Hoc. Development profiles have get-task-allow=true in their
entitlements; Ad Hoc profiles don't.

When a Development profile is detected, override CODE_SIGN_IDENTITY to
"Apple Development" instead of the "iPhone Distribution" that
@expo/config-plugins hardcodes. This fixes builds that use
credentialsSource: "local" with Development profiles (e.g., for
Family Controls entitlement).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* [eas-cli] Accept Development provisioning profiles for internal distribution

Fix isAdHocProfile() to distinguish Development from Ad Hoc profiles by
checking the get-task-allow entitlement. Add isDevelopmentProfile() for
explicit detection.

Update assertProvisioningProfileType() to accept Development profiles
for internal distribution, and reject them for store distribution.

This enables credentialsSource: "local" with Development profiles needed
for entitlements like Family Controls (com.apple.developer.family-controls)
that only work with Development provisioning profiles.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* [build-tools] Use certificateCommonName for development code signing identity

Address review feedback: use the certificate common name from the
provisioning profile instead of hardcoding "Apple Development", which
doesn't cover all certificate types (e.g. "iPhone Developer", "iOS
Development"). Also fix grammar in error message ("an development" →
"a development").

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* [build-tools] Add CHANGELOG entry for development provisioning profile fix

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* [build-tools] Use upstream codeSignIdentity parameter instead of workaround

Now that @expo/[email protected] supports the optional
codeSignIdentity parameter in setProvisioningProfileForPbxproj,
pass profile.certificateCommonName directly for development profiles
and remove the overrideCodeSignIdentityForDevelopment workaround
that was reading/writing the pbxproj a second time.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix changelog

Temporary Commit at 4/2/2026, 1:09:37 PM

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Quinlan Jung <[email protected]>
2026-04-02 13:25:13 -07:00
Phil Pluckthun 2f8712a0f3 [build-tools] Differentiate between Yarn Classic/Modern based on lockfile contents (#3572)
* Detect Yarn legacy/classic based on lockfile contents

* Add CHANGELOG entry

* Fix typo

* Add tests
2026-04-02 21:11:11 +01:00
Expo CI a439721054 update CHANGELOG.md 2026-04-02 10:44:22 +00:00
Expo CI 2e2dddb59a v18.5.0 2026-04-02 10:41:22 +00:00
Szymon ŚwierkandStanisław Chmiela b7df3ff4f8 [eas-build-job] Add app_store_connect.build_upload workflow interpolation context (#3566)
* Add app_store_connect.build_upload workflow interpolation context

* limit the possible states

* make the id type uuid

* Update packages/eas-build-job/src/common.ts

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

* Update packages/eas-build-job/src/common.ts

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

* remove no longer valid test

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-04-02 12:06:44 +02:00
Stanisław Chmiela 8a74758d56 [build-tools] Remove reuse_devices: false dead code (#3560)
## Summary
- remove the internal `eas/__maestro_test` function registration and implementation
- delete the Maestro flow/config discovery helper used by that internal path
- remove the related tests and fixture files for Maestro config parsing and flow resolution

## Testing
- `yarn --cwd packages/build-tools typecheck`
- `yarn --cwd packages/build-tools jest-unit --runInBand maestroResultParser reportMaestroTestResults easFunctions`
2026-04-02 10:38:59 +02:00
Szymon Świerk 26fdf8fb75 [eas-build-job] Add app_store_connect event context to WorkflowInterpolationContext (#3530)
* [eas-build-job] Add app_store_connect event context to WorkflowInterpolationContext

* app_store_connect.app, not app_store_connect.event.app
2026-04-01 11:10:48 +02:00
Stanisław Chmiela 9328063547 [build-tools] Remove precompiled modules preparation flow (#3565)
## Summary
- remove the worker and build-tools plumbing for precompiled dependency downloads and extraction
- stop waiting on precompiled dependency preparation before `pod install`
- delete the dedicated precompiled dependency helpers, fixtures, and tests
- keep the `EAS_USE_PRECOMPILED_MODULES=1` passthrough so iOS jobs still set `EXPO_USE_PRECOMPILED_MODULES=1`

## Testing

CI should pass.
2026-04-01 09:44:39 +02:00
Stanisław Chmiela fc66b8251e [eas-cli] Fix hasIgnoredIosProjectAsync (#3562)
## Why

`hasIgnoredIosProjectAsync()` currently always returns `false` because its `finally` block unconditionally returns `false`, overriding the value from the `try` block.

That means callers never see a positive ignored result even when the Xcode project is actually ignored.

## How

- replace the unconditional `finally` return with a `catch` fallback
- add a unit test that covers the ignored iOS project case

## Test Plan

CI should pass. Verified the test would fail if not for this change.
2026-04-01 09:25:07 +02:00
Stanisław Chmiela 394576368a [worker] Lower project dependencies telemetry errors to warning (#3535)
## Summary
- mark `FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT` as a Sentry warning instead of an error
- reduce noise from non-critical analytics failures in worker project dependency reporting
2026-03-31 10:30:17 +02:00
Evan BaconandClaude Opus 4.6 f80e34cffe feat(metadata): add screenshots and preview support (#3301)
* Add support for Apple screenshots and video previews

Introduces new tasks for managing App Store screenshots and video previews, including downloading and uploading assets per locale and display type. Updates AppleData and PartialAppleData types to include screenshots and previews, and extends config reader/writer to handle these assets. Also bumps @expo/apple-utils to 2.1.13 and updates related tests and types.

* Update apple-utils and improve asset file handling

Bump @expo/apple-utils to 2.1.14 and add a version check for preview support. Normalize preview and screenshot asset directory structure and filenames for consistency and future compatibility.

* Use original type names for preview and screenshot dirs

Updated directory structure for storing Apple previews and screenshots to use the original previewType and displayType values instead of lowercased, hyphenated versions. This change ensures consistency with the original type naming.

* Improve Apple metadata tasks error handling and schema

Replaces assert-based error handling with conditional checks and log messages in Apple metadata tasks (age-rating, app-review-detail, previews, screenshots) to prevent crashes when context is missing. Updates the metadata schema to add support for 'screenshots' and 'previews' fields, including detailed structure for video previews.

* Use original filenames for Apple previews and screenshots

Updated the download logic to use the original filenames from Apple metadata for previews and screenshots instead of normalized index-based names. This change improves file matching during sync operations.

* Update tests to skip on missing context instead of abort

Changed age rating and app review detail task tests to expect skipping (not throwing) when required context is missing, reflecting updated behavior. Also updated CHANGELOG to note new screenshots and previews support for metadata commands.

* wip -- unit tests for metadata

* Include .query(true) in apple metadata tests

Allow nock mocks to match requests with query parameters in two tests. Added `.query(true)` to the GET interceptors in packages/eas-cli/src/metadata/apple/tasks/__tests__/previews.test.ts and packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts so the mocks won't fail when requests include query strings.

* [eas-cli] Fix age rating API and update @expo/apple-utils to 2.1.16

Move age rating from appStoreVersions to appInfos — Apple removed the
ageRatingDeclaration relationship from appStoreVersions. Also apply
formatting fixes from oxfmt.

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

* Update CHANGELOG.md

* [eas-cli] Improve screenshots/previews tasks: native fs, fileSize matching, parallel fetching

- Replace fs-extra with native fs/fs.promises
- Add fileSize check alongside filename matching to detect changed files
- Parallelize locale fetching in prepareAsync with Promise.all
- Skip reorder API call when screenshot order is unchanged
- Remove unnecessary runtime method check for getAppPreviewSetsAsync
- Use consistent index-based fallback filenames for previews

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 17:46:55 -07:00
Quinlan Jung 🦥 59ad2c2ca0 Reapply "[eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)" (#3534) (#3543)
This reverts commit 92ad6b5ba3.
2026-03-30 14:04:44 -07:00
142 changed files with 11951 additions and 3362 deletions

No files matched your search

+40
View File
@@ -8,16 +8,56 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
### 🎉 New features
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.4.0",
"version": "18.7.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.4.0",
"version": "18.7.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -35,19 +35,19 @@
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.4.0",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.6.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.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": "18.4.0",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/steps": "18.6.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@urql/core": "^6.0.1",
+2 -5
View File
@@ -26,7 +26,7 @@ import {
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readEasJsonContents, readPackageJson } from '../utils/project';
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
@@ -82,10 +82,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
ctx.logger.info('Using package.json:');
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
ctx.logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
-7
View File
@@ -19,12 +19,5 @@ export { PackageManager } from './utils/packageManager';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export {
maybeStartPreparingPrecompiledModules,
PRECOMPILED_MODULES_PATH,
shouldUsePrecompiledDependencies,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from './utils/precompiledModules';
export * from './generic';
@@ -165,6 +165,52 @@ describe(configureXcodeProject, () => {
'Info.plist application target'
);
});
it('configures credentials with Apple Development identity for development profile', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
testapp: {
path: 'fake/path.mobileprovision',
target: 'testapp',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'profile name',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Apple Development: Test User',
distributionType: DistributionType.DEVELOPMENT,
},
},
distributionType: DistributionType.DEVELOPMENT,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {},
};
await configureXcodeProject(ctx as any, options);
const pbxproj = vol.readFileSync(
'/app/ios/testapp.xcodeproj/project.pbxproj',
'utf-8'
) as string;
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
expect(pbxproj).not.toContain('"iPhone Distribution"');
});
it('configures credentials and versions for multi target project', async () => {
vol.fromJSON(
{
@@ -1,127 +0,0 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import {
startPreparingPrecompiledDependencies,
PRECOMPILED_MODULES_PATH,
} from '../../utils/precompiledModules';
import { installPods } from '../pod';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('@expo/turtle-spawn');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe(installPods.name, () => {
const extract = jest.fn();
const close = jest.fn();
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
let mkdtempCallCount = 0;
jest.mocked(mkdtemp).mockImplementation(async () => {
mkdtempCallCount += 1;
return mkdtempCallCount === 1
? ('/tmp/precompiled-modules-archive' as any)
: ('/tmp/precompiled-modules-staging' as any);
});
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
it('waits for precompiled dependencies before running pod install', async () => {
let resolvePreparation: (() => void) | undefined;
const preparationPromise = new Promise<void>(resolve => {
resolvePreparation = resolve;
});
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, [
'https://example.com/precompiled-modules-0.zip',
'https://example.com/precompiled-modules-1.zip',
]);
jest.mocked(spawn).mockResolvedValue({} as any);
const installPodsPromise = installPods(ctx, {});
await Promise.resolve();
expect(spawn).not.toHaveBeenCalled();
resolvePreparation?.();
await installPodsPromise;
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']);
jest.mocked(spawn).mockResolvedValue({} as any);
await expect(installPods(ctx, {})).resolves.toEqual({
spawnPromise: expect.any(Promise),
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
});
});
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
import path from 'path';
import { Credentials } from './credentials/manager';
import { DistributionType } from './credentials/provisioningProfile';
import { BuildContext } from '../context';
async function configureXcodeProject(
@@ -56,6 +57,9 @@ async function configureCredentialsAsync(
profileName: profile.name,
appleTeamId: profile.teamId,
buildConfiguration,
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
codeSignIdentity: profile.certificateCommonName,
}),
}
);
}
@@ -24,6 +24,7 @@ export interface ProvisioningProfileData {
export enum DistributionType {
AD_HOC = 'ad-hoc',
APP_STORE = 'app-store',
DEVELOPMENT = 'development',
ENTERPRISE = 'enterprise',
}
@@ -137,6 +138,10 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
if (plistData.ProvisionsAllDevices) {
return DistributionType.ENTERPRISE;
} else if (plistData.ProvisionedDevices) {
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
if (entitlements?.['get-task-allow']) {
return DistributionType.DEVELOPMENT;
}
return DistributionType.AD_HOC;
} else {
return DistributionType.APP_STORE;
-10
View File
@@ -3,21 +3,11 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
import path from 'path';
import { BuildContext } from '../context';
import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
ctx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
@@ -17,9 +17,10 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
@@ -48,6 +49,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createDownloadArtifactFunction(),
createUploadArtifactBuildFunction(ctx),
createSetUpNpmrcBuildFunction(),
createReadPackageJsonBuildFunction(),
createReadAppConfigBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
@@ -81,8 +84,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
];
@@ -0,0 +1,177 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import { createEasBuildBuildFunctionGroup } from '../build';
function createMockBuildToolsContext(
overrides: Partial<{
platform: Platform;
simulator: boolean;
buildCredentials: Record<string, unknown>;
}> = {}
): CustomBuildContext<BuildJob> {
return {
job: {
platform: overrides.platform ?? Platform.ANDROID,
simulator: overrides.simulator ?? false,
secrets: overrides.buildCredentials
? { buildCredentials: overrides.buildCredentials }
: undefined,
},
} as unknown as CustomBuildContext<BuildJob>;
}
describe(createEasBuildBuildFunctionGroup, () => {
describe('working_directory input', () => {
it('does not set working directory on steps when not provided (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
for (const step of steps) {
// Without working_directory, only installPods would have a relative dir (iOS only).
// On Android, no step should have a relative working directory.
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
}
});
it('sets working directory on all steps except checkout (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
it('sets working directory on all steps except checkout (iOS simulator)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
});
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
});
it('sets working directory on all steps except checkout (Android with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.ANDROID,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
});
it('throws for generic jobs (no platform)', () => {
const buildToolsContext = {
job: { platform: undefined },
} as unknown as CustomBuildContext<BuildJob>;
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
'Build function group is not supported in generic jobs.'
);
});
});
@@ -1,5 +1,12 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
import {
BuildFunctionGroup,
BuildStep,
BuildStepGlobalContext,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
@@ -30,6 +37,7 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
globalCtx: BuildStepGlobalContext;
buildToolsContext: CustomBuildContext<BuildJob>;
workingDirectory?: string;
}
export function createEasBuildBuildFunctionGroup(
@@ -38,17 +46,30 @@ export function createEasBuildBuildFunctionGroup(
return new BuildFunctionGroup({
namespace: 'eas',
id: 'build',
createBuildStepsFromFunctionGroupCall: globalCtx => {
inputProviders: [
BuildStepInput.createProvider({
id: 'working_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
const workingDirectory = inputs.working_directory?.getValue({
interpolationContext: globalCtx.getInterpolationContext(),
}) as string | undefined;
if (buildToolsContext.job.platform === Platform.IOS) {
if (buildToolsContext.job.simulator) {
return createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
@@ -56,11 +77,13 @@ export function createEasBuildBuildFunctionGroup(
return createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
}
@@ -73,16 +96,19 @@ export function createEasBuildBuildFunctionGroup(
function createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: './ios',
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -91,6 +117,7 @@ function createStepsForIosSimulatorBuild({
});
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -98,18 +125,26 @@ function createStepsForIosSimulatorBuild({
});
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -117,29 +152,35 @@ function createStepsForIosSimulatorBuild({
}),
]
: []),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
];
}
function createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const resolveAppleTeamIdFromCredentials =
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'resolve_apple_team_id_from_credentials',
workingDirectory,
});
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
},
@@ -147,16 +188,18 @@ function createStepsForIosBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
}
);
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: './ios',
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -166,6 +209,7 @@ function createStepsForIosBuildWithCredentials({
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
credentials: '${ eas.job.secrets.buildCredentials }',
},
@@ -173,6 +217,7 @@ function createStepsForIosBuildWithCredentials({
);
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -181,6 +226,7 @@ function createStepsForIosBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
@@ -188,10 +234,15 @@ function createStepsForIosBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
resolveAppleTeamIdFromCredentials,
prebuildStep,
@@ -199,11 +250,16 @@ function createStepsForIosBuildWithCredentials({
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -215,24 +271,29 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
function createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -242,6 +303,7 @@ function createStepsForAndroidBuildWithoutCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -249,6 +311,7 @@ function createStepsForAndroidBuildWithoutCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -257,6 +320,7 @@ function createStepsForAndroidBuildWithoutCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -264,18 +328,26 @@ function createStepsForAndroidBuildWithoutCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -286,24 +358,29 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
function createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -313,6 +390,7 @@ function createStepsForAndroidBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -320,6 +398,7 @@ function createStepsForAndroidBuildWithCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -328,6 +407,7 @@ function createStepsForAndroidBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -335,21 +415,33 @@ function createStepsForAndroidBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
runGradle,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -359,8 +451,10 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
@@ -1,59 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
import { createInstallPodsBuildFunction } from '../installPods';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/precompiledModules', () => ({
...jest.requireActual('../../../utils/precompiledModules'),
waitForPrecompiledModulesPreparationAsync: jest.fn(),
}));
describe(createInstallPodsBuildFunction, () => {
it('waits for precompiled dependencies before running pod install', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await buildStep.executeAsync();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest
.mocked(waitForPrecompiledModulesPreparationAsync)
.mockRejectedValue(new Error('precompiled dependencies failed'));
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
});
@@ -1,238 +0,0 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('generates per-attempt report filenames when retrying', async () => {
// Mock sequence:
// 1. xcrun simctl shutdown → succeeds (beforeEach default is fine)
// 2. maestro test attempt 0 → fails (triggers retry)
// 3. maestro test attempt 1 → succeeds (beforeEach default)
mockedSpawnAsync
.mockResolvedValueOnce(undefined as any) // xcrun simctl shutdown
.mockRejectedValueOnce(new Error('Maestro test failed')); // maestro attempt 0
// Attempt 1 falls through to beforeEach's mockResolvedValue default
const step = createStep({
callInputs: { output_format: 'junit', retries: 2 },
});
await step.executeAsync();
// Filter to just the maestro test command calls (skip xcrun simctl shutdown)
const maestroCalls = mockedSpawnAsync.mock.calls.filter(([cmd]) => cmd === 'maestro');
expect(maestroCalls).toHaveLength(2);
// Extract --output paths from maestro calls
const outputArgs = maestroCalls.map(([, args]) => args[args.indexOf('--output') + 1]);
expect(outputArgs[0]).toContain('attempt-0');
expect(outputArgs[1]).toContain('attempt-1');
// Filenames must be unique (not overwriting)
expect(outputArgs[0]).not.toBe(outputArgs[1]);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -0,0 +1,77 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAppConfig } from '../../../utils/appConfig';
import { createReadAppConfigBuildFunction } from '../readAppConfig';
jest.mock('../../../utils/appConfig');
describe(createReadAppConfigBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('reads and logs app configuration', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({
logger,
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
});
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
await buildStep.executeAsync();
expect(readAppConfig).toHaveBeenCalledWith(
expect.objectContaining({
projectDir: globalContext.defaultWorkingDirectory,
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
logger: stepLogger,
sdkVersion: '54.0.0',
})
);
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
expect(stepLogger.info).toHaveBeenCalledWith(
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
);
const firstOutput = buildStep.outputById.app_config.value;
expect(firstOutput).toBeDefined();
expect(JSON.parse(firstOutput!)).toEqual({
name: 'my-app',
slug: 'my-app-slug',
version: '1.2.3',
});
});
it('does not throw if reading app config fails', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
});
it('exports app fields as returned by app config', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
await buildStep.executeAsync();
const secondOutput = buildStep.outputById.app_config.value;
expect(secondOutput).toBeDefined();
expect(JSON.parse(secondOutput!)).toEqual({
name: 'my-app',
slug: 123,
});
});
});
@@ -0,0 +1,50 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAndLogPackageJson } from '../../../utils/project';
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
jest.mock('../../../utils/project');
describe(createReadPackageJsonBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('logs package.json contents', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
await buildStep.executeAsync();
expect(readAndLogPackageJson).toHaveBeenCalledWith(
stepLogger,
globalContext.defaultWorkingDirectory
);
expect(buildStep.outputById.package_json.value).toBe(
JSON.stringify({ name: 'app', version: '1.0.0' })
);
});
it('does not throw if reading package.json fails', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
throw new Error('failed to read package.json');
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(stepLogger.error).toHaveBeenCalledWith({
err: expect.any(Error),
});
expect(buildStep.outputById.package_json.value).toBeUndefined();
});
});
@@ -34,7 +34,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
@@ -212,4 +211,64 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});
it('fails early on Linux when emulator host support check fails', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
const step = createStep();
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
});
it('continues startup on Linux when emulator host support check succeeds', async () => {
await createStep().executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('skips emulator host support check when opt out env var is enabled', async () => {
const step = createStep(undefined, {
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
false
);
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: undefined,
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
});
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: '/android/sdk/root',
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'/android/sdk/root/emulator/emulator',
['-accel-check'],
{
env: expect.any(Object),
}
);
});
});
@@ -1,8 +1,6 @@
import { BuildFunction } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
export function createInstallPodsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -10,14 +8,6 @@ export function createInstallPodsBuildFunction(): BuildFunction {
name: 'Install Pods',
__metricsId: 'eas/install_pods',
fn: async (stepsCtx, { env }) => {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
stepsCtx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
stepsCtx.logger.info('Installing pods');
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag =
@@ -1,684 +0,0 @@
import { GenericArtifactType } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { Result, asyncResult, result } from '@expo/results';
import {
BuildFunction,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
spawnAsync,
} from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
AndroidDeviceSerialId,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: '__maestro_test',
__metricsId: 'eas/__maestro_test',
inputProviders: [
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'platform',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
id: 'flow_paths',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'retries',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'include_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'exclude_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'shards',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'output_format',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
id: 'record_screen',
defaultValue: false,
required: false,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
const {
platform,
flow_paths,
retries,
include_tags,
exclude_tags,
shards,
output_format,
record_screen,
} = z
.object({
platform: z.enum(['ios', 'android']),
flow_paths: z.array(z.string()),
retries: z.number().default(1),
include_tags: z.string().optional(),
exclude_tags: z.string().optional(),
shards: z.number().default(1),
output_format: z.string().optional(),
record_screen: z.boolean().default(false),
})
.parse(
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
);
const flowPathsToExecute: string[] = [];
for (const flowPath of flow_paths) {
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: stepCtx.workingDirectory,
flowPath,
logger: stepCtx.logger,
includeTags: include_tags ? include_tags.split(',') : undefined,
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
});
if (flowPaths.length === 0) {
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
continue;
}
stepCtx.logger.info(
`Marking for execution:\n- ${flowPaths
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
stepCtx.logger.info('');
flowPathsToExecute.push(...flowPaths);
}
// TODO: Add support for shards. (Shouldn't be too difficult.)
if (shards > 1) {
stepCtx.logger.warn(
'Sharding support has been temporarily disabled. Running tests on a single shard.'
);
}
// eas/__maestro_test does not start devices, it expects a single device to be already running
// and configured with the app. Here we find the booted device and stop it.
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
switch (platform) {
case 'ios': {
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
});
if (bootedDevices.length === 0) {
throw new Error('No booted iOS Simulator found.');
} else if (bootedDevices.length > 1) {
throw new Error('Multiple booted iOS Simulators found.');
}
const device = bootedDevices[0];
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
stepCtx.logger.info(`Preparing Simulator for tests...`);
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
logger: stepCtx.logger,
stdio: 'pipe',
});
sourceDeviceIdentifier = device.name;
break;
}
case 'android': {
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
if (connectedDevices.length === 0) {
throw new Error('No booted Android Emulator found.');
} else if (connectedDevices.length > 1) {
throw new Error('Multiple booted Android Emulators found.');
}
const { serialId } = connectedDevices[0];
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
mode: PipeMode.COMBINED,
env,
});
const avdName = adbEmuAvdNameResult.stdout
.replace(/\r\n/g, '\n')
.split('\n')[0] as AndroidVirtualDeviceName;
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
stepCtx.logger.info(`Preparing Emulator for tests...`);
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
stdio: 'pipe',
});
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
await setTimeout(1000);
sourceDeviceIdentifier = avdName;
break;
}
}
// During tests we generate reports and device logs. We store them in temporary directories
// and upload them once all tests are done. When a test is retried, new reports overwrite
// the old ones. The files are named "flow-${index}" for easier identification.
const maestroReportsDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'maestro-reports-')
);
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
const failedFlows: string[] = [];
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
stepCtx.logger.info('');
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
// Generate unique report path per attempt (not overwritten on retry)
const outputPath = path.join(
maestroReportsDir,
[
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}-attempt-${attemptCount}`,
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
]
.filter(Boolean)
.join('.')
);
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
| IosSimulatorName
| AndroidVirtualDeviceName;
// If the test passes, but the recording fails, we don't want to make the test fail,
// so we return two separate results.
const {
fnResult: { fnResult, recordingResult },
logsResult,
} = await withCleanDeviceAsync({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger: stepCtx.logger,
fn: async ({ deviceIdentifier }) => {
return await maybeWithScreenRecordingAsync({
shouldRecord: record_screen,
platform,
deviceIdentifier,
env,
logger: stepCtx.logger,
fn: async () => {
stepCtx.logger.info('');
const [command, ...args] = getMaestroTestCommand({
flow_path: flowPath,
output_format,
output_path: outputPath,
});
try {
await spawnAsync(command, args, {
logger: stepCtx.logger,
cwd: stepCtx.workingDirectory,
env,
stdio: 'pipe',
});
} finally {
stepCtx.logger.info('');
}
},
});
},
});
// Move device logs to the device logs directory.
if (logsResult?.ok) {
try {
const extension = path.extname(logsResult.value.outputPath);
const destinationPath = path.join(
deviceLogsDir,
`flow-${flowIndex}-attempt-${attemptCount}${extension}`
);
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
}
} else if (logsResult?.reason) {
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
}
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
if (isLastAttempt && recordingResult.value) {
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
name: `Screen Recording (${flowIndex}-${path.basename(
flowPath,
path.extname(flowPath)
)})`,
paths: [recordingResult.value],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
}
}
if (fnResult.ok) {
stepCtx.logger.info(`Test passed.`);
// Break out of the retry loop.
break;
}
if (attemptCount < retries - 1) {
stepCtx.logger.info(`Retrying test...`);
stepCtx.logger.info('');
continue;
}
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
failedFlows.push(flowPath);
}
}
stepCtx.logger.info('');
// When all tests are done, we upload the reports and device logs.
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
if (generatedMaestroReports.length === 0) {
stepCtx.logger.warn('No reports were generated.');
} else {
stepCtx.logger.info(`Uploading reports...`);
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
paths: [maestroReportsDir],
type: GenericArtifactType.OTHER,
},
});
if (artifactId) {
outputs.test_reports_artifact_id.set(artifactId);
}
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload reports.');
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
} else {
stepCtx.logger.info(`Uploading device logs...`);
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `Maestro Test Device Logs`,
paths: [deviceLogsDir],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
}
}
stepCtx.logger.info('');
// If any tests failed, we throw an error to mark the step as failed.
if (failedFlows.length > 0) {
throw new Error(
`Some Maestro tests failed:\n- ${failedFlows
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
} else {
stepCtx.logger.info('All Maestro tests passed.');
}
},
});
}
export function getMaestroTestCommand(params: {
flow_path: string;
output_format: string | undefined;
/** Unused if `output_format` is undefined */
output_path: string;
}): [command: string, ...args: string[]] {
let outputFormatFlags: string[] = [];
if (params.output_format) {
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
}
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
command: string,
...args: string[],
];
}
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
junit: 'xml',
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger,
fn,
}: {
env: BuildStepEnv;
logger: bunyan;
platform: 'ios' | 'android';
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
fn: ({
deviceIdentifier,
}: {
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
}) => Promise<TResult>;
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
switch (platform) {
case 'ios': {
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await IosSimulatorUtils.cloneAsync({
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
destinationDeviceName: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
const { udid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
await IosSimulatorUtils.waitForReadyAsync({
udid,
env,
});
localDeviceIdentifier = localDeviceName as IosSimulatorName;
break;
}
case 'android': {
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
// Stop the device
let logsResult: Result<{ outputPath: string }> | null = null;
try {
switch (platform) {
case 'ios': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
IosSimulatorUtils.collectLogsAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await IosSimulatorUtils.deleteAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
});
break;
}
case 'android': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
});
break;
}
}
} catch (err) {
logger.error(`Error cleaning up device: ${err}`);
}
return { fnResult: fnResult.enforceValue(), logsResult };
}
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
async function maybeWithScreenRecordingAsync<TResult>({
shouldRecord,
platform,
deviceIdentifier,
env,
logger,
fn,
}: {
// As weird as it is, it's more convenient to have this function like `maybeWith...`
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
shouldRecord: boolean;
platform: 'ios' | 'android';
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
env: BuildStepEnv;
logger: bunyan;
fn: () => Promise<TResult>;
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
if (!shouldRecord) {
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
}
let recordingResult: Result<{
recordingSpawn: SpawnPromise<SpawnResult>;
outputPath?: string;
}>;
// Start screen recording
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
recordingResult = await asyncResult(
IosSimulatorUtils.startScreenRecordingAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorName,
env,
})
);
break;
}
case 'android': {
recordingResult = await asyncResult(
AndroidEmulatorUtils.startScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
env,
})
);
break;
}
}
if (!recordingResult.ok) {
logger.warn('Failed to start screen recording.', recordingResult.reason);
}
// Run the function
const fnResult = await asyncResult(fn());
// If recording failed there's nothing to stop, so we return the results
if (!recordingResult.ok) {
return { fnResult, recordingResult: result(recordingResult.reason) };
}
// If recording started, finish it
try {
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
await IosSimulatorUtils.stopScreenRecordingAsync({
recordingSpawn: recordingResult.value.recordingSpawn,
});
return {
fnResult,
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
recordingResult: result(recordingResult.value.outputPath!),
};
}
case 'android': {
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
recordingSpawn: recordingResult.value.recordingSpawn,
env,
});
return { fnResult, recordingResult: result(outputPath) };
}
}
} catch (err) {
logger.warn('Failed to stop screen recording.', err);
return { fnResult, recordingResult: result(err as Error) };
}
}
@@ -0,0 +1,43 @@
import { Metadata } from '@expo/eas-build-job';
import { BuildFunction, BuildStepOutput } from '@expo/steps';
import { readAppConfig } from '../../utils/appConfig';
export function createReadAppConfigBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_app_config',
name: 'Read app config',
__metricsId: 'eas/read_app_config',
outputProviders: [
BuildStepOutput.createProvider({
id: 'app_config',
required: false,
}),
],
fn: async (stepCtx, { env, outputs }) => {
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
try {
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
})
).exp;
stepCtx.logger.info('Using app configuration:');
stepCtx.logger.info(JSON.stringify(appConfig, null, 2));
outputs.app_config.set(JSON.stringify(appConfig));
} catch {
// readAppConfig logs underlying failures.
}
},
});
}
@@ -0,0 +1,26 @@
import { BuildFunction, BuildStepOutput } from '@expo/steps';
import { readAndLogPackageJson } from '../../utils/project';
export function createReadPackageJsonBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_package_json',
name: 'Read package.json',
__metricsId: 'eas/read_package_json',
outputProviders: [
BuildStepOutput.createProvider({
id: 'package_json',
required: false,
}),
],
fn: async (stepCtx, { outputs }) => {
try {
const packageJson = readAndLogPackageJson(stepCtx.logger, stepCtx.workingDirectory);
outputs.package_json.set(JSON.stringify(packageJson));
} catch (err: unknown) {
stepCtx.logger.error({ err });
}
},
});
}
@@ -200,7 +200,7 @@ export async function restoreGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -161,7 +161,7 @@ export async function saveGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -1,5 +1,11 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import {
@@ -44,6 +50,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger }, { inputs, env }) => {
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
@@ -253,3 +267,32 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
},
});
}
async function assertAndroidEmulatorHostSupportAsync({
env,
}: {
env: NodeJS.ProcessEnv;
}): Promise<void> {
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
if (!isAndroidEmulatorHostSupported) {
throw new UserError(
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
'Could not start the Android emulator on this runner.\n\n' +
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
' runs_on: linux-medium-nested-virtualization\n' +
' runs_on: linux-large-nested-virtualization\n\n' +
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
);
}
}
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
try {
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
return true;
} catch {
return false;
}
}
@@ -50,6 +50,47 @@ describe(configureCredentialsAsync, () => {
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
).toMatchSnapshot();
});
it('configures credentials with Apple Development identity for development profile', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
testapp: {
path: 'fake/path.mobileprovision',
target: 'testapp',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'profile name',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Apple Development: Test User',
distributionType: DistributionType.DEVELOPMENT,
},
},
distributionType: DistributionType.DEVELOPMENT,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile,
},
buildConfiguration: 'Release',
};
await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options);
const pbxproj = vol.readFileSync(
'/app/ios/testapp.xcodeproj/project.pbxproj',
'utf-8'
) as string;
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
expect(pbxproj).not.toContain('"iPhone Distribution"');
});
it('configures credentials for multi target project', async () => {
vol.fromJSON(
{
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
import path from 'path';
import { Credentials } from './credentials/manager';
import { DistributionType } from './credentials/provisioningProfile';
export async function configureCredentialsAsync(
logger: bunyan,
@@ -29,6 +30,9 @@ export async function configureCredentialsAsync(
profileName: profile.name,
appleTeamId: profile.teamId,
buildConfiguration,
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
codeSignIdentity: profile.certificateCommonName,
}),
});
}
}
@@ -23,6 +23,7 @@ export interface ProvisioningProfileData {
export enum DistributionType {
AD_HOC = 'ad-hoc',
APP_STORE = 'app-store',
DEVELOPMENT = 'development',
ENTERPRISE = 'enterprise',
}
@@ -133,6 +134,10 @@ export default class ProvisioningProfile {
if (plistData.ProvisionsAllDevices) {
return DistributionType.ENTERPRISE;
} else if (plistData.ProvisionedDevices) {
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
if (entitlements?.['get-task-allow']) {
return DistributionType.DEVELOPMENT;
}
return DistributionType.AD_HOC;
} else {
return DistributionType.APP_STORE;
@@ -1,400 +0,0 @@
import spawnAsync from '@expo/spawn-async';
import bunyan from 'bunyan';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { findMaestroPathsFlowsToExecuteAsync } from '../findMaestroPathsFlowsToExecuteAsync';
// We use node:fs
jest.unmock('node:fs');
// and fg uses fs.
jest.unmock('fs');
jest.setTimeout(15000);
/**
*
* You need to have a Simulator running to run these tests.
*
*/
describe('findMaestroPathsFlowsToExecuteAsync', () => {
const fixturesDir = path.join(__dirname, 'fixtures', 'maestro-flows');
const singleFileDir = path.join(__dirname, 'fixtures', 'single-file');
const configFlowsDir = path.join(__dirname, 'fixtures', 'config-flows');
const noConfigFlowsDir = path.join(__dirname, 'fixtures', 'no-config-flows');
let logger: bunyan;
beforeAll(() => {
logger = createMockLogger();
});
// Helper function to run maestro test and determine which flows it executed
// by parsing the output
async function getMaestroFlowList(
flowPath: string,
includeTags: string[] = [],
excludeTags: string[] = []
): Promise<string[]> {
const args = ['test', flowPath, '--no-ansi'];
if (includeTags.length > 0) {
args.push('--include-tags', includeTags.join(','));
}
if (excludeTags.length > 0) {
args.push('--exclude-tags', excludeTags.join(','));
}
try {
const result = await spawnAsync('maestro', args, {
stdio: 'pipe',
});
return parseMaestroExecutedFlows(result.stdout + result.stderr);
} catch (error: any) {
const output = (error.stdout || '') + (error.stderr || '') + (error.message || '');
return parseMaestroExecutedFlows(output);
}
}
function parseMaestroExecutedFlows(output: string): string[] {
return output
.split('\n')
.filter(line => line.includes('[Passed]') || line.includes('> Flow'))
.map(line => {
if (line.includes('[Passed]')) {
return line.split('[Passed]')[1].trim().split(' ')[0];
}
return line.split('> Flow')[1].trim();
})
.sort();
}
// Helper function to run our implementation
async function getOurFlowList(
flowPath: string,
includeTags: string[] = [],
excludeTags: string[] = []
): Promise<string[]> {
const result = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: process.cwd(),
flowPath: path.relative(process.cwd(), flowPath),
includeTags,
excludeTags,
logger,
});
return result.map(filePath => path.basename(filePath, path.extname(filePath))).sort();
}
describe('Directory-based flow discovery', () => {
it('should discover all flows when no tags are specified', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir);
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by include tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth']);
const ourFlows = await getOurFlowList(fixturesDir, ['auth']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow']);
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by both include and exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['smoke'], ['regression']);
const ourFlows = await getOurFlowList(fixturesDir, ['smoke'], ['regression']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should handle multiple include tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth', 'smoke']);
const ourFlows = await getOurFlowList(fixturesDir, ['auth', 'smoke']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should handle multiple exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow', 'regression']);
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow', 'regression']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should not include nested flows in subdirectories', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['nested']);
const ourFlows = await getOurFlowList(fixturesDir, ['nested']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should exclude config files from flow discovery', async () => {
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows.every(flow => !flow.endsWith('config.yaml'))).toBe(true);
expect(ourFlows.every(flow => !flow.endsWith('config.yml'))).toBe(true);
});
it('should exclude non-YAML files from flow discovery', async () => {
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows).not.toContain('non-flow-file');
});
});
describe('Single file scenarios', () => {
it('should handle single file input', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
const maestroFlows = await getMaestroFlowList(singleFile);
const ourFlows = await getOurFlowList(singleFile);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
expect(ourFlows[0]).toBe(path.basename(singleFile, path.extname(singleFile)));
});
it('should handle single file with tag filtering (include)', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
// This file has 'smoke' tag, so should be included
const maestroFlows = await getMaestroFlowList(singleFile, ['smoke']);
const ourFlows = await getOurFlowList(singleFile, ['smoke']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
});
it('should handle single file with tag filtering (exclude)', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
// This file has 'smoke' tag, so should be excluded
const maestroFlows = await getMaestroFlowList(singleFile, [], ['smoke']);
const ourFlows = await getOurFlowList(singleFile, [], ['smoke']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
});
});
describe('Edge cases', () => {
it('should handle non-existent paths gracefully', async () => {
const nonExistentPath = '/path/that/does/not/exist';
// Our implementation should return empty array for non-existent paths
await expect(getOurFlowList(nonExistentPath)).rejects.toThrow('ENOENT');
});
it('should handle empty directories', async () => {
const emptyDir = path.join(__dirname, 'fixtures', 'empty-dir');
await fs.mkdir(emptyDir, { recursive: true });
try {
const ourFlows = await getOurFlowList(emptyDir);
expect(ourFlows).toEqual([]);
} finally {
await fs.rmdir(emptyDir);
}
});
it('should handle flows that cannot be parsed', async () => {
// The invalid-flow.yaml has extra fields that might cause parsing issues
// but should still be discovered if tag filtering passes
const ourFlows = await getOurFlowList(fixturesDir, ['invalid']);
// Should either include the file (if parsing is lenient) or exclude it (if parsing fails)
// The important thing is that it doesn't crash
expect(Array.isArray(ourFlows)).toBe(true);
});
});
describe('Performance comparison', () => {
it('should complete discovery in reasonable time', async () => {
const startTime = Date.now();
await getOurFlowList(fixturesDir);
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
});
});
describe('Detailed flow content verification', () => {
it('should correctly identify flows with specific tag combinations', async () => {
// Test complex tag filtering scenarios
const authFlows = await getOurFlowList(fixturesDir, ['auth']);
const smokeFlows = await getOurFlowList(fixturesDir, ['smoke']);
const criticalFlows = await getOurFlowList(fixturesDir, ['critical']);
// Verify specific files are included/excluded correctly
expect(authFlows).toContain('tagged-flow-auth');
expect(authFlows).not.toContain('nested-flow'); // Subdirectory flows excluded by default
expect(smokeFlows).toContain('tagged-flow-smoke');
expect(criticalFlows).toContain('tagged-flow-smoke');
});
it('should handle flows without tags correctly', async () => {
// When no filters are applied, flows without tags should be included
const allFlows = await getOurFlowList(fixturesDir);
expect(allFlows).toContain('no-tags-flow');
expect(allFlows).toContain('basic-flow');
// When include tags are specified, flows without tags should be excluded
const taggedFlows = await getOurFlowList(fixturesDir, ['auth']);
expect(taggedFlows).not.toContain('no-tags-flow');
expect(taggedFlows).not.toContain('basic-flow');
});
});
describe('config.yaml flow patterns', () => {
it('should discover flows using config.yaml patterns', async () => {
const maestroFlows = await getMaestroFlowList(configFlowsDir);
const ourFlows = await getOurFlowList(configFlowsDir);
expect(ourFlows).toEqual(maestroFlows);
// Verify specific files are included based on config patterns
expect(ourFlows).toContain('login'); // features/auth/login.yaml
expect(ourFlows).toContain('logout'); // features/auth/logout.yaml
expect(ourFlows).toContain('navigation'); // features/core/navigation.yaml
expect(ourFlows).toContain('api.spec'); // specs/api.spec.yaml
expect(ourFlows).toContain('ui.spec'); // specs/ui.spec.yaml
// Verify top-level file is excluded (not matching patterns)
expect(ourFlows).not.toContain('ignored-top-level');
});
it('should handle tag filtering with config.yaml patterns', async () => {
const authFlows = await getOurFlowList(configFlowsDir, ['auth']);
const maestroAuthFlows = await getMaestroFlowList(configFlowsDir, ['auth']);
expect(authFlows).toEqual(maestroAuthFlows);
expect(authFlows).toContain('login');
expect(authFlows).toContain('logout');
expect(authFlows).not.toContain('navigation');
expect(authFlows).not.toContain('api.spec');
});
it('should handle exclude tags with config.yaml patterns', async () => {
const nonSmokeFlows = await getOurFlowList(configFlowsDir, [], ['smoke']);
const maestroNonSmokeFlows = await getMaestroFlowList(configFlowsDir, [], ['smoke']);
expect(nonSmokeFlows).toEqual(maestroNonSmokeFlows);
expect(nonSmokeFlows).not.toContain('login'); // has smoke tag
expect(nonSmokeFlows).not.toContain('navigation'); // has smoke tag
expect(nonSmokeFlows).toContain('logout'); // no smoke tag
expect(nonSmokeFlows).toContain('api.spec'); // no smoke tag
});
it('should handle multiple include tags with config.yaml patterns', async () => {
const multiTagFlows = await getOurFlowList(configFlowsDir, ['auth', 'integration']);
const maestroMultiTagFlows = await getMaestroFlowList(configFlowsDir, [
'auth',
'integration',
]);
expect(multiTagFlows).toEqual(maestroMultiTagFlows);
expect(multiTagFlows).toContain('login'); // auth tag
expect(multiTagFlows).toContain('logout'); // auth tag
expect(multiTagFlows).toContain('api.spec'); // integration tag
expect(multiTagFlows).toContain('ui.spec'); // integration tag
expect(multiTagFlows).not.toContain('navigation'); // only core/smoke tags
});
});
describe('config.yaml includeTags/excludeTags merging', () => {
const configTagsDir = path.join(__dirname, 'fixtures', 'config-tags');
it('should respect includeTags/excludeTags from config.yaml when no CLI tags provided', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir);
const ourFlows = await getOurFlowList(configTagsDir);
expect(ourFlows).toEqual(maestroFlows);
// Based on fixtures, only flows matching config includeTags and not matching excludeTags should be included
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-neutral');
expect(ourFlows).not.toContain('flow-cli-exclude');
expect(ourFlows).not.toContain('flow-cli-include');
});
it('should merge CLI includeTags with config includeTags', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cliInclude']);
const ourFlows = await getOurFlowList(configTagsDir, ['cliInclude']);
expect(ourFlows).toEqual(maestroFlows);
// Now flows with either cfgInclude or cliInclude should be included, minus excluded ones
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).toContain('flow-cli-include');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-cli-exclude');
});
it('should merge CLI excludeTags with config excludeTags', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, [], ['cliExclude']);
const ourFlows = await getOurFlowList(configTagsDir, [], ['cliExclude']);
expect(ourFlows).toEqual(maestroFlows);
// Exclude both cfgExclude and cliExclude
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-cli-exclude');
expect(ourFlows).not.toContain('flow-inc-and-exc');
});
it('should apply both merged include and exclude tags correctly', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
const ourFlows = await getOurFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-neutral');
});
});
describe('backward compatibility (no config.yaml)', () => {
it('should use default "*" pattern when no config.yaml exists', async () => {
const maestroFlows = await getMaestroFlowList(noConfigFlowsDir);
const ourFlows = await getOurFlowList(noConfigFlowsDir);
expect(ourFlows).toEqual(maestroFlows);
// Should include top-level files
expect(ourFlows).toContain('top-level-flow');
// Should exclude subdirectory files (default "*" behavior)
expect(ourFlows).not.toContain('nested-flow');
});
it('should handle tag filtering without config.yaml', async () => {
const basicFlows = await getOurFlowList(noConfigFlowsDir, ['basic']);
const maestroBasicFlows = await getMaestroFlowList(noConfigFlowsDir, ['basic']);
expect(basicFlows).toEqual(maestroBasicFlows);
expect(basicFlows).toContain('top-level-flow');
const noIncludeTagsFlows = await getOurFlowList(noConfigFlowsDir, []);
const maestroNoIncludeTagsFlows = await getMaestroFlowList(noConfigFlowsDir, []);
expect(noIncludeTagsFlows).toEqual(maestroNoIncludeTagsFlows);
expect(noIncludeTagsFlows).toContain('top-level-flow');
expect(noIncludeTagsFlows).not.toContain('nested-flow');
});
});
});
@@ -1,3 +0,0 @@
flows:
- 'features/**/*.yaml'
- 'specs/*.spec.yaml'
@@ -1,5 +0,0 @@
appId: com.example.app
name: "login"
tags: ["auth", "smoke"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "logout"
tags: ["auth"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "navigation"
tags: ["core", "smoke"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "ignored-top-level"
tags: ["ignored"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "api.spec"
tags: ["api", "integration"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "ui.spec"
tags: ["ui", "integration"]
---
- assertTrue: true
@@ -1,4 +0,0 @@
appId: com.example.app
name: "basic-flow"
---
- assertTrue: true
@@ -1,6 +0,0 @@
appId: com.example.app
name: "invalid-flow"
tags:
- invalid
---
- assertTrue: true
@@ -1,4 +0,0 @@
appId: com.example.app
name: "no-tags-flow"
---
- assertTrue: true
@@ -1 +0,0 @@
This is not a YAML file and should be ignored by flow discovery.
@@ -1,7 +0,0 @@
appId: com.example.app
name: "nested-flow"
tags:
- nested
- auth
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-auth"
tags:
- auth
- login
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-regression"
tags:
- regression
- slow
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-smoke"
tags:
- smoke
- critical
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "nested-flow"
tags: ["nested"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "top-level-flow"
tags: ["basic"]
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "standalone-flow"
tags:
- standalone
- smoke
---
- assertTrue: true
@@ -1,120 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../__tests__/utils/logger';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe('precompiledModules integration', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.enableNetConnect();
});
afterEach(() => {
nock.cleanAll();
});
it('merges the extracted debug and release xcframework trees', async () => {
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
const previousHome = process.env.HOME;
process.env.HOME = homeDirectory;
try {
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
const debugArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
);
const releaseArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
);
nock('https://fixtures.expo.test')
.get('/xcframeworks-Debug.zip')
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
.get('/xcframeworks-Release.zip')
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
jest.resetModules();
jest.doMock('os', () => ({
...jest.requireActual('os'),
homedir: () => homeDirectory,
}));
const {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} = await import('../precompiledModules');
startPreparingPrecompiledDependencies(
{
logger: createMockLogger(),
env: {},
} as any,
[
'https://fixtures.expo.test/xcframeworks-Debug.zip',
'https://fixtures.expo.test/xcframeworks-Release.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(PRECOMPILED_MODULES_PATH).toBe(
path.join(homeDirectory, '.expo', 'precompiled-modules')
);
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-camera fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-application fixture\n');
} finally {
jest.dontMock('os');
jest.resetModules();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.remove(homeDirectory);
}
});
});
@@ -1,168 +0,0 @@
import downloadFile from '@expo/downloader';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from '../precompiledModules';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe('precompiledModules', () => {
const extract = jest.fn();
const close = jest.fn();
const getMkdtempPath = jest.fn();
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
({
logger: createMockLogger(),
env,
}) as unknown as BuildContext;
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
getMkdtempPath.mockReset();
getMkdtempPath
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
jest.mocked(downloadFile).mockReset();
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
afterEach(() => {
jest.useRealTimers();
});
it('downloads through CocoaPods proxy when enabled', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
jest
.mocked(downloadFile)
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('extracts all configured archives into the well-known destination path', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
]);
await waitForPrecompiledModulesPreparationAsync();
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
{ retry: 3 }
);
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH);
});
it('leaves the destination empty when preparation fails', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
extract.mockRejectedValueOnce(new Error('extract failed'));
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]);
await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed');
expect(move).not.toHaveBeenCalled();
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('throws when precompiled dependencies are still not ready after 30 seconds', async () => {
jest.useFakeTimers();
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => {}) as any);
startPreparingPrecompiledDependencies(createCtx(), [
'https://example.com/xcframeworks-Debug.zip',
]);
const waitPromise = waitForPrecompiledModulesPreparationAsync();
const waitExpectation = expect(waitPromise).rejects.toThrow(
'Timed out waiting for precompiled dependencies after 30 seconds'
);
await Promise.resolve();
await jest.advanceTimersByTimeAsync(30_000);
await waitExpectation;
});
});
@@ -2,18 +2,29 @@ import { ExpoConfig } from '@expo/config';
import { Android } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import { Readable } from 'stream';
import { instance, mock, when } from 'ts-mockito';
import { BuildContext } from '../../context';
import { PackageManager } from '../packageManager';
import { readEasJsonContents, runExpoCliCommand } from '../project';
import { PackageManager, findPackagerRootDir } from '../packageManager';
import {
isUsingModernYarnVersion,
readAndLogPackageJson,
readEasJsonContents,
runExpoCliCommand,
} from '../project';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../packageManager', () => ({
...jest.requireActual('../packageManager'),
findPackagerRootDir: jest.fn((dir: string) => dir),
}));
describe(runExpoCliCommand, () => {
describe('Expo SDK >= 46', () => {
@@ -75,6 +86,61 @@ describe(runExpoCliCommand, () => {
});
});
describe(isUsingModernYarnVersion, () => {
const projectDir = '/project';
const mockedFindPackagerRootDir = findPackagerRootDir as jest.MockedFunction<
typeof findPackagerRootDir
>;
beforeEach(() => {
mockedFindPackagerRootDir.mockReturnValue(projectDir);
jest.spyOn(fs, 'createReadStream').mockImplementation(((
filePath: string,
options?: { start?: number; end?: number }
) => {
const content = fs.readFileSync(filePath, 'utf8');
const start = options?.start ?? 0;
const end = options?.end != null ? options.end + 1 : content.length;
return Readable.from(Buffer.from(content.slice(start, end)));
}) as any);
});
it('returns true when .yarnrc.yml exists in project directory', async () => {
vol.fromJSON({ [path.join(projectDir, '.yarnrc.yml')]: '' });
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
});
it('returns true when .yarnrc.yml exists in workspace root directory', async () => {
const workspaceRoot = '/workspace-root';
mockedFindPackagerRootDir.mockReturnValue(workspaceRoot);
vol.fromJSON({ [path.join(workspaceRoot, '.yarnrc.yml')]: '' });
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
});
it('returns false when no .yarnrc.yml and no yarn.lock exist', async () => {
vol.fromJSON({ [projectDir]: null });
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
});
it('returns false when yarn.lock contains classic v1 header', async () => {
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n' });
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
});
it('returns true when yarn.lock does not contain classic v1 header', async () => {
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '__metadata:\n version: 8\n' });
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
});
it('returns true when .yarnrc.yml exists even with classic yarn.lock', async () => {
vol.fromJSON({
[path.join(projectDir, '.yarnrc.yml')]: '',
[path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n',
});
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
});
});
describe(readEasJsonContents, () => {
it('returns the raw eas.json contents even when it is not valid JSON', async () => {
const projectDir = '/project';
@@ -87,3 +153,23 @@ describe(readEasJsonContents, () => {
expect(readEasJsonContents(projectDir)).toBe(contents);
});
});
describe(readAndLogPackageJson, () => {
beforeEach(() => {
vol.reset();
});
it('reads package.json, logs it, and returns parsed contents', async () => {
const projectDir = '/project';
const packageJsonPath = path.join(projectDir, 'package.json');
const packageJson = { name: 'app', version: '1.0.0' };
const logger = { info: jest.fn() };
await fs.mkdirp(projectDir);
await fs.writeJson(packageJsonPath, packageJson);
expect(readAndLogPackageJson(logger, projectDir)).toEqual(packageJson);
expect(logger.info).toHaveBeenNthCalledWith(1, 'Using package.json:');
expect(logger.info).toHaveBeenNthCalledWith(2, JSON.stringify(packageJson, null, 2));
});
});
@@ -1,230 +0,0 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fg from 'fast-glob';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'yaml';
import { z } from 'zod';
const FlowConfigSchema = z.object({
name: z.string().optional(),
tags: z.array(z.string()).optional(),
});
const WorkspaceConfigSchema = z.object({
flows: z.array(z.string()).optional(),
executionOrder: z.record(z.string(), z.unknown()).optional(),
includeTags: z.array(z.string()).optional(),
excludeTags: z.array(z.string()).optional(),
});
type FlowConfig = z.infer<typeof FlowConfigSchema>;
type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
export async function findMaestroPathsFlowsToExecuteAsync({
workingDirectory,
flowPath,
includeTags: _includeTags,
excludeTags: _excludeTags,
logger,
}: {
workingDirectory: string;
flowPath: string;
includeTags: string[] | undefined;
excludeTags: string[] | undefined;
logger: bunyan;
}): Promise<string[]> {
const absoluteFlowPath = path.resolve(workingDirectory, flowPath);
// If it's a file, just return it (no validation needed)
const stat = await fs.stat(absoluteFlowPath);
if (stat.isFile()) {
logger.info(`Found a file: ${path.relative(workingDirectory, absoluteFlowPath)}`);
return [absoluteFlowPath];
}
// It's a directory - discover flow files
logger.info(`Found a directory: ${path.relative(workingDirectory, absoluteFlowPath)}`);
// Check for workspace config
logger.info(`Searching for workspace config...`);
const workspaceConfig = await findAndParseWorkspaceConfigAsync({
dirPath: absoluteFlowPath,
workingDirectory,
logger,
});
logger.info(`Using workspace config: ${JSON.stringify(workspaceConfig)}`);
if (workspaceConfig?.executionOrder) {
logger.warn(`Execution order is not supported yet. Ignoring.`);
}
logger.info(`Searching for flow files...`);
const { flows } = await findAndParseFlowFilesAsync({
dirPath: absoluteFlowPath,
workingDirectory,
workspaceConfig,
logger,
});
if (flows.length === 0) {
logger.info(
`No valid flow files found in: ${path.relative(workingDirectory, absoluteFlowPath)}`
);
return [];
}
const includeTags = [...(_includeTags ?? []), ...(workspaceConfig?.includeTags ?? [])];
const excludeTags = [...(_excludeTags ?? []), ...(workspaceConfig?.excludeTags ?? [])];
if (includeTags.length === 0 && excludeTags.length === 0) {
logger.info(`No tags provided, returning all flows.`);
return flows.map(({ path }) => path);
}
logger.info(
`Filtering flows by tags. Tags to include: ${JSON.stringify(includeTags)}. Tags to exclude: ${
JSON.stringify(excludeTags) ?? 'none'
}.`
);
return flows
.filter(({ config, path: flowPath }) => {
const shouldInclude = matchesTags({
flowTags: config?.tags ?? [],
includeTags,
excludeTags,
});
logger.info(
shouldInclude
? `- ${path.relative(workingDirectory, flowPath)} matches tags, including.`
: `- ${path.relative(workingDirectory, flowPath)} does not match tags, excluding.`
);
return shouldInclude;
})
.map(({ path }) => path);
}
async function findAndParseWorkspaceConfigAsync({
dirPath,
workingDirectory,
logger,
}: {
dirPath: string;
workingDirectory: string;
logger: bunyan;
}): Promise<WorkspaceConfig | null> {
const configPaths = await fg(['config.yaml', 'config.yml'], {
cwd: dirPath,
absolute: true,
});
if (configPaths.length === 0) {
logger.info(`No workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
return null;
}
for (const configPath of configPaths) {
try {
const content = await fs.readFile(configPath, 'utf-8');
const configDoc = yaml.parse(content);
if (!configDoc) {
logger.warn(
`No content found in workspace config: ${path.relative(workingDirectory, configPath)}`
);
continue;
}
logger.info(`Using workspace config from: ${path.relative(workingDirectory, configPath)}`);
return WorkspaceConfigSchema.parse(configDoc);
} catch (err) {
logger.warn(
{ err },
`Failed to parse workspace config: ${path.relative(workingDirectory, configPath)}`
);
continue;
}
}
logger.info(`No valid workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
return null;
}
async function findAndParseFlowFilesAsync({
workingDirectory,
dirPath,
workspaceConfig,
logger,
}: {
workingDirectory: string;
dirPath: string;
workspaceConfig: WorkspaceConfig | null;
logger: bunyan;
}): Promise<{ flows: { config: FlowConfig; path: string }[] }> {
const flows: { config: FlowConfig; path: string }[] = [];
// Determine flow patterns from config or use default
const flowPatterns = workspaceConfig?.flows ?? ['*'];
logger.info(`Using flow patterns: ${JSON.stringify(flowPatterns)}`);
// Use fast-glob to find matching files
const matchedFiles = await fg(flowPatterns, {
cwd: dirPath,
absolute: true,
onlyFiles: true,
ignore: ['*/config.yaml', '*/config.yml'], // Skip workspace config files
});
logger.info(`Found ${matchedFiles.length} potential flow files`);
// Parse each matched file
for (const filePath of matchedFiles) {
// Skip non-YAML files
const ext = path.extname(filePath);
if (ext !== '.yaml' && ext !== '.yml') {
logger.info(`Skipping non-YAML file: ${path.relative(workingDirectory, filePath)}`);
continue;
}
const result = await asyncResult(parseFlowFile(filePath));
if (result.ok) {
logger.info(`Found flow file: ${path.relative(workingDirectory, filePath)}`);
flows.push({ config: result.value, path: filePath });
} else {
logger.info(
{ err: result.reason },
`Skipping flow file: ${path.relative(workingDirectory, filePath)}`
);
}
}
return { flows };
}
async function parseFlowFile(filePath: string): Promise<FlowConfig> {
const content = await fs.readFile(filePath, 'utf-8');
const documents = yaml.parseAllDocuments(content);
const configDoc = documents[0];
if (!configDoc) {
throw new Error(`No config section found in ${filePath}`);
}
return FlowConfigSchema.parse(configDoc.toJS());
}
function matchesTags({
flowTags,
includeTags,
excludeTags,
}: {
flowTags: string[];
includeTags: string[];
excludeTags: string[];
}): boolean {
// Include logic: if includeTags is empty OR flow has any of the include tags
const includeMatch = includeTags.length === 0 || includeTags.some(tag => flowTags.includes(tag));
// Exclude logic: if excludeTags is empty OR flow has none of the exclude tags
const excludeMatch = excludeTags.length === 0 || !excludeTags.some(tag => flowTags.includes(tag));
return includeMatch && excludeMatch;
}
@@ -1,174 +0,0 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { BuildContext } from '../context';
let precompiledModulesPreparationPromise: Promise<void> | null = null;
const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000;
export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules');
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
return env.EAS_USE_PRECOMPILED_MODULES === '1';
}
export function maybeStartPreparingPrecompiledModules(
ctx: BuildContext,
config: { precompiledModulesUrls: string[] }
): void {
if (!shouldUsePrecompiledDependencies(ctx.env)) {
return;
}
startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrls);
}
export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: string[]): void {
precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({
logger: ctx.logger,
urls,
destinationDirectory: PRECOMPILED_MODULES_PATH,
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
}
export async function waitForPrecompiledModulesPreparationAsync(): Promise<void> {
if (!precompiledModulesPreparationPromise) {
return;
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
precompiledModulesPreparationPromise,
new Promise<void>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(
new Error(
`Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`
)
);
}, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
timeoutHandle.unref?.();
}),
]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function preparePrecompiledDependenciesAsync({
logger,
urls,
destinationDirectory,
cocoapodsProxyUrl,
}: {
logger: bunyan;
urls: string[];
destinationDirectory: string;
cocoapodsProxyUrl?: string;
}): Promise<void> {
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
const destinationParentDirectory = path.dirname(destinationDirectory);
await fs.mkdirp(destinationParentDirectory);
const stagingDirectory = await fs.mkdtemp(
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
);
logger.info('');
logger.info(
{
destinationDirectory,
urls,
},
'Starting precompiled dependencies download'
);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
try {
const archives = urls.map((url, index) => ({
archiveName: path.basename(new URL(url).pathname),
url,
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
}));
await Promise.all(
archives.map(async ({ url, archivePath }) => {
await downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
});
})
);
for (const { archiveName, url, archivePath } of archives) {
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
await extractZipAsync(archivePath, stagingDirectory);
logger.info(
{ destinationDirectory: stagingDirectory },
`Extracted ${archiveName} into staging precompiled dependencies directory`
);
}
await fs.remove(destinationDirectory);
await fs.move(stagingDirectory, destinationDirectory);
logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`);
} catch (error) {
await fs.remove(stagingDirectory);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
throw error;
} finally {
await fs.remove(archiveDirectory);
}
}
async function downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
}: {
url: string;
archivePath: string;
cocoapodsProxyUrl?: string;
logger: bunyan;
}): Promise<void> {
const proxiedUrl = cocoapodsProxyUrl
? (() => {
const parsedUrl = new URL(url);
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
})()
: null;
if (proxiedUrl) {
try {
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
return;
} catch (err) {
logger.warn(
{ err, proxiedUrl },
'Failed to download through CocoaPods proxy, retrying directly'
);
}
}
await downloadFile(url, archivePath, { retry: 3 });
}
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
const zip = new StreamZip.async({ file: archivePath });
try {
await zip.extract(null, destinationDirectory);
} finally {
await zip.close();
}
}
+42 -2
View File
@@ -4,13 +4,43 @@ import path from 'path';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
async function readFirstChars(filePath: string, chars: number): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Uint8Array[] = [];
const stream = fs.createReadStream(filePath, {
start: 0,
end: chars - 1,
});
stream.on('error', reject);
stream.on('data', chunk => {
chunks.push(chunk as Uint8Array);
});
stream.on('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'));
});
});
}
/**
* check if .yarnrc.yml exists in the project dir or in the workspace root dir
* check if .yarnrc.yml exists in the project dir or in the workspace root dir,
* or if the `yarn.lock` file is classic one, not a modern one
*/
export async function isUsingModernYarnVersion(projectDir: string): Promise<boolean> {
const rootDir = findPackagerRootDir(projectDir);
const yarnrcPath = path.join(projectDir, '.yarnrc.yml');
const yarnrcRootPath = path.join(findPackagerRootDir(projectDir), '.yarnrc.yml');
return (await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath));
if ((await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath))) {
return true;
}
const yarnlockPath = path.join(rootDir, 'yarn.lock');
if (!(await fs.pathExists(yarnlockPath))) {
return false;
}
// The yarn.lock file is for Yarn Classic, not Modern, if it contains "# yarn lockfile v1"
const startOfLockfile = await readFirstChars(yarnlockPath, 100);
return !/yarn lockfile v1/i.test(startOfLockfile);
}
export function runExpoCliCommand({
@@ -48,6 +78,16 @@ export function readPackageJson(projectDir: string): any {
}
}
export function readAndLogPackageJson(
logger: { info: (message: string) => void },
projectDir: string
): any {
logger.info('Using package.json:');
const packageJson = readPackageJson(projectDir);
logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
}
export function readEasJsonContents(projectDir: string): string {
const easJsonPath = path.join(projectDir, 'eas.json');
if (!fs.pathExistsSync(easJsonPath)) {
+1
View File
@@ -11,6 +11,7 @@
"composite": true,
"rootDir": "./src",
"outDir": "./dist",
"types": ["jest", "node"],
"plugins": [
{
"name": "gql.tada/ts-plugin",
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.4.0",
"version": "18.6.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.4.0"
"@expo/steps": "18.6.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "18.0.1",
"version": "18.5.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": "18.0.2",
"version": "18.5.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",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.4.0",
"version": "18.6.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -24,6 +24,428 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
cf_bundle_version: '42',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
cf_bundle_version: '42',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context without build_upload', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context with app_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
app_version: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'ready_for_review',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect app_version state', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
app_version: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('accepts app_store_connect context with external_beta', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
external_beta: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'beta_approved',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect external_beta state', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
external_beta: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('accepts app_store_connect context with beta_feedback', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'screenshot',
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects app_store_connect beta_feedback without url', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'crash',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app_store_connect beta_feedback type', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'invalid-type',
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app_store_connect build_upload context', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: 'not-a-uuid',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app and account context', () => {
const context = {
after: {},
+68
View File
@@ -226,6 +226,72 @@ const GitHubContextZ = z.object({
.optional(),
});
const AppStoreConnectContextZ = z.looseObject({
app: z.looseObject({
id: z.string(),
}),
app_version: z
.looseObject({
id: z.string(),
state: z.enum([
'accepted',
'developer_rejected',
'in_review',
'invalid_binary',
'metadata_rejected',
'pending_apple_release',
'pending_developer_release',
'prepare_for_submission',
'processing_for_distribution',
'ready_for_distribution',
'ready_for_review',
'rejected',
'replaced_with_new_version',
'waiting_for_export_compliance',
'waiting_for_review',
]),
})
.optional(),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
cf_bundle_version: z.string().optional(),
build: z
.looseObject({
id: z.string(),
})
.optional(),
})
.optional(),
external_beta: z
.looseObject({
id: z.string(),
state: z.enum([
'processing',
'processing_exception',
'missing_export_compliance',
'ready_for_beta_testing',
'in_beta_testing',
'expired',
'ready_for_beta_submission',
'in_export_compliance_review',
'waiting_for_beta_review',
'in_beta_review',
'beta_rejected',
'beta_approved',
]),
})
.optional(),
beta_feedback: z
.looseObject({
id: z.string(),
type: z.enum(['crash', 'screenshot']),
url: z.string(),
})
.optional(),
});
export const StaticWorkflowInterpolationContextZ = z.object({
after: z.record(
z.string(),
@@ -259,6 +325,8 @@ export const StaticWorkflowInterpolationContextZ = z.object({
id: z.string(),
name: z.string(),
}),
// We need to .optional() to support jobs that are not triggered by an App Store Connect event.
app_store_connect: AppStoreConnectContextZ.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
+94 -91
View File
@@ -178,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -195,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -217,7 +217,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -234,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -248,7 +248,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -300,7 +300,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -321,7 +321,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -341,7 +341,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -361,7 +361,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -384,7 +384,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -422,7 +422,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -442,7 +442,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -460,7 +460,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -480,7 +480,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -488,7 +488,7 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
```
USAGE
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found]
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
@@ -496,12 +496,13 @@ FLAGS
build profile will be selected by default.
-p, --platform=<option> <options: ios|android>
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
--skip-bundler Install and run the development build without starting the bundler server.
DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -522,7 +523,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -560,7 +561,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -600,7 +601,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -630,7 +631,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -656,7 +657,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -708,7 +709,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -726,7 +727,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -744,7 +745,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -761,7 +762,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -782,7 +783,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -803,7 +804,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -825,7 +826,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -845,7 +846,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -867,7 +868,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -889,7 +890,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -924,7 +925,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -947,7 +948,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -968,7 +969,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -985,7 +986,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1003,7 +1004,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1032,7 +1033,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1057,7 +1058,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1078,7 +1079,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1099,7 +1100,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1136,7 +1137,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1156,7 +1157,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1177,7 +1178,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1198,7 +1199,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1212,7 +1213,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1226,7 +1227,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1258,7 +1259,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1284,7 +1285,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1306,7 +1307,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1334,7 +1335,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1362,7 +1363,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1385,7 +1386,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1407,7 +1408,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1441,7 +1442,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1485,7 +1486,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1516,7 +1517,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1625,7 +1626,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1633,16 +1634,17 @@ generate the local store configuration from the app stores
```
USAGE
$ eas metadata:pull [-e <value>]
$ eas metadata:pull [-e <value>] [--non-interactive]
FLAGS
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1650,16 +1652,17 @@ sync the local store configuration to the app stores
```
USAGE
$ eas metadata:push [-e <value>]
$ eas metadata:push [-e <value>] [--non-interactive]
FLAGS
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1711,7 +1714,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1725,7 +1728,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1748,7 +1751,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1772,7 +1775,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1790,7 +1793,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1823,7 +1826,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1866,7 +1869,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1887,7 +1890,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1908,7 +1911,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1933,7 +1936,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1960,7 +1963,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1998,7 +2001,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2026,7 +2029,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2056,7 +2059,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2077,7 +2080,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2097,7 +2100,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2118,7 +2121,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2140,7 +2143,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2160,7 +2163,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2179,7 +2182,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2202,7 +2205,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2219,7 +2222,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2340,7 +2343,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2360,7 +2363,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2384,7 +2387,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2424,7 +2427,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2446,7 +2449,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2474,7 +2477,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2494,7 +2497,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2516,5 +2519,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+2868 -545
View File
File diff suppressed because it is too large. Load diff
+11 -8
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.4.0",
"version": "18.7.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -43,15 +43,15 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.15",
"@expo/apple-utils": "2.1.19",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "18.4.0",
"@expo/eas-json": "18.4.0",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "18.6.0",
"@expo/eas-json": "18.6.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.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": "18.4.0",
"@expo/steps": "18.6.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -204,6 +204,9 @@
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
+213
View File
@@ -315,6 +315,42 @@
"liveEdits": true,
"optional": true
}
},
"screenshots": {
"type": "object",
"description": "Screenshots for this locale, organized by display type (e.g., APP_IPHONE_67, APP_IPAD_PRO_129)",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"previews": {
"type": "object",
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
"additionalProperties": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Video file path (relative to project root)"
},
"previewFrameTimeCode": {
"type": "string",
"description": "Optional preview frame time code (e.g., '00:05:00' for 5 seconds)"
}
},
"required": ["path"],
"additionalProperties": false
}
]
}
}
}
},
@@ -479,6 +515,57 @@
"lootBox": {
"type": "boolean",
"description": "Does your app contain purchasable loot boxes? These virtual containers provide players with in-game items, including functional cards or cosmetic modifications, based on random chance."
},
"advertising": {
"type": "boolean",
"description": "Does your app display advertising?"
},
"ageAssurance": {
"type": "boolean",
"description": "Does your app implement age assurance mechanisms?"
},
"ageRatingOverrideV2": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/definitions/apple/AppleAgeRatingOverrideV2"
}
],
"description": "Override the age rating using the V2 rating system. Allows setting a more granular age rating than the auto-calculated value."
},
"developerAgeRatingInfoUrl": {
"oneOf": [
{
"type": "null"
},
{
"type": "string",
"format": "uri"
}
],
"description": "URL to additional developer-provided information about the app's age rating."
},
"gunsOrOtherWeapons": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain depictions of guns or other weapons?"
},
"healthOrWellnessTopics": {
"type": "boolean",
"description": "Does your app contain health or wellness topics?"
},
"messagingAndChat": {
"type": "boolean",
"description": "Does your app contain messaging or chat functionality?"
},
"parentalControls": {
"type": "boolean",
"description": "Does your app implement parental controls?"
},
"userGeneratedContent": {
"type": "boolean",
"description": "Does your app contain or display user-generated content?"
}
}
},
@@ -601,6 +688,118 @@
}
}
},
"AppleAppClip": {
"type": "object",
"additionalProperties": false,
"description": "App Clip metadata. Only applies to apps that ship an App Clip target.",
"properties": {
"defaultExperience": {
"$ref": "#/definitions/apple/AppleAppClipDefaultExperience",
"description": "The default experience for this App Clip. There is exactly one per app."
}
}
},
"AppleAppClipDefaultExperience": {
"type": "object",
"additionalProperties": false,
"description": "Default experience for an App Clip - the configuration shown when invoked without a custom experience.",
"properties": {
"action": {
"enum": ["OPEN", "VIEW", "PLAY"],
"description": "Action button shown in the App Clip card."
},
"releaseWithAppStoreVersion": {
"type": "boolean",
"description": "Whether to release this default experience alongside the next App Store version."
},
"reviewDetail": {
"$ref": "#/definitions/apple/AppleAppClipReviewDetail",
"description": "App Store review information for the App Clip default experience."
},
"info": {
"$ref": "#/definitions/apple/AppleAppClipInfo",
"description": "Localized subtitle and header image for the App Clip default experience."
}
}
},
"AppleAppClipReviewDetail": {
"type": "object",
"additionalProperties": false,
"description": "App Store review detail for an App Clip default experience.",
"required": ["invocationUrls"],
"properties": {
"invocationUrls": {
"type": "array",
"description": "Invocation URLs that App Review will use to launch the App Clip during review.",
"items": {
"type": "string",
"format": "uri"
}
}
}
},
"AppleAppClipLocalizedInfo": {
"type": "object",
"additionalProperties": false,
"description": "Localized subtitle and header image for an App Clip default experience.",
"properties": {
"subtitle": {
"type": "string",
"description": "Subtitle shown in the App Clip card. Apple limits this to 43 characters.",
"maxLength": 43
},
"headerImage": {
"type": "string",
"description": "Relative path (from project root) to the App Clip header image PNG."
}
}
},
"AppleAppClipInfo": {
"type": "object",
"description": "Localized App Clip information, keyed by locale.",
"additionalProperties": false,
"properties": {
"ar-SA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Arabic" },
"ca": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Catalan" },
"zh-Hans": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Chinese (Simplified)" },
"zh-Hant": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Chinese (Traditional)" },
"hr": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Croatian" },
"cs": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Czech" },
"da": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Danish" },
"nl-NL": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Dutch" },
"en-AU": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (Australia)" },
"en-CA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (Canada)" },
"en-GB": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (U.K.)" },
"en-US": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (U.S.)" },
"fi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Finnish" },
"fr-FR": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "French" },
"fr-CA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "French (Canada)" },
"de-DE": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "German" },
"el": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Greek" },
"he": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hebrew" },
"hi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hindi" },
"hu": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hungarian" },
"id": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Indonesian" },
"it": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Italian" },
"ja": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Japanese" },
"ko": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Korean" },
"ms": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Malay" },
"no": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Norwegian" },
"pl": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Polish" },
"pt-BR": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Portuguese (Brazil)" },
"pt-PT": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Portuguese (Portugal)" },
"ro": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Romanian" },
"ru": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Russian" },
"sk": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Slovak" },
"es-MX": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Spanish (Mexico)" },
"es-ES": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Spanish (Spain)" },
"sv": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Swedish" },
"th": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Thai" },
"tr": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Turkish" },
"uk": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Ukrainian" },
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -614,6 +813,16 @@
"FIFTEEN_PLUS",
"NINETEEN_PLUS"
]
},
"AppleAgeRatingOverrideV2": {
"enum": [
"NONE",
"NINE_PLUS",
"THIRTEEN_PLUS",
"SIXTEEN_PLUS",
"EIGHTEEN_PLUS",
"UNRATED"
]
}
},
"AppleConfig": {
@@ -821,6 +1030,10 @@
"review": {
"$ref": "#/definitions/apple/AppleReview",
"description": "Info for the App Store reviewer"
},
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
}
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ async function validateAndroidPNGsAsync(ctx: CommonContext<Platform.ANDROID>): P
},
{
configPath: 'exp.notification.icon',
pngPath: ctx.exp.notification?.icon,
pngPath: (ctx.exp as Record<string, any>).notification?.icon,
},
];
await validatePNGsAsync(pngs);
@@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = (
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
description,
}: {
defaultTo: number;
limit: number;
description?: string;
}): Interfaces.OptionFlag<number | undefined> =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
description:
description ??
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
+10 -2
View File
@@ -47,6 +47,10 @@ export default class BuildDev extends EasCommand {
description: 'Skip build if no successful build with matching fingerprint is found.',
default: false,
}),
'skip-bundler': Flags.boolean({
description: 'Install and run the development build without starting the bundler server.',
default: false,
}),
};
static override contextDefinition = {
@@ -129,7 +133,9 @@ export default class BuildDev extends EasCommand {
if (build.artifacts?.applicationArchiveUrl) {
await downloadAndRunAsync(build);
await this.startDevServerAsync({ projectDir, platform });
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
return;
} else {
Log.warn('Artifacts for this build expired.');
@@ -182,7 +188,9 @@ export default class BuildDev extends EasCommand {
downloadSimBuildAutoConfirm: true,
envOverride: env,
});
await this.startDevServerAsync({ projectDir, platform });
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
}
private async selectPlatformAsync(platform?: Platform): Promise<Platform> {
+16 -2
View File
@@ -362,8 +362,22 @@ export default class WorkerDeploy extends EasCommand {
throw error;
}
await uploadAssetsAsync(assetFiles, deployResult);
await finalizeDeployAsync(deployResult);
let uploadError: unknown;
try {
await uploadAssetsAsync(assetFiles, deployResult);
} catch (error) {
uploadError = error;
}
try {
await finalizeDeployAsync(deployResult);
} catch (finalizeError) {
if (!uploadError) {
throw finalizeError;
}
}
if (uploadError) {
throw uploadError;
}
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
if (flags.aliasName) {
@@ -0,0 +1,11 @@
# CLAUDE.md - metadata commands
`metadata:push` validates `store.config.json` against `packages/eas-cli/schema/metadata-0.json`. The schema can drift from the apple config reader/writer, and when it does `metadata:pull` happily writes fields that `metadata:push` then rejects — and the failure looks like the user's fault.
**Whenever you touch `src/metadata/apple/config/{reader,writer}.ts`, update `metadata-0.json` in the same change.** For new advisory fields:
- Add the property under `definitions.apple.AppleAdvisory.properties`.
- For enums from `@expo/apple-utils`, add a matching `Apple<Name>` enum definition with the exact string values and reference it via `$ref`. Wrap in `oneOf` with `{ "type": "null" }` if the writer ever persists `null`.
- Don't add to `required` — that breaks existing user configs. Optional with a sensible default in the writer is the convention.
The drift is caught by `src/metadata/apple/config/__tests__/writer.test.ts` ("writer output for ... passes JSON schema validation"). If you change apple-utils types or fixtures, that test will tell you exactly what the schema is missing.
@@ -6,6 +6,7 @@ import path from 'path';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { CredentialsContext } from '../../credentials/context';
import Log, { learnMore } from '../../log';
import { downloadMetadataAsync } from '../../metadata/download';
@@ -21,10 +22,7 @@ export default class MetadataPull extends EasCommand {
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run the command in non-interactive mode.',
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
@@ -4,6 +4,7 @@ import { Flags } from '@oclif/core';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { CredentialsContext } from '../../credentials/context';
import Log, { learnMore } from '../../log';
import { handleMetadataError } from '../../metadata/errors';
@@ -19,10 +20,7 @@ export default class MetadataPush extends EasCommand {
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run the command in non-interactive mode.',
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
@@ -0,0 +1,280 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { 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('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveEvents, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
function createCommand(argv: string[]): ObserveEvents {
const command = new ObserveEvents(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti', '--days', '7']);
await command.runAsync();
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
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',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-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-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']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --limit to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.after).toBe('cursor-xyz');
});
it('does not pass after when --after flag is not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options).not.toHaveProperty('after');
});
it('rejects --days combined with --end', async () => {
const command = createCommand(['tti', '--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
const command = createCommand(['tti', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes --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(mockBuildObserveEventsJson).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('throws in non-interactive mode when no metric is provided', async () => {
const command = createCommand(['--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
'metric argument is required in non-interactive mode'
);
});
});
describe(resolveOrderBy, () => {
it('resolves lowercase "slowest" to MetricValue DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
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,
});
});
});
@@ -0,0 +1,224 @@
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 ObserveMetrics from '../metrics';
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 mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveMetrics, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveMetricsAsync.mockResolvedValue({
metricsMap: new Map(),
buildNumbersMap: new Map(),
updateIdsMap: new Map(),
totalEventCounts: new Map(),
});
});
function createCommand(argv: string[]): ObserveMetrics {
const command = new ObserveMetrics(argv, mockConfig);
// @ts-expect-error 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(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsAsync.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 = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
});
it('queries only iOS when --platform ios is passed', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
await command.runAsync();
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
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');
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 = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['p90', 'eventCount'],
expect.objectContaining({ daysBack: 60 })
);
});
it('deduplicates --stat flags that resolve to the same key', async () => {
const command = createCommand(['--stat', 'median', '--stat', 'median']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median'],
expect.objectContaining({ daysBack: 60 })
);
});
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days', '7']);
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');
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(mockBuildObserveMetricsTable).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(mockBuildObserveMetricsJson).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();
});
});
@@ -0,0 +1,204 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveVersionsAsync } from '../../../observe/fetchVersions';
import {
buildObserveVersionsJson,
buildObserveVersionsTable,
} from '../../../observe/formatVersions';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveVersions from '../versions';
jest.mock('../../../observe/fetchVersions');
jest.mock('../../../observe/formatVersions', () => ({
buildObserveVersionsTable: jest.fn().mockReturnValue('table'),
buildObserveVersionsJson: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveVersionsAsync = jest.mocked(fetchObserveVersionsAsync);
const mockBuildObserveVersionsTable = jest.mocked(buildObserveVersionsTable);
const mockBuildObserveVersionsJson = jest.mocked(buildObserveVersionsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveVersions, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveVersionsAsync.mockResolvedValue([]);
});
function createCommand(argv: string[]): ObserveVersions {
const command = new ObserveVersions(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches versions 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(mockFetchObserveVersionsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
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 = mockFetchObserveVersionsAsync.mock.calls[0][2];
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 = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Ios]);
});
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 = mockFetchObserveVersionsAsync.mock.calls[0][3];
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
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 = mockFetchObserveVersionsAsync.mock.calls[0][3];
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--start', '2025-01-01T00:00:00.000Z']);
await command.runAsync();
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
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 = mockFetchObserveVersionsAsync.mock.calls[0][3];
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
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('passes the context project ID to fetchObserveVersionsAsync by default', async () => {
const command = createCommand([]);
await command.runAsync();
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
expect(appId).toBe(projectId);
});
it('uses --project-id flag when provided', async () => {
const command = createCommand(['--project-id', 'override-project-id']);
await command.runAsync();
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
expect(appId).toBe('override-project-id');
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
mockFetchObserveVersionsAsync.mockResolvedValue([
{
platform: AppPlatform.Ios,
appVersions: [
{
appVersion: '1.0.0',
firstSeenAt: '2025-06-01T00:00:00.000Z',
eventCount: 100,
uniqueUserCount: 50,
buildNumbers: [],
updates: [],
metrics: [],
},
],
},
]);
const command = createCommand(['--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveVersionsJson).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('does not call enableJsonOutput when --json is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('calls buildObserveVersionsTable for non-json output', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveVersionsTable).toHaveBeenCalled();
});
});
@@ -0,0 +1,180 @@
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 { AppObservePlatform, AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import {
EventsOrderPreset,
fetchObserveEventsAsync,
fetchTotalEventCountAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
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 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(),
})(),
platform: Flags.option({
description: 'Filter by platform',
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
})(),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_EVENTS_LIMIT,
limit: 100,
}),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'app-version': Flags.string({
description: 'Filter by app version',
}),
'update-id': Flags.string({
description: 'Filter by EAS update ID',
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...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(ObserveEvents);
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveEvents.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
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 = flags.platform
? flags.platform === 'android'
? AppObservePlatform.Android
: AppObservePlatform.Ios
: undefined;
const platforms: AppPlatform[] = platform
? [platform === AppObservePlatform.Android ? AppPlatform.Android : AppPlatform.Ios]
: [AppPlatform.Android, AppPlatform.Ios];
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,
metricName,
platforms,
startTime,
endTime
),
]);
if (flags.json) {
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveEventsTable(events, pageInfo, {
metricName,
daysBack,
startTime,
endTime,
totalEventCount,
})
);
}
}
}
@@ -0,0 +1,159 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
resolveStatKey,
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
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 ObserveMetrics extends EasCommand {
static override hidden = true;
static override description = 'display app performance metrics grouped by app version';
static override flags = {
platform: Flags.option({
description: 'Filter by platform',
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
})(),
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,
})(),
start: Flags.string({
description: 'Start of time range for metrics data (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range for metrics data (ISO date).',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...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(ObserveMetrics);
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveMetrics.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
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;
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platforms: AppPlatform[] = flags.platform
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
: [AppPlatform.Android, AppPlatform.Ios];
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,
})
);
}
}
}
@@ -0,0 +1,96 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
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 = {
platform: Flags.option({
description: 'Filter by platform',
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
})(),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show versions from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...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(ObserveVersions);
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveVersions.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveVersions, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
const { startTime, endTime } = resolveTimeRange(flags);
const platforms: AppPlatform[] = flags.platform
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
: [AppPlatform.Android, AppPlatform.Ios];
const results = await fetchObserveVersionsAsync(
graphqlClient,
projectId,
platforms,
startTime,
endTime
);
if (flags.json) {
printJsonOnlyOutput(buildObserveVersionsJson(results));
} else {
Log.addNewLineIfNone();
Log.log(buildObserveVersionsTable(results));
}
}
}
@@ -10,7 +10,11 @@ import { getAppFromContextAsync } from './actions/BuildCredentialsUtils';
import { SetUpBuildCredentials } from './actions/SetUpBuildCredentials';
import { SetUpPushKey } from './actions/SetUpPushKey';
import { App, IosCredentials, Target } from './types';
import { isAdHocProfile, isEnterpriseUniversalProfile } from './utils/provisioningProfile';
import {
isAdHocProfile,
isDevelopmentProfile,
isEnterpriseUniversalProfile,
} from './utils/provisioningProfile';
import { CommonIosAppCredentialsFragment } from '../../graphql/generated';
import Log from '../../log';
import { findApplicationTarget } from '../../project/ios/target';
@@ -150,6 +154,7 @@ export default class IosCredentialsProvider {
private assertProvisioningProfileType(provisioningProfile: string, targetName?: string): void {
const isAdHoc = isAdHocProfile(provisioningProfile);
const isDevelopment = isDevelopmentProfile(provisioningProfile);
const isEnterprise = isEnterpriseUniversalProfile(provisioningProfile);
if (this.options.distribution === 'internal') {
if (this.options.enterpriseProvisioning === 'universal' && !isEnterprise) {
@@ -164,16 +169,21 @@ export default class IosCredentialsProvider {
targetName ? ` (target '${targetName})'` : ''
} for internal distribution if you specified "enterpriseProvisioning": "adhoc" in eas.json`
);
} else if (!this.options.enterpriseProvisioning && !isEnterprise && !isAdHoc) {
} else if (
!this.options.enterpriseProvisioning &&
!isEnterprise &&
!isAdHoc &&
!isDevelopment
) {
throw new Error(
`You must use an adhoc provisioning profile${
`You must use an adhoc or development provisioning profile${
targetName ? ` (target '${targetName})'` : ''
} for internal distribution.`
);
}
} else if (isAdHoc) {
} else if (isAdHoc || isDevelopment) {
throw new Error(
`You can't use an adhoc provisioning profile${
`You can't use ${isAdHoc ? 'an adhoc' : 'a development'} provisioning profile${
targetName ? ` (target '${targetName}')` : ''
} for app store distribution.`
);
@@ -23,7 +23,21 @@ export function readProfileName(dataBase64: string): string {
export function isAdHocProfile(dataBase64: string): boolean {
const profilePlist = parse(dataBase64);
const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined;
return Array.isArray(provisionedDevices);
if (!Array.isArray(provisionedDevices)) {
return false;
}
const entitlements = profilePlist['Entitlements'] as PlistObject | undefined;
return !entitlements?.['get-task-allow'];
}
export function isDevelopmentProfile(dataBase64: string): boolean {
const profilePlist = parse(dataBase64);
const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined;
if (!Array.isArray(provisionedDevices)) {
return false;
}
const entitlements = profilePlist['Entitlements'] as PlistObject | undefined;
return !!entitlements?.['get-task-allow'];
}
export function isEnterpriseUniversalProfile(dataBase64: string): boolean {
+393 -70
View File
@@ -120,6 +120,8 @@ export type Account = {
/** Billing information. Only visible to members with the ADMIN or OWNER role. */
billing?: Maybe<Billing>;
billingPeriod: BillingPeriod;
/** Convex team connections for this account */
convexTeamConnections: Array<ConvexTeamConnection>;
createdAt: Scalars['DateTime']['output'];
displayName?: Maybe<Scalars['String']['output']>;
/** Echo projects for this account (paginated, most recent first) */
@@ -155,6 +157,8 @@ export type Account = {
name: Scalars['String']['output'];
/** Offers set on this account */
offers?: Maybe<Array<Offer>>;
/** Onboarding milestone stats for this account */
onboardingStats: AccountOnboardingStats;
/**
* Owning User of this account if personal account
* @deprecated Deprecated in favor of ownerUserActor
@@ -767,6 +771,15 @@ export type AccountNotificationPreferenceInput = {
type: NotificationType;
};
/** Onboarding milestone stats for an account */
export type AccountOnboardingStats = {
__typename?: 'AccountOnboardingStats';
firstBuildCompletedAt?: Maybe<Scalars['DateTime']['output']>;
firstSubmissionCompletedAt?: Maybe<Scalars['DateTime']['output']>;
hasConfiguredUpdate: Scalars['Boolean']['output'];
hasTeamMembers: Scalars['Boolean']['output'];
};
export type AccountQuery = {
__typename?: 'AccountQuery';
/** Query an Account by ID */
@@ -1116,7 +1129,6 @@ export type AndroidAppCredentialsMutationCreateAndroidAppCredentialsArgs = {
export type AndroidAppCredentialsMutationCreateFcmV1CredentialArgs = {
accountId: Scalars['ID']['input'];
androidAppCredentialsId: Scalars['String']['input'];
credential: Scalars['String']['input'];
};
@@ -1358,6 +1370,7 @@ export type App = Project & {
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
/** Android app credentials for the project */
androidAppCredentials: Array<AndroidAppCredentials>;
appStoreConnectApp?: Maybe<AppStoreConnectApp>;
/**
* ios.appStoreUrl field from most recent classic update manifest
* @deprecated Classic updates have been deprecated.
@@ -1490,6 +1503,7 @@ export type App = Project & {
timelineActivity: TimelineActivityConnection;
/** @deprecated 'likes' have been deprecated. */
trendScore: Scalars['Float']['output'];
turtleBrownfieldArtifactsPaginated: BrownfieldArtifactsConnection;
/** get an EAS branch owned by the app by name */
updateBranchByName?: Maybe<UpdateBranch>;
/** EAS branches owned by an app */
@@ -1708,6 +1722,16 @@ export type AppTimelineActivityArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppTurtleBrownfieldArtifactsPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<BrownfieldArtifactFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppUpdateBranchByNameArgs = {
name: Scalars['String']['input'];
@@ -2050,14 +2074,19 @@ export type AppNotificationPreferenceInput = {
export type AppObserve = {
__typename?: 'AppObserve';
appVersions: Array<AppObserveAppVersion>;
environments: Array<Scalars['String']['output']>;
events: AppObserveEventsConnection;
releases: Array<AppObserveRelease>;
timeSeries: AppObserveTimeSeries;
totalEventCount: Scalars['Int']['output'];
};
export type AppObserveAppVersionsArgs = {
input: AppObserveReleasesInput;
};
export type AppObserveEnvironmentsArgs = {
endTime?: InputMaybe<Scalars['DateTime']['input']>;
platform?: InputMaybe<AppObservePlatform>;
@@ -2075,15 +2104,54 @@ export type AppObserveEventsArgs = {
};
export type AppObserveReleasesArgs = {
input: AppObserveReleasesInput;
};
export type AppObserveTimeSeriesArgs = {
input: AppObserveTimeSeriesInput;
};
export type AppObserveAppBuildNumber = {
__typename?: 'AppObserveAppBuildNumber';
appBuildNumber: Scalars['String']['output'];
easBuilds: Array<AppObserveAppEasBuild>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
export type AppObserveAppEasBuild = {
__typename?: 'AppObserveAppEasBuild';
easBuildId: Scalars['String']['output'];
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
export type AppObserveAppUpdate = {
__typename?: 'AppObserveAppUpdate';
appUpdateId: Scalars['String']['output'];
easBuilds: Array<AppObserveAppEasBuild>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
export type AppObserveAppVersion = {
__typename?: 'AppObserveAppVersion';
appVersion: Scalars['String']['output'];
buildNumbers: Array<AppObserveAppBuildNumber>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
metrics: Array<AppObserveAppVersionMetric>;
uniqueUserCount: Scalars['Int']['output'];
updates: Array<AppObserveAppUpdate>;
};
export type AppObserveAppVersionMetric = {
__typename?: 'AppObserveAppVersionMetric';
eventCount: Scalars['Int']['output'];
metricName: Scalars['String']['output'];
statistics: AppObserveVersionMarkerStatistics;
};
export type AppObserveEvent = {
__typename?: 'AppObserveEvent';
appBuildNumber: Scalars['String']['output'];
@@ -2093,6 +2161,7 @@ export type AppObserveEvent = {
appVersion: Scalars['String']['output'];
clientVersion?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
customParams?: Maybe<Scalars['JSON']['output']>;
deviceLanguageTag?: Maybe<Scalars['String']['output']>;
deviceModel: Scalars['String']['output'];
deviceName?: Maybe<Scalars['String']['output']>;
@@ -2129,6 +2198,8 @@ export type AppObserveEventsConnection = {
};
export type AppObserveEventsFilter = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
easClientId?: InputMaybe<Scalars['String']['input']>;
@@ -2160,35 +2231,22 @@ export enum AppObservePlatform {
Ios = 'IOS'
}
export type AppObserveRelease = {
__typename?: 'AppObserveRelease';
appUpdateId?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
firstSeenAt: Scalars['DateTime']['output'];
type: AppObserveReleaseType;
};
export enum AppObserveReleaseType {
Update = 'UPDATE',
Version = 'VERSION'
}
export type AppObserveReleasesInput = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
metricNames?: InputMaybe<Array<Scalars['String']['input']>>;
platform: AppObservePlatform;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveTimeSeries = {
__typename?: 'AppObserveTimeSeries';
appVersionMarkers: Array<AppObserveAppVersion>;
buckets: Array<AppObserveTimeSeriesBucket>;
eventCount: Scalars['Int']['output'];
latestVersionStatistics?: Maybe<AppObserveVersionStatistics>;
previousVersionStatistics?: Maybe<AppObserveVersionStatistics>;
statistics: AppObserveTimeSeriesStatistics;
updateMarkers: Array<AppObserveUpdateMarker>;
versionMarkers: Array<AppObserveVersionMarker>;
};
export type AppObserveTimeSeriesBucket = {
@@ -2205,6 +2263,8 @@ export type AppObserveTimeSeriesBucket = {
};
export type AppObserveTimeSeriesInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
@@ -2227,22 +2287,6 @@ export type AppObserveTimeSeriesStatistics = {
p99?: Maybe<Scalars['Float']['output']>;
};
export type AppObserveUpdateMarker = {
__typename?: 'AppObserveUpdateMarker';
appUpdateId: Scalars['String']['output'];
appVersion: Scalars['String']['output'];
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
};
export type AppObserveVersionMarker = {
__typename?: 'AppObserveVersionMarker';
appVersion: Scalars['String']['output'];
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
statistics: AppObserveVersionMarkerStatistics;
};
export type AppObserveVersionMarkerStatistics = {
__typename?: 'AppObserveVersionMarkerStatistics';
average?: Maybe<Scalars['Float']['output']>;
@@ -2376,10 +2420,16 @@ export type AppStoreConnectApiKey = {
keyIdentifier: Scalars['String']['output'];
keyP8: Scalars['String']['output'];
name?: Maybe<Scalars['String']['output']>;
remoteAppStoreConnectApps: Array<RemoteAppStoreConnectApp>;
roles?: Maybe<Array<AppStoreConnectUserRole>>;
updatedAt: Scalars['DateTime']['output'];
};
export type AppStoreConnectApiKeyRemoteAppStoreConnectAppsArgs = {
bundleIdentifier?: InputMaybe<Scalars['String']['input']>;
};
export type AppStoreConnectApiKeyInput = {
appleTeamId?: InputMaybe<Scalars['ID']['input']>;
issuerIdentifier: Scalars['String']['input'];
@@ -2430,6 +2480,43 @@ export type AppStoreConnectApiKeyUpdateInput = {
appleTeamId?: InputMaybe<Scalars['ID']['input']>;
};
export type AppStoreConnectApp = {
__typename?: 'AppStoreConnectApp';
app: App;
appStoreConnectApiKey: AppStoreConnectApiKey;
ascAppIdentifier: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
remoteAppStoreConnectApp: RemoteAppStoreConnectApp;
updatedAt: Scalars['DateTime']['output'];
webhookEventTypes: Array<Scalars['String']['output']>;
webhookIdentifier: Scalars['ID']['output'];
};
export type AppStoreConnectAppInput = {
appId: Scalars['ID']['input'];
appStoreConnectApiKeyId: Scalars['ID']['input'];
ascAppIdentifier: Scalars['String']['input'];
};
export type AppStoreConnectAppMutation = {
__typename?: 'AppStoreConnectAppMutation';
/** Create an App Store Connect app for an Expo app. */
createAppStoreConnectApp: AppStoreConnectApp;
/** Delete an App Store Connect app by ID. */
deleteAppStoreConnectApp: DeleteAppStoreConnectAppResult;
};
export type AppStoreConnectAppMutationCreateAppStoreConnectAppArgs = {
appStoreConnectAppInput: AppStoreConnectAppInput;
};
export type AppStoreConnectAppMutationDeleteAppStoreConnectAppArgs = {
appStoreConnectAppId: Scalars['ID']['input'];
};
export enum AppStoreConnectUserRole {
AccessToReports = 'ACCESS_TO_REPORTS',
AccountHolder = 'ACCOUNT_HOLDER',
@@ -3278,6 +3365,36 @@ export type BranchQueryByIdArgs = {
branchId: Scalars['ID']['input'];
};
export type BrownfieldArtifact = {
__typename?: 'BrownfieldArtifact';
bundleName: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
downloadUrl?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
platform: AppPlatform;
sizeBytes?: Maybe<Scalars['Int']['output']>;
updatedAt: Scalars['DateTime']['output'];
version: Scalars['String']['output'];
};
export type BrownfieldArtifactEdge = {
__typename?: 'BrownfieldArtifactEdge';
cursor: Scalars['String']['output'];
node: BrownfieldArtifact;
};
export type BrownfieldArtifactFilterInput = {
bundleName?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppPlatform>;
version?: InputMaybe<Scalars['String']['input']>;
};
export type BrownfieldArtifactsConnection = {
__typename?: 'BrownfieldArtifactsConnection';
edges: Array<BrownfieldArtifactEdge>;
pageInfo: PageInfo;
};
/** Represents an EAS Build */
export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
__typename?: 'Build';
@@ -3921,13 +4038,6 @@ export type CodeSigningInfoInput = {
sig: Scalars['String']['input'];
};
/** Metadata to set when completing a message. */
export type CompleteEchoMessageMetadataInput = {
cost?: InputMaybe<Scalars['Float']['input']>;
finishReason?: InputMaybe<Scalars['String']['input']>;
tokens?: InputMaybe<EchoMessageTokenUsageInput>;
};
export type Concurrencies = {
__typename?: 'Concurrencies';
android: Scalars['Int']['output'];
@@ -3951,6 +4061,31 @@ export type ConvexIntegrationQuery = {
clientIdentifier: Scalars['String']['output'];
};
export type ConvexTeamConnection = {
__typename?: 'ConvexTeamConnection';
account: Account;
convexTeamIdentifier: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type ConvexTeamConnectionMutation = {
__typename?: 'ConvexTeamConnectionMutation';
createConvexTeamConnection: ConvexTeamConnection;
deleteConvexTeamConnection: ConvexTeamConnection;
};
export type ConvexTeamConnectionMutationCreateConvexTeamConnectionArgs = {
convexTeamConnectionData: CreateConvexTeamConnectionInput;
};
export type ConvexTeamConnectionMutationDeleteConvexTeamConnectionArgs = {
convexTeamConnectionId: Scalars['ID']['input'];
};
export enum CrashSampleFor {
Newest = 'NEWEST',
Oldest = 'OLDEST'
@@ -3986,12 +4121,41 @@ export type CreateAndroidSubmissionInput = {
submittedBuildId?: InputMaybe<Scalars['ID']['input']>;
};
export type CreateBrownfieldArtifactInput = {
appId: Scalars['ID']['input'];
bundleName: Scalars['String']['input'];
/** Filename for the artifact (e.g. "MyFramework.xcframework.tar.gz"). */
filename: Scalars['String']['input'];
platform: AppPlatform;
/** The ID of the turtle job run that is producing this artifact. */
producingTurtleJobRunId: Scalars['ID']['input'];
/** Size of the artifact in bytes. Required for generating the signed upload URL. */
size: Scalars['Int']['input'];
/** Optional. If omitted, the version is auto-incremented from the latest artifact for the same platform and bundle name. */
version?: InputMaybe<Scalars['String']['input']>;
};
export type CreateBrownfieldArtifactResult = {
__typename?: 'CreateBrownfieldArtifactResult';
artifact: BrownfieldArtifact;
/** Headers to include with the upload request. */
uploadHeaders: Scalars['JSONObject']['output'];
/** Signed URL to upload the artifact to. */
uploadUrl: Scalars['String']['output'];
};
export type CreateBuildResult = {
__typename?: 'CreateBuildResult';
build: Build;
deprecationInfo?: Maybe<EasBuildDeprecationInfo>;
};
export type CreateConvexTeamConnectionInput = {
accountId: Scalars['ID']['input'];
convexTeamName?: InputMaybe<Scalars['String']['input']>;
deploymentRegion: Scalars['String']['input'];
};
export type CreateEchoChatInput = {
agentMetadata?: InputMaybe<Scalars['JSONObject']['input']>;
agentType?: InputMaybe<EchoAgentType>;
@@ -4002,10 +4166,12 @@ export type CreateEchoChatInput = {
export type CreateEchoMessageInput = {
echoChatId: Scalars['ID']['input'];
echoTurnId?: InputMaybe<Scalars['ID']['input']>;
id?: InputMaybe<Scalars['ID']['input']>;
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
parentEchoMessageId?: InputMaybe<Scalars['ID']['input']>;
role: EchoMessageRole;
/** Legacy alias for echoTurnId. Must be a valid UUIDv7. Provide exactly one of echoTurnId or turnId. */
turnId?: InputMaybe<Scalars['ID']['input']>;
userId?: InputMaybe<Scalars['ID']['input']>;
};
@@ -4056,6 +4222,7 @@ export type CreateEchoVersionInput = {
diffs: Scalars['JSON']['input'];
echoChatId?: InputMaybe<Scalars['ID']['input']>;
echoProjectId: Scalars['ID']['input'];
echoTurnId?: InputMaybe<Scalars['ID']['input']>;
gitBranch?: InputMaybe<Scalars['String']['input']>;
gitCommitHash?: InputMaybe<Scalars['String']['input']>;
gitCommitMessage?: InputMaybe<Scalars['String']['input']>;
@@ -4063,6 +4230,7 @@ export type CreateEchoVersionInput = {
revertedFromEchoVersionId?: InputMaybe<Scalars['ID']['input']>;
source: EchoVersionSource;
thumbnail?: InputMaybe<EchoVersionThumbnailInput>;
/** Legacy alias for echoTurnId. Must be a valid UUIDv7. Provide exactly one of echoTurnId or turnId. */
turnId?: InputMaybe<Scalars['ID']['input']>;
};
@@ -4312,6 +4480,11 @@ export type DeleteAndroidKeystoreResult = {
id: Scalars['ID']['output'];
};
export type DeleteAppStoreConnectAppResult = {
__typename?: 'DeleteAppStoreConnectAppResult';
id: Scalars['ID']['output'];
};
export type DeleteAppleDeviceResult = {
__typename?: 'DeleteAppleDeviceResult';
id: Scalars['ID']['output'];
@@ -4394,6 +4567,11 @@ export type DeleteSsoUserResult = {
id: Scalars['ID']['output'];
};
export type DeleteSentryInstallationResult = {
__typename?: 'DeleteSentryInstallationResult';
id: Scalars['ID']['output'];
};
export type DeleteSentryProjectResult = {
__typename?: 'DeleteSentryProjectResult';
id: Scalars['ID']['output'];
@@ -4621,6 +4799,7 @@ export enum EasBuildWaiverType {
}
export enum EasService {
Agent = 'AGENT',
Builds = 'BUILDS',
Jobs = 'JOBS',
Mcp = 'MCP',
@@ -4631,6 +4810,7 @@ export enum EasServiceMetric {
AssetsRequests = 'ASSETS_REQUESTS',
BandwidthUsage = 'BANDWIDTH_USAGE',
Builds = 'BUILDS',
CreditUsage = 'CREDIT_USAGE',
LocalBuilds = 'LOCAL_BUILDS',
ManifestRequests = 'MANIFEST_REQUESTS',
McpRequests = 'MCP_REQUESTS',
@@ -4755,24 +4935,19 @@ export type EchoMessage = {
echoChat: EchoChat;
/** Message parts (text, tool calls, etc.) ordered by index */
echoMessageParts: Array<EchoMessagePart>;
/** Turn entity for grouping user message + assistant response */
echoTurn?: Maybe<EchoTurn>;
id: Scalars['ID']['output'];
metadata?: Maybe<Scalars['JSONObject']['output']>;
/** Parent message (for conversation branching) */
parentEchoMessage?: Maybe<EchoMessage>;
role: EchoMessageRole;
/** Turn ID for grouping user message + assistant response (UUID) */
/** Turn ID (legacy alias for echoTurn.id, prefer echoTurn) */
turnId?: Maybe<Scalars['ID']['output']>;
/** User who sent the message (for user messages) */
user?: Maybe<User>;
};
/** Breakdown of cache write input tokens by cache TTL. */
export type EchoMessageCacheWriteInput = {
ttl1h?: InputMaybe<Scalars['Int']['input']>;
ttl5m?: InputMaybe<Scalars['Int']['input']>;
ttl24h?: InputMaybe<Scalars['Int']['input']>;
};
export type EchoMessageConnection = {
__typename?: 'EchoMessageConnection';
edges: Array<EchoMessageEdge>;
@@ -4788,7 +4963,10 @@ export type EchoMessageEdge = {
export type EchoMessageMutation = {
__typename?: 'EchoMessageMutation';
/** Mark a message as completed (sets completedAt and optionally updates metadata with tokens) */
/**
* Mark a message as completed (sets completedAt).
* metadata is accepted for backward compatibility but ignored — use echoTurn.completeTurn instead.
*/
completeMessage: EchoMessage;
/** Create a new message */
createMessage: EchoMessage;
@@ -4797,7 +4975,7 @@ export type EchoMessageMutation = {
export type EchoMessageMutationCompleteMessageArgs = {
id: Scalars['ID']['input'];
metadata?: InputMaybe<CompleteEchoMessageMetadataInput>;
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
};
@@ -4888,16 +5066,6 @@ export enum EchoMessageRole {
User = 'USER'
}
/** Token usage for billing. Tracks input/output tokens and cache usage. */
export type EchoMessageTokenUsageInput = {
cacheRead?: InputMaybe<Scalars['Int']['input']>;
cacheWrite?: InputMaybe<Scalars['Int']['input']>;
cacheWriteBreakdown?: InputMaybe<EchoMessageCacheWriteInput>;
input: Scalars['Int']['input'];
output: Scalars['Int']['input'];
reasoning?: InputMaybe<Scalars['Int']['input']>;
};
export type EchoProject = {
__typename?: 'EchoProject';
account: Account;
@@ -5119,6 +5287,57 @@ export type EchoRepositoryResult = {
url: Scalars['String']['output'];
};
export type EchoTurn = {
__typename?: 'EchoTurn';
completedAt?: Maybe<Scalars['DateTime']['output']>;
createdAt: Scalars['DateTime']['output'];
/** Parent chat */
echoChat: EchoChat;
/** Messages in this turn */
echoMessages: Array<EchoMessage>;
id: Scalars['ID']['output'];
};
/** Breakdown of cache write input tokens by cache TTL. */
export type EchoTurnCacheWriteInput = {
ttl1h?: InputMaybe<Scalars['Int']['input']>;
ttl5m?: InputMaybe<Scalars['Int']['input']>;
ttl24h?: InputMaybe<Scalars['Int']['input']>;
};
export type EchoTurnMutation = {
__typename?: 'EchoTurnMutation';
/** Mark a turn as completed and create a billing ledger entry */
completeTurn: EchoTurn;
};
export type EchoTurnMutationCompleteTurnArgs = {
id: Scalars['ID']['input'];
usage: EchoTurnUsageInput;
};
/** Token usage for billing. Tracks input/output tokens and cache usage. */
export type EchoTurnTokenUsageInput = {
cacheRead?: InputMaybe<Scalars['Int']['input']>;
cacheWrite?: InputMaybe<Scalars['Int']['input']>;
cacheWriteBreakdown?: InputMaybe<EchoTurnCacheWriteInput>;
input: Scalars['Int']['input'];
output: Scalars['Int']['input'];
reasoning?: InputMaybe<Scalars['Int']['input']>;
};
/** Cumulative usage data for a completed turn. */
export type EchoTurnUsageInput = {
agent?: InputMaybe<Scalars['String']['input']>;
cost?: InputMaybe<Scalars['Float']['input']>;
finishReason?: InputMaybe<Scalars['String']['input']>;
model?: InputMaybe<Scalars['String']['input']>;
provider?: InputMaybe<Scalars['String']['input']>;
sdkCost?: InputMaybe<Scalars['Float']['input']>;
tokens?: InputMaybe<EchoTurnTokenUsageInput>;
};
export type EchoVersion = {
__typename?: 'EchoVersion';
buildError?: Maybe<Scalars['String']['output']>;
@@ -5128,6 +5347,8 @@ export type EchoVersion = {
echoChat?: Maybe<EchoChat>;
/** Parent project */
echoProject: EchoProject;
/** Turn that created this version */
echoTurn?: Maybe<EchoTurn>;
gitBranch: Scalars['String']['output'];
gitCommitHash?: Maybe<Scalars['String']['output']>;
gitCommitMessage?: Maybe<Scalars['String']['output']>;
@@ -5137,7 +5358,7 @@ export type EchoVersion = {
revertedFromEchoVersion?: Maybe<EchoVersion>;
source: EchoVersionSource;
thumbnail?: Maybe<EchoVersionThumbnail>;
/** Turn ID that created this version (UUID, optional) */
/** Turn ID (legacy alias for echoTurn.id, prefer echoTurn) */
turnId?: Maybe<Scalars['ID']['output']>;
};
@@ -5223,7 +5444,7 @@ export enum EntityTypeName {
AndroidKeystoreEntity = 'AndroidKeystoreEntity',
AppEntity = 'AppEntity',
AppStoreConnectApiKeyEntity = 'AppStoreConnectApiKeyEntity',
AppStoreConnectAppWebhookEntity = 'AppStoreConnectAppWebhookEntity',
AppStoreConnectAppEntity = 'AppStoreConnectAppEntity',
AppleDeviceEntity = 'AppleDeviceEntity',
AppleDistributionCertificateEntity = 'AppleDistributionCertificateEntity',
AppleProvisioningProfileEntity = 'AppleProvisioningProfileEntity',
@@ -5880,6 +6101,7 @@ export type GitHubRepository = {
githubRepositoryIdentifier: Scalars['Int']['output'];
githubRepositoryUrl?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
metadata: GitHubRepositoryMetadata;
nodeIdentifier: Scalars['String']['output'];
};
@@ -5903,8 +6125,8 @@ export type GitHubRepositoryMutation = {
createAndConfigureRepository: BackgroundJobReceipt;
/** Create a GitHub repository for an App */
createGitHubRepository: GitHubRepository;
/** Delete a GitHub repository by ID */
deleteGitHubRepository: GitHubRepository;
/** Delete a GitHub repository by ID in the background */
scheduleGitHubRepositoryDeletion: BackgroundJobReceipt;
};
@@ -5923,7 +6145,7 @@ export type GitHubRepositoryMutationCreateGitHubRepositoryArgs = {
};
export type GitHubRepositoryMutationDeleteGitHubRepositoryArgs = {
export type GitHubRepositoryMutationScheduleGitHubRepositoryDeletionArgs = {
githubRepositoryId: Scalars['ID']['input'];
};
@@ -7091,6 +7313,14 @@ export type PublishUpdateGroupInput = {
updateInfoGroup?: InputMaybe<UpdateInfoGroup>;
};
export type RemoteAppStoreConnectApp = {
__typename?: 'RemoteAppStoreConnectApp';
appStoreIconUrl?: Maybe<Scalars['String']['output']>;
ascAppIdentifier: Scalars['String']['output'];
bundleIdentifier: Scalars['String']['output'];
name?: Maybe<Scalars['String']['output']>;
};
export enum RequestMethod {
Delete = 'DELETE',
Get = 'GET',
@@ -7270,6 +7500,8 @@ export type RootMutation = {
app?: Maybe<AppMutation>;
/** Mutations that modify an App Store Connect Api Key */
appStoreConnectApiKey: AppStoreConnectApiKeyMutation;
/** Mutations for App Store Connect apps. */
appStoreConnectApp: AppStoreConnectAppMutation;
/** Mutations that modify an AppVersion */
appVersion: AppVersionMutation;
/** Mutations that modify an Identifier for an iOS App */
@@ -7292,6 +7524,7 @@ export type RootMutation = {
build: BuildMutation;
/** Mutations that create, update, and delete Build Annotations */
buildAnnotation: BuildAnnotationMutation;
convexTeamConnection: ConvexTeamConnectionMutation;
customDomain: CustomDomainMutation;
deployments: DeploymentsMutation;
/** Mutations that assign or modify DevDomainNames for apps */
@@ -7308,6 +7541,8 @@ export type RootMutation = {
echoProject: EchoProjectMutation;
/** Mutations for Echo repository management via the GitHub App */
echoRepository: EchoRepositoryMutation;
/** Mutations for Echo turns */
echoTurn: EchoTurnMutation;
/** Mutations for Echo versions */
echoVersion: EchoVersionMutation;
/** Mutations that create and delete EnvironmentSecrets */
@@ -7354,6 +7589,7 @@ export type RootMutation = {
sentryProject: SentryProjectMutation;
/** Mutations that modify an EAS Submit submission */
submission: SubmissionMutation;
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactMutation;
update: UpdateMutation;
updateBranch: UpdateBranchMutation;
updateChannel: UpdateChannelMutation;
@@ -7493,6 +7729,7 @@ export type RootQuery = {
/** Top-level query object for querying Expo status page services. */
statuspageService: StatuspageServiceQuery;
submissions: SubmissionQuery;
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactQuery;
/** Top-level query object for querying Updates. */
updates: UpdateQuery;
/** fetch all updates in a group */
@@ -7705,6 +7942,7 @@ export type SsoUser = Actor & UserActor & {
/** @deprecated No longer supported */
industry?: Maybe<Scalars['String']['output']>;
isExpoAdmin: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
@@ -7817,6 +8055,8 @@ export type SentryInstallationMutation = {
__typename?: 'SentryInstallationMutation';
/** Confirm a pending Sentry installation */
confirmPendingSentryInstallation: SentryInstallation;
/** Force delete a Sentry installation from Expo's side */
deleteSentryInstallation: DeleteSentryInstallationResult;
/** Generate a Sentry token for an installation */
generateSentryToken: GenerateSentryTokenResult;
/** Link a Sentry installation to an Expo account */
@@ -7829,6 +8069,11 @@ export type SentryInstallationMutationConfirmPendingSentryInstallationArgs = {
};
export type SentryInstallationMutationDeleteSentryInstallationArgs = {
accountId: Scalars['ID']['input'];
};
export type SentryInstallationMutationGenerateSentryTokenArgs = {
accountId: Scalars['ID']['input'];
};
@@ -7867,6 +8112,13 @@ export type SentryProjectMutationDeleteSentryProjectArgs = {
sentryProjectId: Scalars['ID']['input'];
};
export type SizeBreakdownCategory = {
__typename?: 'SizeBreakdownCategory';
assetCount: Scalars['Int']['output'];
category: Scalars['String']['output'];
totalBytes: Scalars['Float']['output'];
};
export type Snack = Project & {
__typename?: 'Snack';
/** Description of the Snack */
@@ -8226,6 +8478,34 @@ export type TimelineActivityFilterInput = {
types?: InputMaybe<Array<ActivityTimelineProjectActivityType>>;
};
export type TurtleBrownfieldArtifactMutation = {
__typename?: 'TurtleBrownfieldArtifactMutation';
createTurtleBrownfieldArtifact: CreateBrownfieldArtifactResult;
};
export type TurtleBrownfieldArtifactMutationCreateTurtleBrownfieldArtifactArgs = {
input: CreateBrownfieldArtifactInput;
};
export type TurtleBrownfieldArtifactQuery = {
__typename?: 'TurtleBrownfieldArtifactQuery';
byId: BrownfieldArtifact;
latestForApp?: Maybe<BrownfieldArtifact>;
};
export type TurtleBrownfieldArtifactQueryByIdArgs = {
turtleBrownfieldArtifactId: Scalars['ID']['input'];
};
export type TurtleBrownfieldArtifactQueryLatestForAppArgs = {
appId: Scalars['ID']['input'];
bundleName: Scalars['String']['input'];
platform: AppPlatform;
};
export type UniqueUsersOverTimeData = {
__typename?: 'UniqueUsersOverTimeData';
data: LineChartData;
@@ -8520,8 +8800,10 @@ export type UpdateDeploymentsConnection = {
export type UpdateDiffReceipt = {
__typename?: 'UpdateDiffReceipt';
appId: Scalars['ID']['output'];
baseUpdate?: Maybe<Update>;
baseUpdateId: Scalars['ID']['output'];
createdAt: Scalars['DateTime']['output'];
downloadUrl?: Maybe<Scalars['String']['output']>;
errorCode?: Maybe<Scalars['String']['output']>;
errorMessage?: Maybe<Scalars['String']['output']>;
fileSize?: Maybe<Scalars['Int']['output']>;
@@ -8674,6 +8956,7 @@ export type UpdateInsights = {
cumulativeAverageMetrics: CumulativeAverageMetrics;
cumulativeMetrics: CumulativeMetrics;
id: Scalars['ID']['output'];
sizeBreakdownByCategory: Array<SizeBreakdownCategory>;
totalUniqueUsers: Scalars['Int']['output'];
};
@@ -8839,6 +9122,7 @@ export type UsageMetricTotal = {
export enum UsageMetricType {
Bandwidth = 'BANDWIDTH',
Build = 'BUILD',
Credit = 'CREDIT',
Minute = 'MINUTE',
Request = 'REQUEST',
Update = 'UPDATE',
@@ -8903,6 +9187,7 @@ export type User = Actor & UserActor & {
/** @deprecated No longer supported */
isLegacy: Scalars['Boolean']['output'];
isSecondFactorAuthenticationEnabled: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
@@ -9010,6 +9295,7 @@ export type UserActor = {
/** @deprecated No longer supported */
industry?: Maybe<Scalars['String']['output']>;
isExpoAdmin: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
@@ -10587,6 +10873,10 @@ export type WorkflowRunTimeRangeInput = {
};
export enum WorkflowRunTriggerEventType {
AppStoreConnectAppVersionStateChanged = 'APP_STORE_CONNECT_APP_VERSION_STATE_CHANGED',
AppStoreConnectBetaFeedbackSubmitted = 'APP_STORE_CONNECT_BETA_FEEDBACK_SUBMITTED',
AppStoreConnectBuildUploadStateChanged = 'APP_STORE_CONNECT_BUILD_UPLOAD_STATE_CHANGED',
AppStoreConnectExternalBetaStateChanged = 'APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED',
EasSubmit = 'EAS_SUBMIT',
ExpoLaunch = 'EXPO_LAUNCH',
GithubPullRequestLabeled = 'GITHUB_PULL_REQUEST_LABELED',
@@ -11791,6 +12081,33 @@ export type GoogleServiceAccountKeyByIdQueryVariables = Exact<{
export type GoogleServiceAccountKeyByIdQuery = { __typename?: 'RootQuery', googleServiceAccountKey: { __typename?: 'GoogleServiceAccountKeyQuery', byId: { __typename?: 'GoogleServiceAccountKey', id: string, keyJson: string } } };
export type AppObserveTimeSeriesQueryVariables = Exact<{
appId: Scalars['String']['input'];
input: AppObserveTimeSeriesInput;
}>;
export type AppObserveTimeSeriesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', timeSeries: { __typename?: 'AppObserveTimeSeries', eventCount: number, appVersionMarkers: Array<{ __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }>, statistics: { __typename?: 'AppObserveTimeSeriesStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } } } } } };
export type AppObserveAppVersionsQueryVariables = Exact<{
appId: Scalars['String']['input'];
input: AppObserveReleasesInput;
}>;
export type AppObserveAppVersionsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', appVersions: Array<{ __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }> } } } };
export type AppObserveEventsQueryVariables = Exact<{
appId: Scalars['String']['input'];
filter?: InputMaybe<AppObserveEventsFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
orderBy?: InputMaybe<AppObserveEventsOrderBy>;
}>;
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string } }> } } } } };
export type GetAssetMetadataQueryVariables = Exact<{
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
}>;
@@ -11971,6 +12288,12 @@ export type EnvironmentVariableWithSecretFragment = { __typename?: 'EnvironmentV
export type FingerprintFragment = { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } };
export type AppObserveTimeSeriesFragment = { __typename?: 'AppObserveTimeSeries', eventCount: number, appVersionMarkers: Array<{ __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }>, statistics: { __typename?: 'AppObserveTimeSeriesStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } };
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string };
export type AppObserveAppVersionFragment = { __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> };
export type RuntimeFragment = { __typename?: 'Runtime', id: string, version: string };
export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> };
@@ -0,0 +1,239 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AppObserveAppVersion,
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObservePlatform,
AppObserveReleasesInput,
AppObserveTimeSeriesInput,
AppObserveTimeSeriesStatistics,
PageInfo,
} from '../generated';
import { print } from 'graphql';
import {
AppObserveAppVersionFragmentNode,
AppObserveTimeSeriesFragmentNode,
AppObserveEventFragmentNode,
} from '../types/Observe';
export type AppObserveTimeSeriesResult = {
appVersionMarkers: AppObserveAppVersion[];
eventCount: number;
statistics: AppObserveTimeSeriesStatistics;
};
type AppObserveTimeSeriesQuery = {
app: {
byId: {
id: string;
observe: {
timeSeries: AppObserveTimeSeriesResult;
};
};
};
};
type AppObserveTimeSeriesQueryVariables = {
appId: string;
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
};
type AppObserveAppVersionsQuery = {
app: {
byId: {
id: string;
observe: {
appVersions: AppObserveAppVersion[];
};
};
};
};
type AppObserveAppVersionsQueryVariables = {
appId: string;
input: AppObserveReleasesInput;
};
type AppObserveEventsQuery = {
app: {
byId: {
id: string;
observe: {
events: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveEvent;
}>;
};
};
};
};
};
type AppObserveEventsQueryVariables = {
appId: string;
filter?: AppObserveEventsFilter;
first?: number;
after?: string;
orderBy?: AppObserveEventsOrderBy;
};
export const ObserveQuery = {
async timeSeriesAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
metricName,
platform,
startTime,
endTime,
}: {
appId: string;
metricName: string;
platform: AppObservePlatform;
startTime: string;
endTime: string;
}
): Promise<AppObserveTimeSeriesResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
gql`
query AppObserveTimeSeries(
$appId: String!
$input: AppObserveTimeSeriesInput!
) {
app {
byId(appId: $appId) {
id
observe {
timeSeries(input: $input) {
...AppObserveTimeSeriesFragment
}
}
}
}
}
${print(AppObserveAppVersionFragmentNode)}
${print(AppObserveTimeSeriesFragmentNode)}
`,
{
appId,
input: { metricName, platform, startTime, endTime },
}
)
.toPromise()
);
return data.app.byId.observe.timeSeries;
},
async appVersionsAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
platform,
startTime,
endTime,
metricNames,
}: {
appId: string;
platform: AppObservePlatform;
startTime: string;
endTime: string;
metricNames?: string[];
}
): Promise<AppObserveAppVersion[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveAppVersionsQuery, AppObserveAppVersionsQueryVariables>(
gql`
query AppObserveAppVersions(
$appId: String!
$input: AppObserveReleasesInput!
) {
app {
byId(appId: $appId) {
id
observe {
appVersions(input: $input) {
...AppObserveAppVersionFragment
}
}
}
}
}
${print(AppObserveAppVersionFragmentNode)}
`,
{
appId,
input: { platform, startTime, endTime, ...(metricNames && { metricNames }) },
}
)
.toPromise()
);
return data.app.byId.observe.appVersions;
},
async eventsAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveEventsQueryVariables
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
gql`
query AppObserveEvents(
$appId: String!
$filter: AppObserveEventsFilter
$first: Int
$after: String
$orderBy: AppObserveEventsOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
events(
filter: $filter
first: $first
after: $after
orderBy: $orderBy
) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
...AppObserveEventFragment
}
}
}
}
}
}
}
${print(AppObserveEventFragmentNode)}
`,
variables
)
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.events;
return {
events: edges.map(edge => edge.node),
pageInfo,
};
},
};
@@ -0,0 +1,83 @@
import gql from 'graphql-tag';
export const AppObserveTimeSeriesFragmentNode = gql`
fragment AppObserveTimeSeriesFragment on AppObserveTimeSeries {
appVersionMarkers {
...AppObserveAppVersionFragment
}
eventCount
statistics {
min
max
median
average
p80
p90
p99
}
}
`;
export const AppObserveEventFragmentNode = gql`
fragment AppObserveEventFragment on AppObserveEvent {
id
metricName
metricValue
timestamp
appVersion
appBuildNumber
appUpdateId
deviceModel
deviceOs
deviceOsVersion
countryCode
sessionId
easClientId
}
`;
export const AppObserveAppVersionFragmentNode = gql`
fragment AppObserveAppVersionFragment on AppObserveAppVersion {
appVersion
firstSeenAt
eventCount
uniqueUserCount
buildNumbers {
appBuildNumber
firstSeenAt
eventCount
uniqueUserCount
easBuilds {
easBuildId
firstSeenAt
eventCount
uniqueUserCount
}
}
updates {
appUpdateId
firstSeenAt
eventCount
uniqueUserCount
easBuilds {
easBuildId
firstSeenAt
eventCount
uniqueUserCount
}
}
metrics {
metricName
eventCount
statistics {
min
max
median
average
p80
p90
p99
}
}
}
`;
@@ -34,6 +34,15 @@ export const emptyAdvisory: AgeRatingDeclarationProps = {
ageRatingOverride: null,
koreaAgeRatingOverride: null,
lootBox: null,
advertising: null,
ageAssurance: null,
ageRatingOverrideV2: null,
developerAgeRatingInfoUrl: null,
gunsOrOtherWeapons: null,
healthOrWellnessTopics: null,
messagingAndChat: null,
parentalControls: null,
userGeneratedContent: null,
};
export const leastRestrictiveAdvisory: AgeRatingDeclarationProps = {
@@ -55,6 +64,15 @@ export const leastRestrictiveAdvisory: AgeRatingDeclarationProps = {
ageRatingOverride: RatingOverride.NONE,
koreaAgeRatingOverride: KoreaRatingOverride.NONE,
lootBox: false,
advertising: false,
ageAssurance: false,
ageRatingOverrideV2: null,
developerAgeRatingInfoUrl: null,
gunsOrOtherWeapons: Rating.NONE,
healthOrWellnessTopics: false,
messagingAndChat: false,
parentalControls: false,
userGeneratedContent: false,
};
export const mostRestrictiveAdvisory: AgeRatingDeclarationProps = {
@@ -76,6 +94,15 @@ export const mostRestrictiveAdvisory: AgeRatingDeclarationProps = {
ageRatingOverride: RatingOverride.SEVENTEEN_PLUS,
koreaAgeRatingOverride: KoreaRatingOverride.NINETEEN_PLUS,
lootBox: true,
advertising: true,
ageAssurance: true,
ageRatingOverrideV2: null,
developerAgeRatingInfoUrl: null,
gunsOrOtherWeapons: Rating.FREQUENT_OR_INTENSE,
healthOrWellnessTopics: true,
messagingAndChat: true,
parentalControls: true,
userGeneratedContent: true,
};
export const kidsSixToEightAdvisory: AgeRatingDeclarationProps = {
@@ -97,4 +124,13 @@ export const kidsSixToEightAdvisory: AgeRatingDeclarationProps = {
ageRatingOverride: RatingOverride.NONE,
koreaAgeRatingOverride: KoreaRatingOverride.NONE,
lootBox: false,
advertising: false,
ageAssurance: false,
ageRatingOverrideV2: null,
developerAgeRatingInfoUrl: null,
gunsOrOtherWeapons: Rating.NONE,
healthOrWellnessTopics: false,
messagingAndChat: false,
parentalControls: false,
userGeneratedContent: false,
};
@@ -176,3 +176,43 @@ describe('getVersion', () => {
});
});
});
describe('getAppClip', () => {
it('returns null when no app clip is configured', () => {
const reader = new AppleConfigReader({});
expect(reader.getAppClip()).toBeNull();
expect(reader.getAppClipDefaultExperience()).toBeNull();
expect(reader.getAppClipLocales()).toEqual([]);
});
it('returns the configured app clip block', () => {
const reader = new AppleConfigReader({
appClip: {
defaultExperience: {
action: 'OPEN',
releaseWithAppStoreVersion: true,
reviewDetail: { invocationUrls: ['https://example.com/clip'] },
info: {
'en-US': { subtitle: 'Quick experience', headerImage: 'header.png' },
'fr-FR': { subtitle: 'Expérience rapide' },
},
},
},
});
expect(reader.getAppClipDefaultExperience()).toMatchObject({
action: 'OPEN',
releaseWithAppStoreVersion: true,
reviewDetail: { invocationUrls: ['https://example.com/clip'] },
});
expect(reader.getAppClipLocales().sort()).toEqual(['en-US', 'fr-FR']);
expect(reader.getAppClipLocalizedInfo('en-US')).toEqual({
subtitle: 'Quick experience',
headerImage: 'header.png',
});
expect(reader.getAppClipLocalizedInfo('fr-FR')).toEqual({
subtitle: 'Expérience rapide',
});
expect(reader.getAppClipLocalizedInfo('de-DE')).toBeNull();
});
});
@@ -12,6 +12,7 @@ import { nameAndDemoReviewDetails, nameOnlyReviewDetails } from './fixtures/appS
import { automaticRelease, manualRelease, scheduledRelease } from './fixtures/appStoreVersion';
import { dutchVersion, englishVersion } from './fixtures/appStoreVersionLocalization';
import { phasedRelease } from './fixtures/appStoreVersionPhasedRelease';
import { validateConfig } from '../../../config/validate';
import { AppleConfigWriter } from '../writer';
describe('toSchema', () => {
@@ -42,6 +43,22 @@ describe('setAgeRating', () => {
writer.setAgeRating(mostRestrictiveAdvisory);
expect(writer.schema.advisory).toMatchObject(mostRestrictiveAdvisory);
});
// Drift guard: every advisory field that the writer emits must be allowed by
// metadata-0.json. If you add a field to writer.ts (or apple-utils surfaces a
// new one upstream), update the schema in the same change. Without this
// test, the drift only shows up at `metadata:push` time as a confusing
// validation error against the user's freshly pulled config.
it.each([
['emptyAdvisory', emptyAdvisory],
['leastRestrictiveAdvisory', leastRestrictiveAdvisory],
['mostRestrictiveAdvisory', mostRestrictiveAdvisory],
['kidsSixToEightAdvisory', kidsSixToEightAdvisory],
])('writer output for %s passes JSON schema validation', (_name, advisory) => {
const writer = new AppleConfigWriter();
writer.setAgeRating(advisory);
expect(validateConfig(writer.toSchema())).toEqual([]);
});
});
describe('setInfoLocale', () => {
@@ -329,3 +346,72 @@ describe('setReviewDetails', () => {
});
});
});
describe('setAppClipDefaultExperience', () => {
it('sets default experience attributes', () => {
const writer = new AppleConfigWriter();
writer.setAppClipDefaultExperience({
action: 'OPEN',
releaseWithAppStoreVersion: true,
});
expect(writer.schema.appClip?.defaultExperience).toMatchObject({
action: 'OPEN',
releaseWithAppStoreVersion: true,
});
});
});
describe('setAppClipReviewDetail', () => {
it('sets invocation urls', () => {
const writer = new AppleConfigWriter();
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toEqual({
invocationUrls: ['https://example.com/clip'],
});
});
it('removes review detail when null', () => {
const writer = new AppleConfigWriter();
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
writer.setAppClipReviewDetail(null);
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toBeUndefined();
});
it('removes review detail when invocationUrls is empty', () => {
const writer = new AppleConfigWriter();
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
writer.setAppClipReviewDetail({ invocationUrls: [] });
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toBeUndefined();
});
});
describe('setAppClipLocalizedInfo', () => {
it('writes localized subtitle and header image', () => {
const writer = new AppleConfigWriter();
writer.setAppClipLocalizedInfo('en-US', {
subtitle: 'Quick experience',
headerImage: 'store/apple/app-clip/en-US/header.png',
});
expect(writer.schema.appClip?.defaultExperience?.info?.['en-US']).toEqual({
subtitle: 'Quick experience',
headerImage: 'store/apple/app-clip/en-US/header.png',
});
});
it('omits empty locale entries', () => {
const writer = new AppleConfigWriter();
writer.setAppClipLocalizedInfo('en-US', { subtitle: 'Quick' });
writer.setAppClipLocalizedInfo('en-US', {});
expect(writer.schema.appClip?.defaultExperience?.info?.['en-US']).toBeUndefined();
});
it('keeps multiple locales independent', () => {
const writer = new AppleConfigWriter();
writer.setAppClipLocalizedInfo('en-US', { subtitle: 'Quick' });
writer.setAppClipLocalizedInfo('fr-FR', { subtitle: 'Rapide' });
expect(writer.schema.appClip?.defaultExperience?.info).toMatchObject({
'en-US': { subtitle: 'Quick' },
'fr-FR': { subtitle: 'Rapide' },
});
});
});
@@ -16,7 +16,14 @@ import {
import uniq from '../../../utils/expodash/uniq';
import { AttributesOf } from '../../utils/asc';
import { removeDatePrecision } from '../../utils/date';
import { AppleMetadata } from '../types';
import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
} from '../types';
type PartialExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
@@ -57,6 +64,15 @@ export class AppleConfigReader {
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic:
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
advertising: attributes.advertising ?? false,
ageAssurance: attributes.ageAssurance ?? false,
ageRatingOverrideV2: attributes.ageRatingOverrideV2 ?? null,
developerAgeRatingInfoUrl: attributes.developerAgeRatingInfoUrl ?? null,
gunsOrOtherWeapons: attributes.gunsOrOtherWeapons ?? Rating.NONE,
healthOrWellnessTopics: attributes.healthOrWellnessTopics ?? false,
messagingAndChat: attributes.messagingAndChat ?? false,
parentalControls: attributes.parentalControls ?? false,
userGeneratedContent: attributes.userGeneratedContent ?? false,
};
}
@@ -211,4 +227,34 @@ export class AppleConfigReader {
// TODO: add attachment
};
}
/** Get screenshots configuration for a specific locale */
public getScreenshots(locale: string): AppleScreenshots | null {
return this.schema.info?.[locale]?.screenshots ?? null;
}
/** Get video previews configuration for a specific locale */
public getPreviews(locale: string): ApplePreviews | null {
return this.schema.info?.[locale]?.previews ?? null;
}
/** Get the App Clip configuration block, or null if no App Clip is configured. */
public getAppClip(): AppleAppClip | null {
return this.schema.appClip ?? null;
}
/** Get the App Clip default experience, or null if not configured. */
public getAppClipDefaultExperience(): AppleAppClipDefaultExperience | null {
return this.schema.appClip?.defaultExperience ?? null;
}
/** Locales that have App Clip default-experience info configured. */
public getAppClipLocales(): string[] {
return uniq(Object.keys(this.schema.appClip?.defaultExperience?.info ?? {}));
}
/** Get the localized App Clip info (subtitle + header image) for a specific locale. */
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
}
}
@@ -13,7 +13,14 @@ import {
} from '@expo/apple-utils';
import { AttributesOf } from '../../utils/asc';
import { AppleMetadata } from '../types';
import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
} from '../types';
/**
* Serializes the Apple ASC entities into the metadata configuration schema.
@@ -55,6 +62,15 @@ export class AppleConfigWriter {
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic:
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
advertising: attributes.advertising ?? false,
ageAssurance: attributes.ageAssurance ?? false,
ageRatingOverrideV2: attributes.ageRatingOverrideV2 ?? null,
developerAgeRatingInfoUrl: attributes.developerAgeRatingInfoUrl ?? null,
gunsOrOtherWeapons: attributes.gunsOrOtherWeapons ?? Rating.NONE,
healthOrWellnessTopics: attributes.healthOrWellnessTopics ?? false,
messagingAndChat: attributes.messagingAndChat ?? false,
parentalControls: attributes.parentalControls ?? false,
userGeneratedContent: attributes.userGeneratedContent ?? false,
};
}
@@ -186,6 +202,59 @@ export class AppleConfigWriter {
// TODO: add attachment
};
}
/** Set screenshots for a specific locale */
public setScreenshots(locale: string, screenshots: AppleScreenshots): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].screenshots =
Object.keys(screenshots).length > 0 ? screenshots : undefined;
}
/** Set video previews for a specific locale */
public setPreviews(locale: string, previews: ApplePreviews): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
}
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
public setAppClipDefaultExperience(
attributes: Pick<AppleAppClipDefaultExperience, 'action' | 'releaseWithAppStoreVersion'>
): void {
this.schema.appClip = this.schema.appClip ?? {};
this.schema.appClip.defaultExperience = {
...this.schema.appClip.defaultExperience,
action: attributes.action,
releaseWithAppStoreVersion: attributes.releaseWithAppStoreVersion,
};
}
/** Set the App Clip App Store review detail (invocation URLs). */
public setAppClipReviewDetail(reviewDetail: AppleAppClipReviewDetail | null): void {
this.schema.appClip = this.schema.appClip ?? {};
this.schema.appClip.defaultExperience = this.schema.appClip.defaultExperience ?? {};
if (reviewDetail && reviewDetail.invocationUrls.length > 0) {
this.schema.appClip.defaultExperience.reviewDetail = reviewDetail;
} else {
delete this.schema.appClip.defaultExperience.reviewDetail;
}
}
/** Set per-locale App Clip info (subtitle + header image). */
public setAppClipLocalizedInfo(locale: string, info: AppleAppClipLocalizedInfo): void {
this.schema.appClip = this.schema.appClip ?? {};
this.schema.appClip.defaultExperience = this.schema.appClip.defaultExperience ?? {};
this.schema.appClip.defaultExperience.info = this.schema.appClip.defaultExperience.info ?? {};
if (info.subtitle || info.headerImage) {
this.schema.appClip.defaultExperience.info[locale] = {
subtitle: optional(info.subtitle ?? null),
headerImage: optional(info.headerImage ?? null),
};
} else {
delete this.schema.appClip.defaultExperience.info[locale];
}
}
}
/** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */
+13 -2
View File
@@ -1,19 +1,30 @@
import type { App } from '@expo/apple-utils';
import type { AgeRatingData } from './tasks/age-rating';
import type { AppClipData } from './tasks/app-clip';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
/**
* The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks.
* It contains references to each individual models, to either upload or download App Store data.
*/
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData & AppReviewData;
export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppVersionData &
AgeRatingData &
AppReviewData &
ScreenshotsData &
PreviewsData &
AppClipData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
* It contains a reference to the app, each task should populate the necessary data.
* If an entity fails to prepare the data, individual tasks should raise errors about the missing data.
*/
export type PartialAppleData = { app: App } & Partial<Omit<AppleData, 'app'>>;
export type PartialAppleData = { app: App; projectDir: string } & Partial<
Omit<AppleData, 'app' | 'projectDir'>
>;
@@ -1,8 +1,14 @@
import { truthy } from '../../../utils/expodash/filter';
import { IssueRule } from '../../config/issue';
import { AppleInfo } from '../types';
const RESTRICTED_PROPERTIES: (keyof AppleInfo)[] = ['title', 'subtitle', 'description', 'keywords'];
/** Only check text properties that may contain restricted words */
type AppleInfoTextProperty = 'title' | 'subtitle' | 'description' | 'keywords';
const RESTRICTED_PROPERTIES: AppleInfoTextProperty[] = [
'title',
'subtitle',
'description',
'keywords',
];
const RESTRICTED_WORDS = {
beta: 'Apple restricts the word "beta" and synonyms implying incomplete functionality.',
};
@@ -5,4 +5,4 @@ import { getAdapter } from 'axios';
// see: https://github.com/nock/nock#axios
export { default } from 'nock';
getRequestClient().defaults.adapter = getAdapter('http');
getRequestClient().defaults.adapter = getAdapter('http') as any;
@@ -1,4 +1,4 @@
import { AgeRatingDeclaration, AppStoreVersion, Rating } from '@expo/apple-utils';
import { AgeRatingDeclaration, AppInfo, Rating } from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
@@ -12,13 +12,13 @@ jest.mock('../../config/writer');
describe(AgeRatingTask, () => {
describe('prepareAsync', () => {
it('loads age rating from app version', async () => {
it('loads age rating from app info', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
.get(`/v1/${AppInfo.type}/stub-id/ageRatingDeclaration`)
.reply(200, require('./fixtures/appStoreVersions/get-ageRatingDeclaration-200.json'));
const context: any = {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
info: new AppInfo(requestContext, 'stub-id', {} as any),
};
await new AgeRatingTask().prepareAsync({ context });
@@ -26,6 +26,16 @@ describe(AgeRatingTask, () => {
expect(context.ageRating).toBeInstanceOf(AgeRatingDeclaration);
expect(scope.isDone()).toBeTruthy();
});
it('skips when info is not available', async () => {
const context: any = {
info: undefined,
};
await new AgeRatingTask().prepareAsync({ context });
expect(context.ageRating).toBeUndefined();
});
});
describe('downloadAsync', () => {
@@ -54,13 +64,13 @@ describe(AgeRatingTask, () => {
});
describe('uploadAsync', () => {
it('aborts when age rating is not loaded', async () => {
const promise = new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
});
await expect(promise).rejects.toThrow('rating not initialized');
it('skips when age rating is not loaded', async () => {
await expect(
new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
})
).resolves.not.toThrow();
});
it('skips updating age rating when not configured', async () => {
@@ -0,0 +1,315 @@
import {
App,
AppClip,
AppClipAppStoreReviewDetail,
AppClipDefaultExperience,
AppClipDefaultExperienceLocalization,
AppStoreVersion,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData, PartialAppleData } from '../../data';
import { AppClipTask } from '../app-clip';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeExperience(
id: string,
attrs: Partial<AppClipDefaultExperience['attributes']> = {}
): AppClipDefaultExperience {
return new AppClipDefaultExperience(requestContext, id, {
action: null,
...attrs,
} as any);
}
function makeLocalization(
id: string,
locale: string,
subtitle: string | null = null
): AppClipDefaultExperienceLocalization {
return new AppClipDefaultExperienceLocalization(requestContext, id, {
locale,
subtitle,
} as any);
}
function makeVersion(id: string): AppStoreVersion {
return new AppStoreVersion(requestContext, id, { versionString: '1.0.0' } as any);
}
const APP_CLIPS_EMPTY_RESPONSE = {
data: [],
links: { self: 'https://api.appstoreconnect.apple.com/v1/apps/stub-id/appClips' },
meta: { paging: { total: 0, limit: 50 } },
};
describe(AppClipTask, () => {
describe('prepareAsync', () => {
it('initializes empty state when the app has no app clips', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(uri => uri.startsWith(`/v1/${App.type}/stub-id/${AppClip.type}`))
.reply(200, APP_CLIPS_EMPTY_RESPONSE);
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppClipTask().prepareAsync({ context });
expect(context.appClip).toBeNull();
expect(context.appClipDefaultExperience).toBeNull();
expect(context.appClipLocalizations?.size).toBe(0);
expect(context.appClipHeaderImages?.size).toBe(0);
expect(context.appClipReviewDetail).toBeNull();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when no default experience is loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new AppClipTask().downloadAsync({
config: writer,
context: {
appClip: null,
appClipDefaultExperience: null,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as any,
});
expect(writer.setAppClipDefaultExperience).not.toBeCalled();
expect(writer.setAppClipLocalizedInfo).not.toBeCalled();
expect(writer.setAppClipReviewDetail).not.toBeCalled();
});
it('writes from current version experience when present', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const experience = makeExperience('exp-current', { action: 'PLAY' as any });
const localization = makeLocalization('loc-1', 'en-US', 'Quick play');
await new AppClipTask().downloadAsync({
config: writer,
context: {
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
appClipDefaultExperience: experience,
appClipTemplateExperience: null,
appClipLocalizations: new Map([['en-US', localization]]),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
projectDir: '/test/project',
} as any,
});
expect(writer.setAppClipDefaultExperience).toBeCalledWith(
expect.objectContaining({ action: 'PLAY' })
);
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
'en-US',
expect.objectContaining({ subtitle: 'Quick play' })
);
});
it('falls back to template experience when current version has none', async () => {
// Regression test: when bumping the app version, the new version has no
// default experience yet. Pull should still populate config from the
// most recent prior experience (the template) so the user has data to
// edit, instead of writing nothing.
const writer = jest.mocked(new AppleConfigWriter());
const template = makeExperience('exp-template', { action: 'OPEN' as any });
const localization = makeLocalization('loc-1', 'en-US', 'Old subtitle');
await new AppClipTask().downloadAsync({
config: writer,
context: {
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
appClipDefaultExperience: null,
appClipTemplateExperience: template,
appClipLocalizations: new Map([['en-US', localization]]),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
projectDir: '/test/project',
} as any,
});
expect(writer.setAppClipDefaultExperience).toBeCalledWith(
expect.objectContaining({ action: 'OPEN' })
);
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
'en-US',
expect.objectContaining({ subtitle: 'Old subtitle' })
);
});
it('preserves expected local path when header image is not yet downloadable', async () => {
// Regression test for the App Clip header image race condition: ASC
// hasn't yet rendered the imageAsset (typically right after a fresh
// upload), so the download URL is null. We should still preserve the
// headerImage path in config so subsequent pushes don't try to delete
// the in-progress upload.
const writer = jest.mocked(new AppleConfigWriter());
const experience = makeExperience('exp-1', { action: 'OPEN' as any });
const localization = makeLocalization('loc-1', 'en-US', 'Sub');
// Header image with no rendered imageAsset (still processing).
const headerImage = {
attributes: { fileName: 'header.png', imageAsset: null },
getImageAssetUrl: () => null,
} as any;
await new AppClipTask().downloadAsync({
config: writer,
context: {
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
appClipDefaultExperience: experience,
appClipTemplateExperience: null,
appClipLocalizations: new Map([['en-US', localization]]),
appClipHeaderImages: new Map([['en-US', headerImage]]),
appClipReviewDetail: null,
projectDir: '/test/project',
} as any,
});
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
'en-US',
expect.objectContaining({
subtitle: 'Sub',
headerImage: 'store/apple/app-clip/en-US/header.png',
})
);
});
});
describe('uploadAsync', () => {
it('skips when no app clip is configured in store config', async () => {
const reader = new AppleConfigReader({});
await new AppClipTask().uploadAsync({
config: reader,
context: {
appClip: null,
appClipDefaultExperience: null,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as AppleData,
});
// No HTTP calls should be made when nothing is configured.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('warns and aborts when ASC has no app clip but config does', async () => {
const reader = new AppleConfigReader({
appClip: {
defaultExperience: { action: 'OPEN' },
},
});
await new AppClipTask().uploadAsync({
config: reader,
context: {
appClip: null,
appClipDefaultExperience: null,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as AppleData,
});
// No HTTP calls when there's no clip in ASC to update.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('warns and aborts when no editable app store version is loaded', async () => {
const reader = new AppleConfigReader({
appClip: { defaultExperience: { action: 'OPEN' } },
});
await new AppClipTask().uploadAsync({
config: reader,
context: {
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
version: null,
appClipDefaultExperience: null,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as any,
});
expect(nock.pendingMocks()).toHaveLength(0);
});
it('skips PATCH when default experience action already matches config', async () => {
// Regression test: Apple rejects PATCHes on `action` once the linked
// version is locked (in review/released), so we must skip the call when
// nothing changed to avoid spurious failures on round-trip pushes.
const reader = new AppleConfigReader({
appClip: { defaultExperience: { action: 'PLAY' } },
});
const existing = makeExperience('exp-current', { action: 'PLAY' as any });
// Spy on updateAsync to assert it's not invoked.
const updateSpy = jest.spyOn(existing, 'updateAsync');
await new AppClipTask().uploadAsync({
config: reader,
context: {
app: new App(requestContext, 'app-1', {} as any),
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
version: makeVersion('ver-1'),
appClipDefaultExperience: existing,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as any,
});
expect(updateSpy).not.toBeCalled();
});
it('skips review detail PATCH when invocation URLs already match', async () => {
const reader = new AppleConfigReader({
appClip: {
defaultExperience: {
action: 'OPEN',
reviewDetail: { invocationUrls: ['https://a.example/', 'https://b.example/'] },
},
},
});
const existing = makeExperience('exp-current', { action: 'OPEN' as any });
const review = new AppClipAppStoreReviewDetail(requestContext, 'rev-1', {
invocationUrls: ['https://a.example/', 'https://b.example/'],
} as any);
const updateSpy = jest.spyOn(review, 'updateAsync');
await new AppClipTask().uploadAsync({
config: reader,
context: {
app: new App(requestContext, 'app-1', {} as any),
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
version: makeVersion('ver-1'),
appClipDefaultExperience: existing,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: review,
} as any,
});
expect(updateSpy).not.toBeCalled();
});
});
});
@@ -23,6 +23,7 @@ describe(AppInfoTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppInfoTask().prepareAsync({ context });
@@ -72,20 +72,20 @@ describe(AppReviewDetailTask, () => {
nock.cleanAll();
});
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: 'review@example.com',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
});
await expect(promise).rejects.toThrow('version not init');
it('skips when version is not loaded', async () => {
await expect(
new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
})
).resolves.not.toThrow();
});
it('updates review details when loaded', async () => {
@@ -44,6 +44,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
@@ -83,6 +84,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
@@ -118,6 +120,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask().prepareAsync({ context });
@@ -153,6 +156,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask().prepareAsync({ context });
@@ -194,6 +198,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ version: '2.0' }).prepareAsync({ context });
@@ -243,6 +248,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ version: '3.0' }).prepareAsync({ context });
@@ -371,6 +377,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
};
@@ -399,6 +406,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
versionPhasedRelease: null, // Not enabled yet
};
@@ -417,6 +425,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
// Enabled, and not completed yet
versionPhasedRelease: new AppStoreVersionPhasedRelease(
@@ -440,8 +449,9 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
// Enabled, and not completed yet
// Enabled, and completed
versionPhasedRelease: new AppStoreVersionPhasedRelease(
requestContext,
'APP_STORE_VERSION_PHASED_RELEASE_1',
@@ -0,0 +1,496 @@
import {
AppPreview,
AppPreviewSet,
AppStoreVersionLocalization,
PreviewType,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { PreviewsTask } from '../previews';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
__esModule: true,
default: (...args: any[]) => mockFetch(...args),
}));
import fs from 'fs';
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 10240 } as any);
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
describe(PreviewsTask, () => {
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('prepareAsync', () => {
it('initializes empty preview sets when no locales are available', async () => {
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: undefined,
};
await new PreviewsTask().prepareAsync({ context });
expect(context.previewSets).toBeDefined();
expect(context.previewSets!.size).toBe(0);
});
it('fetches preview sets for each locale', async () => {
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppPreviewSet.type}`)
.query(true)
.reply(200, {
data: [
{
id: 'PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
],
});
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: [locale],
};
await new PreviewsTask().prepareAsync({ context });
expect(context.previewSets).toBeDefined();
expect(context.previewSets!.size).toBe(1);
expect(context.previewSets!.get('en-US')).toBeDefined();
expect(context.previewSets!.get('en-US')!.has(PreviewType.IPHONE_67)).toBeTruthy();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when preview sets are not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PreviewsTask().downloadAsync({
config: writer,
context: { previewSets: undefined, versionLocales: undefined } as any,
});
expect(writer.setPreviews).not.toBeCalled();
});
it('skips locales with no preview sets', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const previewSets = new Map();
previewSets.set('en-US', new Map());
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
} as any,
});
expect(writer.setPreviews).not.toBeCalled();
});
it('downloads previews and sets config with simple path', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const preview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
videoUrl: 'https://example.com/demo.mp4',
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [preview],
} as any);
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
});
});
it('downloads previews with previewFrameTimeCode as object config', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const preview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:05:00',
videoUrl: 'https://example.com/demo.mp4',
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [preview],
} as any);
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: {
path: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
previewFrameTimeCode: '00:05:00',
},
});
});
it('preserves entries with placeholder paths when videoUrl is null (broken state)', async () => {
// Same regression as for screenshots: previews stuck in AWAITING_UPLOAD
// with no rendered videoUrl used to be dropped from config. Now we
// preserve the entry so the user can recover.
const writer = jest.mocked(new AppleConfigWriter());
const broken = new AppPreview(requestContext, 'PV_BROKEN', {
fileName: 'demo.mp4',
fileSize: 12345,
videoUrl: null,
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
} as any);
jest.spyOn(broken, 'getVideoUrl').mockReturnValue(null);
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
previewTypeMap.set(
PreviewType.IPHONE_67,
new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [broken],
} as any)
);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets: new Map([['en-US', previewTypeMap]]),
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(mockFetch).not.toBeCalled();
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
});
});
});
describe('uploadAsync', () => {
it('skips when preview sets are not prepared', async () => {
const config = new AppleConfigReader({});
await new PreviewsTask().uploadAsync({
config,
context: { previewSets: undefined, versionLocales: undefined } as any,
});
// Should not throw, just log and return
});
it('skips when no locales are configured', async () => {
const config = new AppleConfigReader({});
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets: new Map(),
versionLocales: [],
} as any,
});
// Should not throw, just log and return
});
it('skips locales without previews in config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No previews
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets: new Map(),
versionLocales: [locale],
} as any,
});
// Should complete without uploading
});
it('uploads new preview for configured locale with string path', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: './previews/demo.mp4',
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
// Create preview set
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_PSET_1',
filePath: '/test/project/previews/demo.mp4',
waitForProcessing: true,
})
);
});
it('uploads new preview with object config including previewFrameTimeCode', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: {
path: './previews/demo.mp4',
previewFrameTimeCode: '00:05:00',
},
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:05:00',
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
// Create preview set
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_PSET_1',
filePath: '/test/project/previews/demo.mp4',
waitForProcessing: true,
previewFrameTimeCode: '00:05:00',
})
);
});
it('deletes remote previews when preview type is removed from config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No previews configured — the IPHONE_67 preview should be deleted
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const existingPreview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const deleteScope = nock('https://api.appstoreconnect.apple.com')
.delete(`/v1/${AppPreview.type}/PV_1`)
.reply(204);
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
previewTypeMap.set(
PreviewType.IPHONE_67,
new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [existingPreview],
} as any)
);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(deleteScope.isDone()).toBeTruthy();
});
});
});
@@ -0,0 +1,392 @@
import {
AppScreenshot,
AppScreenshotSet,
AppStoreVersionLocalization,
ScreenshotDisplayType,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { ScreenshotsTask } from '../screenshots';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
__esModule: true,
default: (...args: any[]) => mockFetch(...args),
}));
import fs from 'fs';
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 1024 } as any);
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
describe(ScreenshotsTask, () => {
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('prepareAsync', () => {
it('initializes empty screenshot sets when no locales are available', async () => {
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: undefined,
};
await new ScreenshotsTask().prepareAsync({ context });
expect(context.screenshotSets).toBeDefined();
expect(context.screenshotSets!.size).toBe(0);
});
it('fetches screenshot sets for each locale', async () => {
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppScreenshotSet.type}`)
.query(true)
.reply(200, {
data: [
{
id: 'SET_1',
type: AppScreenshotSet.type,
attributes: {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [],
},
},
],
});
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: [locale],
};
await new ScreenshotsTask().prepareAsync({ context });
expect(context.screenshotSets).toBeDefined();
expect(context.screenshotSets!.size).toBe(1);
expect(context.screenshotSets!.get('en-US')).toBeDefined();
expect(
context.screenshotSets!.get('en-US')!.has(ScreenshotDisplayType.APP_IPHONE_67)
).toBeTruthy();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when screenshot sets are not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new ScreenshotsTask().downloadAsync({
config: writer,
context: { screenshotSets: undefined, versionLocales: undefined } as any,
});
expect(writer.setScreenshots).not.toBeCalled();
});
it('skips locales with no screenshot sets', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const screenshotSets = new Map();
screenshotSets.set('en-US', new Map());
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
} as any,
});
expect(writer.setScreenshots).not.toBeCalled();
});
it('downloads screenshots and sets config', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const screenshot = new AppScreenshot(requestContext, 'SS_1', {
fileName: 'home.png',
fileSize: 1024,
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
// Mock getImageAssetUrl
jest.spyOn(screenshot, 'getImageAssetUrl').mockReturnValue('https://example.com/home.png');
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
const screenshotSet = new AppScreenshotSet(requestContext, 'SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [screenshot],
} as any);
displayTypeMap.set(ScreenshotDisplayType.APP_IPHONE_67, screenshotSet);
const screenshotSets = new Map();
screenshotSets.set('en-US', displayTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
// Mock fetch response
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-image-data')),
});
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setScreenshots).toBeCalledWith('en-US', {
[ScreenshotDisplayType.APP_IPHONE_67]: [
'store/apple/screenshot/en-US/APP_IPHONE_67/home.png',
],
});
});
it('preserves entries with placeholder paths when imageAsset is null (broken state)', async () => {
// Regression test for screenshots stuck in AWAITING_UPLOAD with no
// rendered imageAsset. Pull used to drop these from config entirely,
// which made it impossible to recover via push (since push only acts on
// entries present in config). Now pull writes a placeholder path so the
// user can drop in a replacement file or remove the entry to delete the
// broken ASC record.
const writer = jest.mocked(new AppleConfigWriter());
const broken = new AppScreenshot(requestContext, 'SS_BROKEN', {
fileName: '01.png',
fileSize: 599307,
imageAsset: null,
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
} as any);
// getImageAssetUrl returns null when imageAsset is null.
jest.spyOn(broken, 'getImageAssetUrl').mockReturnValue(null);
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
const screenshotSet = new AppScreenshotSet(requestContext, 'SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [broken],
} as any);
displayTypeMap.set(ScreenshotDisplayType.APP_IPHONE_67, screenshotSet);
const screenshotSets = new Map([['en-US', displayTypeMap]]);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
// No fetch should have been attempted (URL was null).
expect(mockFetch).not.toBeCalled();
// Entry should still be present in config with the original filename.
expect(writer.setScreenshots).toBeCalledWith('en-US', {
[ScreenshotDisplayType.APP_IPHONE_67]: [
'store/apple/screenshot/en-US/APP_IPHONE_67/01.png',
],
});
});
it('uses index-based fallback filename when fileName is also null', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const broken = new AppScreenshot(requestContext, 'SS_BROKEN', {
fileName: null,
fileSize: 0,
imageAsset: null,
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
} as any);
jest.spyOn(broken, 'getImageAssetUrl').mockReturnValue(null);
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
displayTypeMap.set(
ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129,
new AppScreenshotSet(requestContext, 'SET_2', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129,
appScreenshots: [broken],
} as any)
);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets: new Map([['en-US', displayTypeMap]]),
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setScreenshots).toBeCalledWith('en-US', {
[ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129]: [
'store/apple/screenshot/en-US/APP_IPAD_PRO_3GEN_129/01.png',
],
});
});
});
describe('uploadAsync', () => {
it('skips when screenshot sets are not prepared', async () => {
const config = new AppleConfigReader({});
await new ScreenshotsTask().uploadAsync({
config,
context: { screenshotSets: undefined, versionLocales: undefined } as any,
});
// Should not throw, just log and return
});
it('skips when no locales are configured', async () => {
const config = new AppleConfigReader({});
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets: new Map(),
versionLocales: [],
} as any,
});
// Should not throw, just log and return
});
it('skips locales without screenshots in config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No screenshots
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets: new Map(),
versionLocales: [locale],
} as any,
});
// Should complete without uploading
});
it('uploads new screenshots for configured locale', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
screenshots: {
APP_IPHONE_67: ['./screenshots/home.png'],
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newScreenshot = new AppScreenshot(requestContext, 'NEW_SS_1', {
fileName: 'home.png',
fileSize: 1024,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
// Mock createAppScreenshotSetAsync on the locale
const scope = nock('https://api.appstoreconnect.apple.com')
// Create screenshot set
.post(`/v1/${AppScreenshotSet.type}`)
.reply(201, {
data: {
id: 'NEW_SET_1',
type: AppScreenshotSet.type,
attributes: {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [],
},
},
});
// Mock AppScreenshot.uploadAsync to avoid real file access
jest.spyOn(AppScreenshot, 'uploadAsync').mockResolvedValue(newScreenshot);
// Mock AppScreenshotSet.infoAsync for reorder step
jest.spyOn(AppScreenshotSet, 'infoAsync').mockResolvedValue(
new AppScreenshotSet(requestContext, 'NEW_SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [newScreenshot],
} as any)
);
// Mock reorderScreenshotsAsync
const reorderMock = jest.fn().mockResolvedValue([]);
AppScreenshotSet.prototype.reorderScreenshotsAsync = reorderMock;
const screenshotSets = new Map();
screenshotSets.set('en-US', new Map());
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppScreenshot.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_SET_1',
filePath: '/test/project/screenshots/home.png',
waitForProcessing: true,
})
);
// Reorder is skipped because the current order already matches
expect(reorderMock).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,4 @@
import { AgeRatingDeclaration } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
@@ -7,7 +6,7 @@ import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
export type AgeRatingData = {
/** The app age rating declaration for the app version */
/** The app age rating declaration for the app info */
ageRating: AgeRatingDeclaration;
};
@@ -15,7 +14,22 @@ export class AgeRatingTask extends AppleTask {
public name = (): string => 'age rating declarations';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
if (!context.info) {
return;
}
// The ageRatingDeclaration relationship is on appInfos (not appStoreVersions).
try {
context.ageRating = (await context.info.getAgeRatingDeclarationAsync()) ?? undefined;
} catch (error: any) {
// Gracefully handle cases where the relationship is not available
if (error?.message?.includes('ageRatingDeclaration')) {
Log.warn(
chalk`{yellow Skipped age rating - not available for this app. This may require updating through App Store Connect directly.}`
);
return;
}
throw error;
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -25,7 +39,10 @@ export class AgeRatingTask extends AppleTask {
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
assert(context.ageRating, `Age rating not initialized, can't update age rating`);
if (!context.ageRating) {
Log.log(chalk`{dim - Skipped age rating update, not available}`);
return;
}
const ageRating = config.getAgeRating();
if (!ageRating) {
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more