Compare commits

...
Author SHA1 Message Date
Expo CI 490457976c v20.3.0 2026-06-18 17:44:26 +00:00
Gabe Debes 787fe4d3a7 [eas-cli] Make integrations:posthog:connect resilient to dynamic app configs (#3877) 2026-06-18 09:54:59 -07:00
Gabe Debes 2dae2cdd0e [eas-cli] Add integrations:posthog:disconnect (#3838) 2026-06-18 09:44:07 -07:00
Gabe Debes 967dff73df [eas-cli] Add integrations:posthog:dashboard (#3837) 2026-06-18 09:29:04 -07:00
Stanisław Chmiela 3a3995817e [build-tools] Fix Argent remote session startup (#3881)
Fixes Argent remote simulator sessions getting stuck immediately after starting the Argent tool-server.

The latest Argent support change switched the startup path to `bunx @swmansion/argent server start ...` and awaited that process. When the server process stays in the foreground, the build step never advances to read the tool-server state file, start the ngrok tunnel, or report remote config back to the API.

This restores detached process launching for the Argent tool-server while keeping the new server/auth flow. If the state file is not created in time, the error now includes captured Argent process output to aid diagnosis.

Failing job run referenced while debugging: https://expo.dev/accounts/sjchmiela/projects/test-production/job-runs/019eda48-6467-789d-8c5e-a7ed5f4ec89d
2026-06-18 18:26:47 +02:00
Gabe Debes 8a392544f2 [eas-cli] Add integrations:posthog:connect (#3836)
## Why

Connect an Expo app to PostHog in one command. It provisions the PostHog project, asks which features you want, installs the SDK, and writes the env vars — so analytics / session replay / error tracking work after a build.

## How

Fast-forward order — provision first, then ask what to wire up:

1. **Provision** — reuses your account's PostHog org + this app's project, or creates them. Only a brand-new connection asks a question: **region** (US / EU), which sets data residency and can't be changed later.
2. **Pick features** — a multiselect, all preselected: Analytics, Session replay, Error tracking (source maps, _requires a personal API key_).
3. **Error tracking, if selected** — prompts you to paste a PostHog personal API key (we can't mint it — you create it in PostHog and paste it in).

It then:

- **Installs** `posthog-react-native` (+ `expo-file-system`, `expo-application`, `expo-device`, `expo-localization`; plus `posthog-react-native-session-replay` when replay is on) via `npx expo install`
- **Adds** the `posthog-react-native/expo` config plugin (prints manual steps if your app config is dynamic)
- **Writes env vars** to `.env.local` (local dev) and EAS (Production / Preview / Development):
  - Analytics → `EXPO_PUBLIC_POSTHOG_API_KEY`, `EXPO_PUBLIC_POSTHOG_HOST` (public)
  - Error tracking → `POSTHOG_CLI_API_KEY` (sensitive), `POSTHOG_CLI_PROJECT_ID`, `POSTHOG_CLI_HOST` (public)
- Source-map uploads on builds are covered by our guide; the command just sets the `POSTHOG_CLI_*` vars.

This PR also includes the PostHog GraphQL layer the command builds on — `PostHogQuery` / `PostHogMutation`, the connection/project types, and `commandUtils/posthog` (dashboard URL).

**Flags:** `--region`, `--session-replay` / `--no-session-replay`, `--error-tracking` / `--no-error-tracking`, `--posthog-cli-api-key`, `--overwrite`, plus `--json` / `--non-interactive`. Agent-ready: error tracking auto-skips without a key (or pass `--posthog-cli-api-key`); `--error-tracking` without a key fails before provisioning; `--region` is required non-interactively.

## Test Plan

- CI — 34 unit tests, full coverage of `connect.ts`
- Ran end-to-end against production: provisioned a PostHog project, selected all features, and confirmed the env vars land in `.env.local` + EAS and the SDK packages / config plugin are added

<img width="2048" height="2718" alt="carbon (1)" src="https://github.com/user-attachments/assets/073a5c41-7f07-48d6-b5b7-b1de3cff89fa" />
2026-06-18 09:20:28 -07:00
Stanisław Chmiela 84015ebe32 [build-tools] Upgrade Argent support (#3879)
- v0.11 of Argent added official support for "remote mode", i.e. we don't need to start the MCP server for _it_ to start the tools-server, we can just start the tools-server.
- just to be sure I've added a min Argent version check.
- new Argent versions also guard the tools server by authorization, so added `authToken` support
- new Argent versions also check `Host` header so made ngrok rewrite it to `localhost:...` to fix error below

<img width="868" height="538" alt="Zrzut ekranu 2026-06-18 o 10 59 54" src="https://github.com/user-attachments/assets/6b3689a4-9359-4f8f-bba7-4e98e97ff0cd" />
2026-06-18 12:13:26 +02:00
Jeff C 7a38191692 [eas-cli] Fix update:embedded topic description (#3866)
* [eas-cli] Fix update:embedded topic description

Without an explicit topic entry, oclif derived the "update:embedded"
group description from a subcommand, so the topic listing showed
"delete an embedded update registered with EAS Update" — confusingly
describing only the delete subcommand. Add an explicit topic description.

* [eas-cli] Add changelog entry for update:embedded topic fix
2026-06-17 11:42:34 -07:00
Keith KurakandClaude Opus 4.8 3e393650dc [eas-cli] Add account:audit command to view account audit logs (#3863)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-16 14:58:44 -04:00
Keith KurakandClaude Opus 4.8 5e2ecb5997 [eas-cli] Support platform-specific update IDs in update:view (#3864)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-16 11:41:39 -04:00
Will Schurman 🦦 51aceb1db0 [eas-cli] Simplify 2FA now that SMS is no longer supported (#3859) 2026-06-15 16:22:07 -07:00
Expo CI 90f78ab2ae update CHANGELOG.md 2026-06-15 20:16:07 +00:00
Expo CI 203e9997ee v20.2.0 2026-06-15 20:13:05 +00:00
Quinlan Jung 🦥andClaude Opus 4.8 792af28a5d [eas-cli] add support for non-interactive update:rollback (#3825)
* [eas-cli] add support for non-interactive update:rollback

* [eas-cli] Add CHANGELOG entry for non-interactive update:rollback

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-15 13:02:37 -07:00
Mustafa Abban e62bb1a840 [worker] Resolve runtime cache settings from job env and fallback on npm cache failures (#3855)
* [worker] Resolve runtime cache settings from job env and fallback on npm cache failures

* [worker] Resolve runtime cache settings from job env and fallback on npm cache failures

* no longer url fallback

* use env over runtime stngs

* report log to sentry only

* proper subclass errs

* proper subclass errs

* proper subclass errs

* runtime setting override, rem set registry cmd

* continue support DISABLE flags, in line err tracker
2026-06-15 13:46:25 -04:00
Stanisław Chmiela f8c2b2daf4 [worker] Remove legacy upload code, add retries, auto-waive builds (#3848)
## What changed

- Removed `gcsSignedUploadUrlForApplicationArchive` and `gcsSignedUploadUrlForBuildArtifacts` from worker runtime config and runtime config typing.
- Removed launcher signed-upload fallback behavior for managed application archive and build artifacts uploads.
- Classified managed artifact upload failures as `SystemError`s with internal tracking codes while keeping the public error code as `SERVER_ERROR`.
- Added retry options to worker upload API `turtleFetch` calls for upload-session creation and artifact metadata saving.

## Why

These signed upload URL fields are no longer expected, so keeping fallback paths made the worker handle an obsolete runtime contract. Upload failures in these managed paths are infrastructure/API/storage failures rather than user-actionable project issues.

## Validation

- `yarn oxfmt packages/worker/src/config.ts packages/worker/src/upload.ts packages/worker/src/external/turtle.ts packages/worker/src/__unit__/upload.test.ts`
- `yarn workspace @expo/worker test:unit src/__unit__/upload.test.ts --runInBand`
- `yarn workspace @expo/worker typecheck`
2026-06-15 17:38:41 +02:00
Mustafa Abban 22fc4da732 [build-tools] Enable Gradle cache via machine-level gradle.properties to avoid fingerprint mutation (#3852) 2026-06-12 09:34:58 -04:00
Stanisław Chmiela 1c7c79d445 [build-tools] Drop legacy cache URL fallbacks (#3856)
## Summary

Drops the temporary legacy cache URL fallback path now that the worker env-var PR is deployed.

This removes the fallback URL state from runtime settings, removes the old cache URL fields from worker runtime config parsing/types, and removes the now-obsolete config fallback test.

## Impact

Cache URL sourcing now relies on the runtime-settings cache gates plus the new worker cache URL environment variables only. Public build env names remain unchanged.

## Validation

CI should pass. Once deployed to staging I'll confirm the env vars and cache config is there.
2026-06-12 10:06:16 +02:00
Szymon Świerk b30dd9054d [eas-cli] Validate/regenerate/create provisioning profile in non-interactive iOS builds using submission ASC API key when present (#3805)
# Why

Many projects already configure an App Store Connect API key in EAS credentials for submissions, but non-interactive iOS App Store and Enterprise builds (`eas build --non-interactive`) could not use that key when validating or repairing provisioning profiles on Apple servers. Instead, CI had to provide `EXPO_ASC_*` environment variables separately, even when a usable key was already stored on EAS.

This makes non-interactive credential setup more convenient by reusing the EAS-stored submission ASC API key for App Store and Enterprise profile validation and repair.

# How

- Added `tryAuthenticateAppStoreWithEasAscApiKeyAsync` in `AscApiKeyUtils.ts` — a best-effort, non-throwing helper that authenticates `ctx.appStore` in non-interactive mode using:
  1. `EXPO_ASC_API_KEY_PATH` / `EXPO_ASC_KEY_ID` / `EXPO_ASC_ISSUER_ID` environment variables, or
  2. The App Store Connect API key configured for submissions on the app in EAS credentials.
- In `SetUpProvisioningProfile.runAsync`, call this helper before Apple-side validation when running non-interactively without an existing auth context.
- Use actual authentication state (`ctx.appStore.authCtx`) instead of startup `defaultAuthenticationMode` when deciding whether Apple authentication is available in `SetUpProvisioningProfile`, `CreateProvisioningProfile`, and `ConfigureProvisioningProfile`.
- In non-interactive mode, treat unexpected Apple validation errors as best-effort: log a warning and continue rather than failing the build.
- Extracted shared `resolveAscApiKeyForAppCredentialsAsync` (previously duplicated in `SetUpAdhocProvisioningProfile`) for reuse across credential flows.
- Derive the `AppleTeamType` for profile operations from the distribution type (`IN_HOUSE` for enterprise, `COMPANY_OR_ORGANIZATION` otherwise), with `EXPO_APPLE_TEAM_TYPE` override.
- Updated error messages to mention both `EXPO_ASC_*` environment variables and EAS-stored ASC API keys.

Out of scope: ad-hoc provisioning profile refresh (opt-in, handled separately via `--refresh-ad-hoc-provisioning-profile`).

# Test Plan

- Added unit tests for auto-authentication, team type resolution, best-effort validation skip, and missing-auth paths in `SetUpProvisioningProfile-test.ts`.
- Tested manually:
  - Run an interactive iOS production build and create a provisioning profile
  - Confirm non-interactive build works with that profile
  - Revoke the provisioning profile in the Apple Developer portal
  - Confirm non-interactive build succeeds and the profile is repaired when the submission ASC API key is configured in app credentials (`easd build --platform ios --profile production --non-interactive`)
2026-06-11 19:12:58 +02:00
Szymon DziedzicandClaude Opus 4.7 7af74c605e [eas-cli] Add --max-duration-minutes flag to eas simulator:start (#3806)
* [eas-cli] Regenerate GraphQL schema

* [eas-cli] Add --max-duration-minutes flag to eas simulator:start

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

* [eas-cli] Only reject negative --max-duration-minutes in simulator:start

Leave the upper-bound check to the server instead of capping it client-side.

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

---------

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-06-11 16:58:14 +02:00
Szymon DziedzicandClaude Opus 4.8 a5fccedf4e build-tools: fetch Cloudflare TURN ICE servers from www and pass to serve-sim (#3828)
When launching serve-sim, the worker requests short-lived TURN ICE servers from www's turtle-job-runs route (same robot-token pattern as the project clone URL) and forwards them as --stun-url/--turn-url/--turn-username/--turn-credential. Covers all three serve-sim launchers (serve_sim, agent_device, argent).

Best-effort: on any failure serve-sim falls back to its built-in P2P/STUN behavior, so a TURN outage never blocks a session.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-11 16:43:24 +02:00
Ash Wu f1aa26d9f3 [build-tools] read JUnit new file attribute for Maestro 2.6+ (#3839)
Signed-off-by: Ash Wu <[email protected]>
2026-06-11 20:59:27 +08:00
Szymon Świerk 68a0847058 [build-tools] Query GQL for build download URL in the downloadBuild function, instead of constructing it manually (#3842)
* [build-tools] Query GQL for build download URL in the downloadBuild function, instead of constructing it manually

* more precise error handling (make use of UserError/SystemError)
2026-06-11 14:15:13 +02:00
Stanisław Chmiela 1431a20b86 Log user-provided custom functions during parsing (#3847)
I want to learn how much are custom functions being used.
2026-06-11 10:40:49 +02:00
Szymon Dziedzic d2ed1feb67 Speed up agent-device session startup (#3841) 2026-06-10 14:29:55 +02:00
Stanisław Chmiela cc49343b39 [worker] Use runtime settings from storage (#3801)
<!-- 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

https://github.com/expo/workflow-orchestration/pull/851

# How

Added `RuntimeSettings` namespace in `build-tools`. It is expected to be called in the beginning with `loadAsync` to fetch data from the bucket.

# Test Plan

Once approved deploy to staging, run a few builds with different settings.
2026-06-10 12:21:53 +02:00
Ash Wu 10f9edc910 [build-tools] fix getMaestroVersion parsing error (#3834)
Signed-off-by: Ash Wu <[email protected]>
2026-06-10 16:21:51 +08:00
Stanisław Chmiela 9d1a6b7796 [scripts] Add runtime settings editor (#3812)
I want us to move off of killswitches in Redis coming from `workflow-orchestration` towards fetching killswitches from a simple JSON file.

I picked GCS over R2 because it lets us make objects fully public (no `Authorization` required) and I (Stanley) found no way to achieve the same thing with R2.

This pull request adds a script `scripts/edit-runtime-settings staging | production` that is going to let us edit the file.
2026-06-09 14:14:50 +02:00
Gabe Debes d8cca2c8e8 [expo-cocoapods-proxy] Bump bundler to fix compatibility with RubyGems 4 (#3824)
* [expo-cocoapods-proxy] Bump bundler to fix compatibility with RubyGems 4

* update CHANGELOG.md

* Add manual worker build workflow

* Add manual worker build workflow

* tmp: trigger worker build on branch push

* Trigger worker build on PRs touching worker or cocoapods-proxy

* Print job statuses on EAS workflow success

* Remove manual worker build workflow
2026-06-08 08:59:27 -07:00
Kudo Chien fb818fce54 [repack] add ios signing entitlements params (#3803) 2026-06-08 22:52:07 +08:00
Ash Wu 11263c23a0 [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output (#3818)
* [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output

Signed-off-by: Ash Wu <[email protected]>

* address comments

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-06-08 21:04:27 +08:00
Ash Wu 80baa13ffa [build-tools] track installed maestro cli version (#3822)
Signed-off-by: Ash Wu <[email protected]>
2026-06-08 15:05:54 +08:00
Expo CI 58982ee212 update CHANGELOG.md 2026-06-05 21:39:01 +00:00
Expo CI 33225d2b73 v20.1.0 2026-06-05 21:36:09 +00:00
Gabe Debes 7a97f297ee [eas-cli] Add eas update:embedded:delete command (#3809) 2026-06-05 14:27:30 -07:00
Gabe Debes 5f952fd33b [eas-cli] Add eas update:embedded:list command (#3811)
* [eas-cli] Add eas update:embedded:list command

* [eas-cli] Put "All channels" first in the channel-filter prompt

* [eas-cli] Refresh graphql schema snapshot
2026-06-05 14:09:31 -07:00
Gabe Debes 45354d12da [eas-cli] Add eas update:embedded:view command (#3810)
* [eas-cli] Add eas update:embedded:view command

* [eas-cli] Unit tests for EmbeddedUpdateQuery wrapper
2026-06-05 10:04:14 -07:00
Ash Wu 598f0050be [build-tools] Route Datadog metrics and logs by build vs job-run target (#3819)
Signed-off-by: Ash Wu <[email protected]>
2026-06-05 14:01:20 +08:00
111 changed files with 13439 additions and 1324 deletions

No files matched your search

+2 -1
View File
@@ -5,5 +5,6 @@
"**/*.code-search": true,
"**/src/**/build/**": false,
"**/src/build/**": false,
}
},
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
+30 -1
View File
@@ -8,12 +8,41 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas account:audit [ACCOUNT_NAME]` command to view an account's audit logs. ([#3863](https://github.com/expo/eas-cli/pull/3863) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] `eas update:view [GROUP_ID]` now also accepts a platform-specific update ID, resolving it to and displaying its update group. ([#3864](https://github.com/expo/eas-cli/pull/3864) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:disconnect` command. ([#3838](https://github.com/expo/eas-cli/pull/3838) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] `eas integrations:posthog:connect` no longer aborts when `npx expo install` reports an error (for example, on a dynamic app config); it finishes writing the environment variables and prints any remaining manual steps. ([#3877](https://github.com/expo/eas-cli/pull/3877) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman))
## [20.2.0](https://github.com/expo/eas-cli/releases/tag/v20.2.0) - 2026-06-15
### 🎉 New features
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add support for non-interactive `eas update:rollback [GROUP_ID]`. The given update group must be the latest for its branch and runtime version; the previous update group is republished, or a roll back to the embedded update is published if there is none. ([#3825](https://github.com/expo/eas-cli/pull/3825) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `update:embedded` topic showing the `update:embedded:delete` subcommand description in the command listing. ([#3866](https://github.com/expo/eas-cli/pull/3866) by [@jc-expo](https://github.com/jc-expo))
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
### 🎉 New features
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "20.0.0",
"version": "20.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2
View File
@@ -3,4 +3,6 @@ _.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "20.0.0",
"version": "20.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,14 +38,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "20.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.0.0",
"@expo/steps": "20.1.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -77,7 +77,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.4.1",
"@expo/repack-app": "~0.6.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -22,13 +22,13 @@ describe('Datadog singleton', () => {
Datadog.setup(null);
});
it('POSTs distribution metrics to the turtle build metrics endpoint', () => {
it('POSTs distribution metrics to the turtle build metrics endpoint for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1234, {
@@ -51,6 +51,7 @@ describe('Datadog singleton', () => {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
target: 'build',
},
},
],
@@ -61,13 +62,49 @@ describe('Datadog singleton', () => {
);
});
it('POSTs logs to the turtle build logs endpoint', () => {
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: '2.1.0',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
target: 'job_run',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched', {
@@ -85,6 +122,38 @@ describe('Datadog singleton', () => {
tags: {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
target: 'build',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)', {
cache_type: 'gradle',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
{
json: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: {
cache_type: 'gradle',
target: 'job_run',
},
},
headers: { Authorization: 'Bearer token-abc' },
@@ -102,12 +171,12 @@ describe('Datadog singleton', () => {
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows upload failures', async () => {
it('swallows metric upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
@@ -119,18 +188,54 @@ describe('Datadog singleton', () => {
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1,
tags: { target: 'build' },
},
],
},
})
);
});
it('swallows log upload failures', async () => {
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: { target: 'job_run' },
},
],
},
})
);
});
it('swallows log upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched');
@@ -142,7 +247,37 @@ describe('Datadog singleton', () => {
expect.any(Error),
expect.objectContaining({
extras: {
log: { buildId: 'build-id', message: 'artifact dry-run matched', tags: {} },
log: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: { target: 'build' },
},
},
})
);
});
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)');
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun log',
expect.any(Error),
expect.objectContaining({
extras: {
log: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: { target: 'job_run' },
},
},
})
);
@@ -153,13 +288,13 @@ describe('Datadog singleton', () => {
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'first-build-id',
robotAccessToken: 'first-token',
target: { kind: 'build', turtleBuildId: 'first-build-id' },
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'second-build-id',
robotAccessToken: 'second-token',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
@@ -167,7 +302,7 @@ describe('Datadog singleton', () => {
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
@@ -175,7 +310,7 @@ describe('Datadog singleton', () => {
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
'https://api.expo.test/v2/turtle-builds/logs',
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
@@ -192,8 +327,8 @@ describe('Datadog singleton', () => {
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
@@ -219,8 +354,8 @@ describe('Datadog singleton', () => {
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
@@ -236,6 +371,35 @@ describe('Datadog singleton', () => {
expect(flushed).toBe(true);
});
it('only awaits uploads enqueued since the previous flush', async () => {
let resolveSecondUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveSecondUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
// The first upload never settles; the first flush rotates it out of the queue.
Datadog.distribution('eas.build.phase_duration', 1);
void Datadog.flushAsync();
Datadog.distribution('eas.build.phase_duration', 2);
let flushed = false;
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
resolveSecondUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
@@ -0,0 +1,329 @@
import fetch from 'node-fetch';
import { RuntimeSettings } from '../runtimeSettings';
import { Sentry } from '../sentry';
jest.mock('node-fetch', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe('runtimeSettings', () => {
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
};
const originalPlatform = process.platform;
afterEach(() => {
jest.mocked(fetch).mockReset();
jest.restoreAllMocks();
jest.mocked(Sentry.capture).mockReset();
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
mockProcessPlatform(originalPlatform);
});
beforeEach(async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
jest.mocked(fetch).mockReset();
});
it('fetches runtime settings from the staging and production buckets', async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'production' });
await RuntimeSettings.loadAsync({ environment: 'test' });
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not use caches by default when remote settings are unavailable', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({}, 404));
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
extras: {
status: 404,
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
});
});
it('does not override existing settings when remote settings are invalid', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest
.mocked(fetch)
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to load worker runtime settings',
expect.any(Error),
{
extras: {
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
}
);
});
it('accepts partial runtime settings and ignores unknown fields', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false, nodejs: true, maven: true },
darwin: { npm: true, maven: true, cocoapods: false },
},
iosPrecompiledModules: true,
unknownFutureSetting: true,
})
);
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(true);
});
it('uses Maven and CocoaPods caches when enabled for the current platform and their environment variables exist', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { maven: true },
darwin: { maven: true, cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
});
it('uses job environment flags to enable cache URLs from worker environment variables', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
});
it('returns null when job env enables cache but worker env var is empty', async () => {
process.env.EAS_NPM_CACHE_URL = '';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use cache URLs from the job environment', async () => {
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
EAS_NPM_CACHE_URL: 'https://job-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not enable cache when runtime settings explicitly disable it, even with job env flag', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: false } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_NPM_CACHE is set', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_MAVEN_CACHE is set', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { maven: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_MAVEN_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_COCOAPODS_CACHE is set', async () => {
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { darwin: { cocoapods: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_COCOAPODS_CACHE: '1',
},
});
mockProcessPlatform('darwin');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
});
it('allows job environment flags to disable remotely enabled cache URLs', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '0',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use caches when runtime settings are not loaded', () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.isolateModules(() => {
const { RuntimeSettings } = require('../runtimeSettings');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
});
});
it('reports when loaded runtime settings omit the current platform cache settings', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
mockProcessPlatform('darwin');
await RuntimeSettings.loadAsync({ environment: 'staging' });
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Runtime settings are missing cache settings for platform "darwin"',
}),
{
extras: {
configuredPlatforms: ['linux'],
platform: 'darwin',
},
level: 'error',
}
);
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function response(body: unknown, status = 200): any {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}
@@ -7,6 +7,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { prepareProjectSourcesAsync } from '../../common/projectSources';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
import { Datadog } from '../../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
import { runCustomBuildAsync } from '../custom';
@@ -17,6 +18,7 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
describe(runCustomBuildAsync, () => {
let ctx: BuildContext<BuildJob>;
const datadogLogSpy = jest.spyOn(Datadog, 'log');
beforeEach(() => {
const job = createTestIosJob();
@@ -27,10 +29,24 @@ describe(runCustomBuildAsync, () => {
vol.fromJSON(
{
'test.yaml': `
functions:
test_function:
name: Test function
path: ./custom-function
shell: /bin/zsh
supported_platforms:
- darwin
- linux
inputs:
- name
- profile
outputs:
- artifact_path
build:
steps:
- eas/checkout
`,
'custom-function/package.json': '{}',
},
'/workingdir/temporary-custom-build'
);
@@ -77,6 +93,21 @@ describe(runCustomBuildAsync, () => {
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
});
it('reports user-provided custom functions when parsing the custom build config', async () => {
await runCustomBuildAsync(ctx);
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: 'test_function',
custom_function_input_count: '2',
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
custom_function_name: 'Test function',
custom_function_output_count: '1',
custom_function_shell: '/bin/zsh',
custom_function_supported_runtime_platforms: 'darwin,linux',
});
});
it('awaits drainPendingMetricUploads after workflow execution', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
+32 -1
View File
@@ -1,5 +1,11 @@
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
import {
BuildConfigParser,
BuildStepGlobalContext,
BuildWorkflow,
StepsConfigParser,
errors,
} from '@expo/steps';
import assert from 'assert';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
@@ -9,6 +15,7 @@ import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
import { prepareProjectSourcesAsync } from '../common/projectSources';
import { Artifacts, BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { Datadog } from '../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
@@ -81,6 +88,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
throw parseError;
}
});
logUserProvidedCustomFunctions(workflow);
try {
try {
await workflow.executeAsync();
@@ -103,3 +111,26 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
return ctx.artifacts;
}
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
for (const buildFunction of Object.values(workflow.buildFunctions)) {
if (!buildFunction.customFunctionModulePath) {
continue;
}
Datadog.log('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: buildFunction.getFullId(),
custom_function_module_path: buildFunction.customFunctionModulePath,
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
...(buildFunction.supportedRuntimePlatforms
? {
custom_function_supported_runtime_platforms:
buildFunction.supportedRuntimePlatforms.join(','),
}
: {}),
});
}
}
@@ -0,0 +1,222 @@
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { PackageManager } from '../../utils/packageManager';
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('retries without NPM_CONFIG_REGISTRY when npm cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(1, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(spawn).toHaveBeenNthCalledWith(2, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
},
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Failed to install dependencies using npm cache registry',
name: 'NpmCacheRegistryInstallError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
originalErrorMessage: 'npm install --include=dev exited with non-zero code: 1',
status: 1,
signal: null,
},
}
);
});
it('reports non-fatal npm cache registry errors from successful installs', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const auditErrorLine = `npm verbose audit error FetchError: request to ${npmCacheUrl}/-/npm/v1/security/advisories/bulk failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`;
jest.mocked(spawn).mockImplementationOnce((_command, _args, options: any) => {
options.lineTransformer(auditErrorLine);
options.lineTransformer(
`npm http cache left-pad@${npmCacheUrl}/left-pad/-/left-pad-1.3.0.tgz 0ms (cache hit)`
);
return createSpawnPromise(Promise.resolve(createSpawnResult()));
});
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Non-fatal npm cache registry error during dependency install',
name: 'NpmCacheRegistryNonFatalError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
firstErrorLine: auditErrorLine,
errorLineCount: 1,
},
}
);
});
it('does not retry when install failure does not reference npm cache registry', async () => {
const logger = createMockLogger();
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: 'npm error command sh -c node postinstall.js',
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
NPM_CONFIG_REGISTRY: 'http://npm.staging.caches.eas-build.internal',
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('does not retry when EAS_USE_NPM_CACHE is not enabled', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
pid: 123,
},
}) as SpawnPromise<SpawnResult>;
}
function createSpawnResult(): SpawnResult {
return {
pid: 123,
output: ['', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
@@ -0,0 +1,94 @@
import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { XcodeBuildLogger } from '../xcpretty';
jest.mock('@expo/spawn-async');
jest.mock('@expo/xcpretty');
jest.mock('fast-glob');
jest.mock('../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
describe(XcodeBuildLogger, () => {
let stdout: EventEmitter;
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
stdout = new EventEmitter();
const spawnPromise = Object.assign(new Promise(() => {}), {
child: { stdout, kill: jest.fn() },
});
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
formatter = {
pipe: jest.fn(),
getBuildSummary: jest.fn().mockReturnValue('summary'),
};
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
});
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
}
it('logs formatted lines', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockReturnValue(['line1', 'line2']);
stdout.emit('data', 'raw xcodebuild output');
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
expect(logger.info).toHaveBeenCalledWith('line1');
expect(logger.info).toHaveBeenCalledWith('line2');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockImplementation(() => {
throw new RangeError('Invalid array length');
});
stdout.emit('data', 'offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
});
it('reports the formatter error to Sentry only once', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
const error = new RangeError('Invalid array length');
formatter.pipe.mockImplementation(() => {
throw error;
});
stdout.emit('data', 'offending output');
stdout.emit('data', 'more offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
expect(logger.info).toHaveBeenCalledWith('more offending output');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'xcpretty formatter failed to parse xcodebuild logs',
error,
{ extras: { data: 'offending output' } }
);
});
});
@@ -1,8 +1,9 @@
import { Job } from '@expo/eas-build-job';
import { Job, SystemError } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -11,6 +12,7 @@ export async function installDependenciesAsync({
env,
logger,
infoCallbackFn,
lineTransformer,
cwd,
useFrozenLockfile,
}: {
@@ -19,6 +21,7 @@ export async function installDependenciesAsync({
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
lineTransformer?: SpawnOptions['lineTransformer'];
useFrozenLockfile: boolean;
}): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
let args: string[];
@@ -69,11 +72,92 @@ export async function installDependenciesAsync({
cwd,
logger,
infoCallbackFn,
lineTransformer,
env,
}),
};
}
export async function installDependenciesWithNpmCacheFallbackAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
}: {
packageManager: PackageManager;
env: Record<string, string | undefined>;
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
useFrozenLockfile: boolean;
}): Promise<void> {
const npmCacheUrl = env.NPM_CONFIG_REGISTRY;
let firstErrorLine: string | undefined;
let errorLineCount = 0;
try {
await (
await installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
lineTransformer: (line: string) => {
if (isNpmCacheRegistryErrorLine(line, { env, npmCacheUrl })) {
firstErrorLine ??= line;
errorLineCount += 1;
}
return line;
},
cwd,
useFrozenLockfile,
})
).spawnPromise;
if (firstErrorLine) {
Sentry.capture(new NpmCacheRegistryNonFatalError(), {
level: 'warning',
tags: { packageManager },
extras: { cwd, npmCacheUrl, useFrozenLockfile, firstErrorLine, errorLineCount },
});
}
} catch (err: unknown) {
if (!isNpmCacheInstallFailure(err, { env, npmCacheUrl })) {
throw err;
}
logger.warn(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
Sentry.capture(new NpmCacheRegistryInstallError(err), {
level: 'warning',
tags: { packageManager },
extras: {
cwd,
npmCacheUrl,
useFrozenLockfile,
originalErrorMessage: err instanceof Error ? err.message : String(err),
status: err instanceof Error ? (err as any).status : undefined,
signal: err instanceof Error ? (err as any).signal : undefined,
},
});
const { NPM_CONFIG_REGISTRY: _NPM_CONFIG_REGISTRY, ...fallbackEnv } = env;
await (
await installDependenciesAsync({
packageManager,
env: fallbackEnv,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
})
).spawnPromise;
}
}
export function resolvePackagerDir(ctx: BuildContext<Job>): string {
const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory());
if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) {
@@ -87,3 +171,54 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
}
return packagerRunDir;
}
function isNpmCacheInstallFailure(
err: unknown,
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
): boolean {
if (!isNpmCacheRegistryEnabled(env, npmCacheUrl)) {
return false;
}
const errorOutput = getErrorOutput(err);
return errorOutput.includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(
line: string,
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
): boolean {
return (
isNpmCacheRegistryEnabled(env, npmCacheUrl) &&
line.includes(npmCacheUrl) &&
/(?:error|failed|ENOTFOUND|ECONN|ETIMEDOUT|EAI_AGAIN|FetchError)/i.test(line)
);
}
function isNpmCacheRegistryEnabled(
env: Record<string, string | undefined>,
npmCacheUrl: string | undefined
): npmCacheUrl is string {
return env.EAS_USE_NPM_CACHE === '1' && !!npmCacheUrl;
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+12 -12
View File
@@ -2,7 +2,10 @@ import { BuildJob } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { SpawnOptions } from '@expo/turtle-spawn';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import {
installDependenciesWithNpmCacheFallbackAsync,
resolvePackagerDir,
} from './installDependencies';
import { BuildContext } from '../context';
import { runExpoCliCommand } from '../utils/project';
@@ -30,17 +33,14 @@ export async function prebuildAsync<TJob extends BuildJob>(
options: spawnOptions,
packageManager: ctx.packageManager,
});
const installDependenciesSpawnPromise = (
await installDependenciesAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
})
).spawnPromise;
await installDependenciesSpawnPromise;
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
});
}
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
@@ -6,11 +6,14 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { Sentry } from '../sentry';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private reportedFormatterError: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
@@ -60,9 +63,21 @@ export class XcodeBuildLogger {
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
try {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
} catch (err: any) {
// The formatter can throw on unexpected xcodebuild output. Fall back to
// raw logs instead of crashing the process with an uncaught exception.
if (!this.reportedFormatterError) {
this.reportedFormatterError = true;
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
extras: { data: data.toString().slice(-2000) },
});
}
this.logger.info(data.toString());
}
});
await this.logReaderPromise;
+47 -34
View File
@@ -1,15 +1,26 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
turtleBuildId: string;
robotAccessToken: string;
target: MetricsTarget;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
@@ -19,28 +30,29 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags,
tags: withTargetTag(tags, target),
},
];
const uploadPromise = turtleFetch(
new URL(`turtle-builds/${turtleBuildId}/metrics`, expoApiV2BaseUrl).toString(),
'POST',
{
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}
).catch(err => {
Sentry.capture('Failed to report turtle build metric', err, {
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
extras: { metrics },
});
});
@@ -52,25 +64,22 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const log = {
buildId: turtleBuildId,
message,
tags,
};
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const tagsWithTarget = withTargetTag(tags, target);
const log =
target.kind === 'build'
? { buildId: target.turtleBuildId, message, tags: tagsWithTarget }
: { jobRunId: target.turtleJobRunId, message, tags: tagsWithTarget };
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
const uploadPromise = turtleFetch(
new URL('turtle-builds/logs', expoApiV2BaseUrl).toString(),
'POST',
{
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
).catch(err => {
Sentry.capture('Failed to report turtle build log', err, {
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
extras: { log },
});
});
@@ -79,6 +88,10 @@ export const Datadog = {
},
async flushAsync(): Promise<void> {
await Promise.allSettled(pendingUploads);
// Rotate so each flush only awaits its own batch; uploads enqueued during the
// await land in the fresh array for a later flush.
const uploads = pendingUploads;
pendingUploads = [];
await Promise.allSettled(uploads);
},
};
+1
View File
@@ -27,3 +27,4 @@ export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
export * from './runtimeSettings';
+1 -1
View File
@@ -10,8 +10,8 @@ import type { Credentials } from './credentials/manager';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
import { XcodeBuildLogger } from '../common/xcpretty';
import { BuildContext, SkipNativeBuildError } from '../context';
export async function runFastlaneGym<TJob extends Ios.Job>(
+1 -1
View File
@@ -51,7 +51,7 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return {};
}
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
if (ctx.env.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
+138
View File
@@ -0,0 +1,138 @@
import { Env } from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { z } from 'zod';
import { Sentry } from './sentry';
const ENVIRONMENT_TO_RUNTIME_SETTINGS_URL: Record<string, string | undefined> = {
staging: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
production: 'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
};
const RuntimeSettingsSchema = z
.object({
caches: z.record(
z.string(),
z
.object({
npm: z.boolean(),
nodejs: z.boolean(),
maven: z.boolean(),
cocoapods: z.boolean(),
})
.partial()
),
iosPrecompiledModules: z.boolean(),
})
.partial();
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
let runtimeEnvironment: Env = {};
export namespace RuntimeSettings {
export async function loadAsync({
environment,
env: nextRuntimeEnvironment = {},
}: {
environment: string;
env?: Env;
}): Promise<void> {
runtimeEnvironment = nextRuntimeEnvironment;
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
if (!url) {
return;
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(1000),
});
if (!response.ok) {
Sentry.capture('Failed to fetch worker runtime settings', {
extras: { url, status: response.status },
level: 'warning',
});
return;
}
runtimeSettings = RuntimeSettingsSchema.parse(await response.json());
if (runtimeSettings.caches && !runtimeSettings.caches[process.platform]) {
Sentry.capture(
new Error(
`Runtime settings are missing cache settings for platform "${process.platform}"`
),
{
extras: {
configuredPlatforms: Object.keys(runtimeSettings.caches),
platform: process.platform,
},
level: 'error',
}
);
}
} catch (err) {
Sentry.capture(
'Failed to load worker runtime settings',
err instanceof Error ? err : new Error(String(err)),
{
extras: { url },
level: 'warning',
}
);
}
}
export function isUsingIosPrecompiledModulesEnabled(): boolean {
return runtimeSettings.iosPrecompiledModules ?? false;
}
export function getNpmCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_NPM_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.npm;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_NPM_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_NPM_CACHE_URL || null : null;
}
export function getNodeJsCacheUrl(): string | null {
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.nodejs;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_NODEJS_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_NODEJS_CACHE_URL || null : null;
}
export function getMavenCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_MAVEN_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.maven;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_MAVEN_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_MAVEN_CACHE_URL || null : null;
}
export function getCocoapodsCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_COCOAPODS_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.cocoapods;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_COCOAPODS_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_COCOAPODS_CACHE_URL || null : null;
}
}
@@ -61,7 +61,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createDownloadBuildFunction(ctx),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -1,4 +1,6 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
@@ -17,22 +19,63 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
'base64'
);
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
function createMockGraphqlClient({
applicationArchiveUrl,
error,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
error
? { error, data: undefined }
: {
data: {
builds: {
byId: {
id: randomUUID(),
platform: 'IOS',
artifacts: {
applicationArchiveUrl: applicationArchiveUrl ?? null,
},
},
},
},
}
);
return {
query: jest.fn().mockReturnValue({ toPromise }),
} as unknown as Client;
}
describe('downloadBuild', () => {
it('should handle an archive', async () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(artifactPath).toBeDefined();
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
'i am executable\n'
@@ -40,16 +83,21 @@ describe('downloadBuild', () => {
});
it('should handle a straight-up file', async () => {
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(Buffer.from('hello')),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: applicationArchiveUrl,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
@@ -58,18 +106,114 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('throws UserError when the build has no application archive url', async () => {
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
message: 'Build does not have an application archive url',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
response: { status: 500 },
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws UserError when GraphQL returns a client error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Build not found' }],
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
})
@@ -79,7 +223,11 @@ describe('downloadBuild', () => {
describe('createDownloadBuildFunction', () => {
it('should download a build', async () => {
const downloadBuild = createDownloadBuildFunction();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
const logger = createMockLogger();
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
@@ -92,7 +240,7 @@ describe('createDownloadBuildFunction', () => {
}),
{
callInputs: {
build_id: randomUUID(),
build_id: buildId,
extensions: ['app'],
},
}
@@ -105,5 +253,9 @@ describe('createDownloadBuildFunction', () => {
} as unknown as Response);
await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
});
});
@@ -0,0 +1,90 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
import { createInstallMaestroBuildFunction } from '../installMaestro';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
describe('createInstallMaestroBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
// `maestro --version` reports an installed version; `java -version` succeeds.
// With no requested version and Maestro present, the step skips installation.
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? '1.41.0\n' : '',
})) as any);
});
it('reports the detected Maestro version to Datadog on EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
});
});
it('does not report to Datadog outside EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const step = installMaestro.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {},
});
await step.executeAsync();
expect(Datadog.distribution).not.toHaveBeenCalled();
});
it('extracts the version when `maestro --version` prints an analytics notice', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro'
? 'Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS environment variable to any value before running Maestro.\n2.0.10\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
});
});
it('uses the trailing version when the notice itself contains an earlier version-like string', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? 'Analytics schema v2.0.0 enabled.\n2.0.10\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
});
@@ -4,10 +4,14 @@ import { vol } from 'memfs';
import {
copyLatestAttemptXml,
isFileAttrRun,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
} from '../maestroResultParser';
describe(parseMaestroResults, () => {
@@ -298,7 +302,169 @@ describe(parseMaestroResults, () => {
});
});
describe(parseMaestroResultsFromFileAttrs, () => {
it('uses file= as the path', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results[0]).toEqual(
expect.objectContaining({ name: 'login', path: '.maestro/login.yaml' })
);
});
it('keeps two same-named flows separate by their file= path', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="ERROR" time="1.0"><failure>x</failure></testcase>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results.map(r => r.path).sort()).toEqual(['a/login.yaml', 'b/login.yaml']);
});
it('returns per-attempt results keyed by file= across attempt files', async () => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>',
'</testsuite></testsuites>',
].join('\n'),
'/junit/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results).toEqual([
expect.objectContaining({ path: 'flows/login.yaml', status: 'failed', retryCount: 0 }),
expect.objectContaining({ path: 'flows/login.yaml', status: 'passed', retryCount: 1 }),
]);
});
it('returns empty array when the directory does not exist', async () => {
const results = await parseMaestroResultsFromFileAttrs('/nope');
expect(results).toEqual([]);
});
it('skips testcases without file= instead of emitting an undefined path (contract guard)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results).toEqual([expect.objectContaining({ name: 'a', path: 'a.yaml' })]);
});
});
describe('junitFileHasFileAttrs', () => {
it('returns true when every testcase has a non-empty file= attribute', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" file="b.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
});
it('returns false when any testcase lacks file= (mixed report)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false when file= is an empty string', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false for a legacy report with no file= attributes', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false when the file cannot be read', async () => {
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
});
});
describe('isFileAttrRun', () => {
it('returns false for an empty list (an empty report is not a file-attr run)', () => {
expect(isFileAttrRun([])).toBe(false);
});
});
describe(parseJUnitTestCases, () => {
it('extracts the file= attribute when present', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].file).toBe('.maestro/login.yaml');
});
it('treats a missing or empty file= attribute as undefined', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" status="SUCCESS" time="1.0"/>',
' <testcase name="b" file="" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results.find(r => r.name === 'a')!.file).toBeUndefined();
expect(results.find(r => r.name === 'b')!.file).toBeUndefined();
});
it('parses a single testcase with SUCCESS status', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -582,6 +748,100 @@ describe(parseJUnitTestCases, () => {
});
});
describe(parseFailedFlowsFromFileAttrs, () => {
it('returns the failing flows by file= (duplicate names retry correctly)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/proj/b/login.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['b/login.yaml']);
});
it('returns null when a file= path does not exist on disk (dumb retry, never legacy)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="gone" file="missing/flow.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
it('returns null when the report lacks file= attributes (callers must pre-check)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="B" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/proj/a.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
it('dedupes repeated failing file= paths (same flow failed twice in one attempt)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/proj/flows/login.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['flows/login.yaml']);
});
it('resolves and existence-checks an absolute file= path', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="ext" file="/outside/flow.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/outside/flow.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['/outside/flow.yaml']);
});
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="B" file="b.yaml"`, // intentionally truncated mid-tag
'/proj/a.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
});
describe('parseFailedFlowsFromJUnit', () => {
it('returns the subset of input flow paths whose testcases failed', async () => {
vol.fromJSON({
@@ -765,6 +1025,28 @@ describe('mergeJUnitReports', () => {
expect(b.failure).toBeUndefined();
});
it('keeps a same-named flow from an earlier attempt when keyed by file=', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
const testcases = parsed.testsuites.testsuite.testcase;
const files = testcases.map((t: any) => t['@_file']).sort();
expect(files).toEqual(['a/login.yaml', 'b/login.yaml']);
});
it('throws when source directory contains no *.xml files', async () => {
// No per-attempt JUnit files were ever written (maestro crashed before
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
@@ -273,6 +273,33 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('uses the file= path on reports that carry it, without scanning flow files', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'junitFileHasFileAttrs').mockResolvedValue(true);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const legacyParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const newParseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromFileAttrs')
.mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(newParseSpy).toHaveBeenCalledTimes(1);
// The new branch needs no name→path map: no scan, no legacy parser.
expect(discoverySpy).not.toHaveBeenCalled();
expect(legacyParseSpy).not.toHaveBeenCalled();
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
@@ -293,6 +320,44 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('never scans flow files when all attempts succeed', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await step.executeAsync();
expect(discoverySpy).not.toHaveBeenCalled();
});
it('memoizes the flow scan across retries (legacy reports)', async () => {
// Routing note: the step lands in the legacy arm because the real
// junitFileHasFileAttrs returns false — the attempt XML does not exist on
// the mock fs. Creating that file with file= attrs would flip the branch.
mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const discoverySpy = jest
.spyOn(discovery, 'buildFlowNameToPathMap')
.mockResolvedValue(new Map([['a', 'flows/a.yaml']]));
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
.mockResolvedValue(['flows/a.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Two failed attempts hit the legacy branch twice; the scan runs once.
expect(parseSpy).toHaveBeenCalledTimes(2);
expect(discoverySpy).toHaveBeenCalledTimes(1);
});
it('defaults retry_failed_only to true when omitted from inputs', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
@@ -3,6 +3,7 @@ import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as discovery from '../maestroFlowDiscovery';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
@@ -43,15 +44,24 @@ describe(createReportMaestroTestResultsFunction, () => {
} as unknown as Client;
});
// Returns a mock logger whose child() is itself, so step-level log calls can
// be asserted on the returned object.
function createSelfChildedLogger(): ReturnType<typeof createMockLogger> {
const logger = createMockLogger();
(logger.child as jest.Mock).mockReturnValue(logger);
return logger;
}
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
logger?: ReturnType<typeof createMockLogger>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
logger: overrides?.logger ?? createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
@@ -105,6 +115,54 @@ describe(createReportMaestroTestResultsFunction, () => {
]);
});
it('reports both flows when two share a name but have distinct file= paths', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: '1' }, { id: '2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults.map((r: any) => r.path).sort()).toEqual([
'a/login.yaml',
'b/login.yaml',
]);
});
it('never scans flow files for a file= report, even when flow_path is provided', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: { createWorkflowDeviceTestCaseResults: [{ id: '1' }] },
},
});
await createStep({ callInputs: { flow_path: ['flows/login.yaml'] } }).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(discoverySpy).not.toHaveBeenCalled();
});
it('sends tags and properties from JUnit XML', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -221,7 +279,7 @@ describe(createReportMaestroTestResultsFunction, () => {
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found', async () => {
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -238,8 +296,27 @@ describe(createReportMaestroTestResultsFunction, () => {
}),
});
await createStep().executeAsync();
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('unique name'));
});
it('skips report when the same flow file appears twice in one attempt, advising flow_path check', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="2.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('flow_path entries'));
});
it('sends per-attempt results with same name but different retryCount', async () => {
@@ -0,0 +1,58 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { warnIfArgentPackageVersionCannotBeVerified } from '../startArgentRemoteSession';
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
beforeEach(() => {
warn.mockClear();
});
it('allows the default latest package version', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '0.11.0', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '0.11.1', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'v0.11.1', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '0.10.9', logger })
).toThrow(SystemError);
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '0.10.9', logger })
).toThrow(/Use "latest" or pass an exact version >= 0\.11\.0/);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '^0.11.0', logger })
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bunx resolve it.')
);
});
});
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -7,7 +7,9 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import fs from 'node:fs';
import os from 'node:os';
@@ -16,6 +18,7 @@ import stream from 'stream';
import { promisify } from 'util';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
@@ -23,7 +26,21 @@ import { pluralize } from '../../utils/strings';
const streamPipeline = promisify(stream.pipeline);
export function createDownloadBuildFunction(): BuildFunction {
const BUILD_BY_ID_QUERY = graphql(`
query DownloadBuildByIdQuery($buildId: ID!) {
builds {
byId(buildId: $buildId) {
id
platform
artifacts {
applicationArchiveUrl
}
}
}
}
`);
export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'download_build',
@@ -59,7 +76,7 @@ export function createDownloadBuildFunction(): BuildFunction {
const { artifactPath } = await downloadBuildAsync({
logger,
buildId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
});
@@ -69,16 +86,45 @@ export function createDownloadBuildFunction(): BuildFunction {
});
}
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
}: {
buildId: string;
graphqlClient: Client;
}): Promise<string> {
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
'Build does not have an application archive url'
);
}
return applicationArchiveUrl;
}
export async function downloadBuildAsync({
logger,
buildId,
expoApiServerURL,
graphqlClient,
robotAccessToken,
extensions,
}: {
logger: bunyan;
buildId: string;
expoApiServerURL: string;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}): Promise<{ artifactPath: string }> {
@@ -86,12 +132,11 @@ export async function downloadBuildAsync({
path.join(os.tmpdir(), 'download_build-downloaded-')
);
const response = await retryOnDNSFailure(fetch)(
new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL),
{
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
}
);
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
});
if (!response.ok) {
const textResult = await asyncResult(response.text());
@@ -26,14 +26,14 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
try {
await uploadApplicationArchivesAsync({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error(`Failed to upload application archives.`, err);
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
firstError ||= err;
}
try {
await uploadBuildArtifacts({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error(`Failed to upload build artifacts.`, err);
stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
firstError ||= err;
}
@@ -15,6 +15,8 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { Datadog } from '../../datadog';
export function createInstallMaestroBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -123,13 +125,24 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: maestroVersionResult.value,
});
},
});
}
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
const maestroVersion = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
return maestroVersion.stdout.trim();
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice, so
// this stays correct even if the notice itself contains a version-like string. Keeps the
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
// the raw output if none is found, so we never fail the build over a version string.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
}
async function installMaestro({
@@ -1,3 +1,4 @@
import { asyncResult } from '@expo/results';
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
@@ -18,6 +19,7 @@ const ATTEMPT_PATTERN = /attempt-(\d+)/;
export interface JUnitTestCaseResult {
name: string;
file: string | undefined;
status: 'passed' | 'failed';
duration: number; // milliseconds
errorMessage: string | null;
@@ -32,6 +34,12 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// A `file=` attribute counts as present only when it is a non-empty string.
function fileAttrOf(tc: any): string | undefined {
const f = tc?.['@_file'];
return typeof f === 'string' && f.length > 0 ? f : undefined;
}
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
const results: JUnitTestCaseResult[] = [];
try {
@@ -54,6 +62,8 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
continue;
}
const file = fileAttrOf(tc);
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
@@ -94,7 +104,7 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
results.push({ name, file, status, duration, errorMessage, tags, properties });
}
}
} catch {
@@ -131,6 +141,142 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return perFile.flat();
}
// ---------------------------------------------------------------------------
// Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
// its flow's own path (all-or-none per run). Callers check the report first
// (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
// scan is then never consulted.
// ---------------------------------------------------------------------------
export function isFileAttrRun(
testcases: JUnitTestCaseResult[]
): testcases is (JUnitTestCaseResult & { file: string })[] {
return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
}
// Unreadable or attribute-less reports ⇒ false: the caller then takes the
// legacy path, whose own guards degrade safely.
export async function junitFileHasFileAttrs(junitFile: string): Promise<boolean> {
return isFileAttrRun(await parseJUnitFile(junitFile));
}
async function fileExists(absPath: string): Promise<boolean> {
return (await asyncResult(fs.stat(absPath))).ok;
}
// Group by `file=` so two same-named flows in different files stay separate.
export async function parseMaestroResultsFromFileAttrs(
junitDirectory: string
): Promise<MaestroFlowResult[]> {
interface JUnitEntry {
result: JUnitTestCaseResult;
sourceFile: string;
}
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
const entries: JUnitEntry[] = [];
for (const xmlFile of xmlFiles) {
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
for (const result of fileResults) {
entries.push({ result, sourceFile: xmlFile });
}
}
const byPath = new Map<string, JUnitEntry[]>();
for (const entry of entries) {
// Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
// skip (rather than emit an undefined path) if that contract is violated.
const flowPath = entry.result.file;
if (flowPath === undefined) {
continue;
}
const group = byPath.get(flowPath) ?? [];
group.push(entry);
byPath.set(flowPath, group);
}
const results: MaestroFlowResult[] = [];
for (const [flowPath, group] of byPath) {
// retryCount is derived from each entry's source filename (`attempt-N`);
// a single entry from `report.xml` (no marker) collapses to attempt 0.
const sorted = group
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: result.name,
path: flowPath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
return results;
}
/**
* Returns the `file=` paths of the failing testcases in the given attempt's
* JUnit file, or `null` when the result cannot be trusted (caller then falls
* back to dumb retry — re-run everything; never the legacy scan).
*
* Callers verify the report carries `file=` attributes first
* (junitFileHasFileAttrs).
*/
export async function parseFailedFlowsFromFileAttrs(args: {
junitFile: string;
workingDirectory: string;
}): Promise<string[] | null> {
// Same hardening as the legacy parser: fast-xml-parser is lenient — a
// truncated XML could drop testcases, and trusting that partial failing set
// would skip retries for cut-off flows.
let content: string;
try {
content = await fs.readFile(args.junitFile, 'utf-8');
} catch {
return null;
}
if (XMLValidator.validate(content) !== true) {
return null;
}
const testcases = parseJUnitContent(content);
if (!isFileAttrRun(testcases)) {
return null;
}
const failing = testcases.filter(tc => tc.status === 'failed');
if (failing.length === 0) {
return [];
}
// Each failing testcase is self-identifying via `file=`, so duplicate flow
// names retry correctly. path.resolve (not join): `file=` may be absolute
// when the flow lives outside the working directory.
const paths = [...new Set(failing.map(tc => tc.file))];
for (const p of paths) {
if (!(await fileExists(path.resolve(args.workingDirectory, p)))) {
return null;
}
}
return paths;
}
export async function parseMaestroResults(
junitDirectory: string,
nameToPath: Map<string, string> | null
@@ -313,6 +459,13 @@ export async function mergeJUnitReports(args: {
}
const match = filename.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
// Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
// different files merge separately. Legacy reports keep name keying.
const allCases = testsuites.flatMap((suite: any) =>
Array.isArray(suite?.testcase) ? suite.testcase : []
);
const useFileKeys =
allCases.length > 0 && allCases.every((tc: any) => fileAttrOf(tc) !== undefined);
const testcasesByName = new Map<string, unknown[]>();
for (const suite of testsuites) {
const cases = suite?.testcase;
@@ -324,9 +477,10 @@ export async function mergeJUnitReports(args: {
if (typeof name !== 'string') {
continue;
}
const group = testcasesByName.get(name) ?? [];
const key = useFileKeys ? fileAttrOf(tc)! : name;
const group = testcasesByName.get(key) ?? [];
group.push(tc);
testcasesByName.set(name, group);
testcasesByName.set(key, group);
}
}
if (testcasesByName.size === 0) {
@@ -14,7 +14,9 @@ import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
} from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
@@ -200,13 +202,10 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// null → duplicate flow names; retry-failed-only disabled, fall through to
// dumb retry (re-run everything) on failure.
const nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
});
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
// flow scan is built lazily in the retry branch below and memoized so
// retries share one scan. Never runs when the report has `file=`.
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
@@ -214,7 +213,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
// flows. The failed-flow parsers return null when the JUnit cannot be
// trusted; we then fall through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
@@ -263,11 +262,26 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
break;
}
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
});
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
let failed: string[] | null;
if (await junitFileHasFileAttrs(outputPath)) {
failed = await parseFailedFlowsFromFileAttrs({
junitFile: outputPath,
workingDirectory: stepCtx.workingDirectory,
});
} else {
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
// paths via the flow-file scan. DELETE this arm once the fleet is
// on >= 2.6.0.
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
}));
failed = nameToPath
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
: null;
}
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
logger.info(
@@ -57,6 +57,16 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_use_source_app_entitlements',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_app_entitlements_path',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -138,6 +148,12 @@ export function createRepackBuildFunction(): BuildFunction {
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
| boolean
| undefined,
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
| string
| undefined,
}),
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -275,9 +291,13 @@ export async function resolveAndroidSigningOptionsAsync({
export async function resolveIosSigningOptionsAsync({
job,
logger,
useAppEntitlements,
entitlementsPath,
}: {
job: Job;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
}): Promise<IosSigningOptions | undefined> {
const iosJob = job as Ios.Job;
const buildCredentials = iosJob.secrets?.buildCredentials;
@@ -295,5 +315,7 @@ export async function resolveIosSigningOptionsAsync({
provisioningProfile,
keychainPath: credentials.keychainPath,
signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
useAppEntitlements,
entitlementsPath,
};
}
@@ -3,7 +3,13 @@ import { graphql } from 'gql.tada';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import {
MaestroFlowResult,
isFileAttrRun,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
} from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -61,47 +67,64 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
return;
}
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
try {
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
// Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
// `file=` attribute; older reports need the legacy flow-file scan to
// map testcase names back to paths.
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
let flowResults: MaestroFlowResult[];
if (usedFileAttrs) {
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
} else {
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
flowResults = await parseMaestroResults(junitDirectory, nameToPath);
}
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
// share the same name (Maestro config override), which we can't disambiguate.
// Same name with different retryCount is expected (per-attempt results from retries).
// Detect truly conflicting results: the same (path, retryCount) twice means two
// flow files resolved to the same path, which we can't disambiguate and the API
// rejects as a duplicate. Same path with different retryCount is expected
// (per-attempt results from retries).
const seen = new Set<string>();
const conflicting = new Set<string>();
for (const r of flowResults) {
const key = `${r.name}:${r.retryCount}`;
const key = `${r.path}:${r.retryCount}`;
if (seen.has(key)) {
conflicting.add(r.name);
conflicting.add(r.path);
}
seen.add(key);
}
if (conflicting.size > 0) {
const conflictList = [...conflicting].join(', ');
logger.error(
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
usedFileAttrs
? `The same flow file was reported more than once in a single attempt: ` +
`${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
`or leftover XML files in the JUnit report directory.`
: `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
`Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
`then identified by file path, so duplicate names are fine).`
);
return;
}
@@ -206,16 +206,16 @@ export async function restoreGradleCacheAsync({
}
try {
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
await fs.promises.writeFile(
gradlePropertiesPath,
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
);
// Enable build cache via user-level gradle.properties.
// This avoids mutating android/gradle.properties which affects fingerprinting.
const gradleUserHome = path.join(os.homedir(), '.gradle');
await fs.promises.mkdir(gradleUserHome, { recursive: true });
const userGradlePropertiesPath = path.join(gradleUserHome, 'gradle.properties');
await fs.promises.appendFile(userGradlePropertiesPath, '\norg.gradle.caching=true\n');
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
// org.gradle.cache.cleanup property was removed in Gradle 9)
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
// Configure cache cleanup via init script.
// Works with both Gradle 8 and 9 (org.gradle.cache.cleanup property was removed in Gradle 9).
const initScriptDir = path.join(gradleUserHome, 'init.d');
await fs.promises.mkdir(initScriptDir, { recursive: true });
await fs.promises.writeFile(
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
@@ -1,18 +1,21 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import {
type DetachedProcessHandle,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -23,6 +26,7 @@ import {
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
@@ -64,34 +68,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching agent-device daemon.');
spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
daemonProcess,
});
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
@@ -108,7 +90,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
// on Darwin. Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
@@ -137,6 +119,93 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching daemon from cloned agent-device source.');
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
@@ -153,6 +222,63 @@ async function cloneAgentDeviceAsync({
});
}
async function waitForDaemonInfoAsync({
daemonProcess,
}: {
daemonProcess: DetachedProcessHandle;
}): Promise<{ port: number; token: string }> {
try {
return await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
});
} catch (err) {
const output = daemonProcess.getOutput();
throw new SystemError(
`${
err instanceof Error
? err.message
: `Timed out waiting for agent-device daemon credentials.`
}${output ? `\nagent-device daemon output:\n${output}` : ''}`
);
}
}
async function getBunVersionForDiagnosticsAsync(env: BuildStepEnv): Promise<string> {
try {
const result = await spawn('bun', ['--version'], { stdio: 'pipe', env, cwd: os.tmpdir() });
return result.stdout.trim() || 'unknown';
} catch {
return 'unknown';
}
}
function createAgentDevicePackageSpec(packageVersion: string | undefined): string {
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
'src',
'internal',
'daemon.js'
);
}
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
@@ -1,4 +1,5 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -9,6 +10,7 @@ import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import semver from 'semver';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
@@ -24,11 +26,15 @@ import {
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.11.0';
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
const ArgentToolServerStateSchema = z.object({ port: z.number() });
const ArgentToolServerStateSchema = z.object({
port: z.number(),
token: z.string().optional(),
});
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
@@ -55,6 +61,7 @@ export function createStartArgentRemoteSessionBuildFunction(
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
logger.info(
@@ -69,39 +76,58 @@ export function createStartArgentRemoteSessionBuildFunction(
// Stale state from a previous run would mask the new server's port.
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
// `argent mcp` is the public entry that triggers @argent/tools-client
// to spawn the tool-server detached + unref'd, so the tool-server
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
// 30-min idle shutdown that would otherwise tear the tunnel down.
spawnDetached({
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
const argentServer = spawnDetached({
command: 'bunx',
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
args: [
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--detach',
],
env,
});
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
const { port: toolServerPort } = await waitForFileAsync({
filePath: ARGENT_STATE_FILE,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'argent tool-server state',
parse: parseArgentToolServerState,
});
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForFileAsync({
filePath: ARGENT_STATE_FILE,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'argent tool-server state',
parse: parseArgentToolServerState,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const toolsUrl = await startNgrokTunnelAsync({
const publicToolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
logger,
});
logger.info(`Tunnel is ready at ${toolsUrl}.`);
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync({
const serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
@@ -115,7 +141,8 @@ export function createStartArgentRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl,
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
@@ -129,7 +156,37 @@ export function createStartArgentRemoteSessionBuildFunction(
});
}
function parseArgentToolServerState(raw: string): { port: number } {
export function warnIfArgentPackageVersionCannotBeVerified({
packageVersion,
logger,
}: {
packageVersion: string | undefined;
logger: bunyan;
}): void {
if (!packageVersion || packageVersion === 'latest') {
return;
}
const validVersion = semver.valid(packageVersion);
if (!validVersion) {
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting bunx resolve it.`
);
return;
}
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
throw new SystemError(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
);
}
}
function parseArgentToolServerState(raw: string): { port: number; token?: string } {
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
if (!result.success) {
throw new SystemError(
@@ -30,7 +30,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
@@ -128,7 +128,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error(`Failed to upload ${artifact.type}. Ignoring error.`, error);
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
// Ignoring error.
return;
}
@@ -0,0 +1,154 @@
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
fetchServeSimTurnArgsAsync,
turnIceServersToServeSimArgs,
} from '../remoteDeviceRunSession';
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../sentry');
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
}
function createCtxMock(): CustomBuildContext {
return {
env: {
__API_SERVER_URL: 'https://api.expo.test',
},
job: {
secrets: { robotAccessToken: 'robot-token' },
},
} as unknown as CustomBuildContext;
}
function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
describe(turnIceServersToServeSimArgs, () => {
it('returns no args for an empty ICE server list', () => {
expect(turnIceServersToServeSimArgs([])).toEqual([]);
});
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
expect(
turnIceServersToServeSimArgs([
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
{
urls: [
'turn:turn.cloudflare.com:3478?transport=udp',
'turns:turn.cloudflare.com:443?transport=tcp',
],
username: 'user-123',
credential: 'cred-456',
},
])
).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478,stun:stun.cloudflare.com:53',
'--turn-url',
'turn:turn.cloudflare.com:3478?transport=udp,turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'user-123',
'--turn-credential',
'cred-456',
]);
});
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
expect(
turnIceServersToServeSimArgs([
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
])
).toEqual([
'--turn-url',
'turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'u',
'--turn-credential',
'c',
]);
});
it('emits only --stun-url when there is no credentialed TURN entry', () => {
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
]);
});
});
describe(fetchServeSimTurnArgsAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
jest.mocked(turtleFetch).mockResolvedValue({
json: async () => ({
data: {
iceServers: [
{ urls: ['stun:stun.cloudflare.com:3478'] },
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
],
},
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
expect(args).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
'--turn-url',
'turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'u',
'--turn-credential',
'c',
]);
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer robot-token' },
})
);
});
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger,
});
expect(args).toEqual([]);
expect(logger.warn).toHaveBeenCalled();
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
});
@@ -3,8 +3,8 @@ import { BuildStepEnv } from '@expo/steps';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
import { XcodeBuildLogger } from '../../../common/xcpretty';
export async function runFastlaneGym({
workingDir,
@@ -1,91 +0,0 @@
import { bunyan } from '@expo/logger';
import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import assert from 'assert';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
constructor(
private readonly logger: bunyan,
private readonly projectRoot: string
) {}
public async watchLogFiles(logsDirectory: string): Promise<void> {
while (!this.flushing) {
const logsFilename = await this.getBuildLogFilename(logsDirectory);
if (logsFilename) {
this.logsPath = path.join(logsDirectory, logsFilename);
void this.startBuildLogger(this.logsPath);
return;
}
await new Promise(res => setTimeout(res, CHECK_FILE_INTERVAL_MS));
}
}
public async flush(): Promise<void> {
this.flushing = true;
if (this.loggerError) {
throw this.loggerError;
}
if (this.logReaderPromise) {
this.logReaderPromise.child.kill('SIGINT');
try {
await this.logReaderPromise;
} catch {}
}
if (this.logsPath) {
await this.findBundlerErrors(this.logsPath);
}
}
private async getBuildLogFilename(logsDirectory: string): Promise<string | undefined> {
const paths = await fg('*.log', { cwd: logsDirectory });
return paths.length >= 1 ? paths[0] : undefined;
}
private async startBuildLogger(logsPath: string): Promise<void> {
try {
const formatter = ExpoRunFormatter.create(this.projectRoot, {
// TODO: Can provide xcode project name for better parsing
isDebug: false,
});
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
});
await this.logReaderPromise;
this.logger.info(formatter.getBuildSummary());
} catch (err: any) {
if (!this.flushing) {
this.loggerError = err;
}
}
}
private async findBundlerErrors(logsPath: string): Promise<void> {
try {
const logFile = await fs.readFile(logsPath, 'utf-8');
const match = logFile.match(
/Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/
);
if (match) {
this.logger.info(match[1]);
}
} catch (err) {
this.logger.error({ err }, 'Failed to read Xcode logs');
}
}
}
@@ -4,11 +4,15 @@ import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
import nullthrows from 'nullthrows';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -60,6 +64,105 @@ export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
return authtoken;
}
const TurnIceServersSchema = z.array(
z.object({
urls: z.array(z.string()),
username: z.string().optional(),
credential: z.string().optional(),
})
);
export type TurnIceServers = z.infer<typeof TurnIceServersSchema>;
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
}),
});
/**
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
* (the entry carrying the short-lived credentials).
*/
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
const stunUrls = iceServers
.filter(server => !server.username && !server.credential)
.flatMap(server => server.urls);
const turnServer = iceServers.find(server => server.username && server.credential);
const args: string[] = [];
if (stunUrls.length > 0) {
args.push('--stun-url', stunUrls.join(','));
}
if (turnServer?.username && turnServer.credential && turnServer.urls.length > 0) {
args.push(
'--turn-url',
turnServer.urls.join(','),
'--turn-username',
turnServer.username,
'--turn-credential',
turnServer.credential
);
}
return args;
}
/**
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
* (minted on demand, mirroring how the worker fetches project clone URLs) and
* translate them into serve-sim CLI flags.
*
* Best-effort: on any failure we log and return [] so serve-sim falls back to
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
* process arg and deliberately not logged (turtle-spawn never logs argv and the
* worker is single-tenant).
*/
export async function fetchServeSimTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<string[]> {
try {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
ctx.job.secrets?.robotAccessToken,
'robot access token is not set'
);
const response = await turtleFetch(
new URL(
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
expoApiServerUrl
).toString(),
'POST',
{
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 5000,
retries: 1,
logger,
}
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
const args = turnIceServersToServeSimArgs(data.iceServers);
if (args.length > 0) {
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
}
return args;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
logger.warn(
{ err: error },
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
);
return [];
}
}
export async function uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
@@ -120,18 +223,22 @@ export function spawnDetached({
return { getOutput: () => output };
}
export async function startServeSimWithTunnelAsync({
baseDomain,
env,
logger,
timeoutMs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}): Promise<{ previewUrl: string; streamUrl: string }> {
export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}
): Promise<{ previewUrl: string; streamUrl: string }> {
logger.info('Launching serve-sim with tunnel.');
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const serveSim = spawnDetached({
command: 'npx',
args: [
@@ -147,6 +254,7 @@ export async function startServeSimWithTunnelAsync({
'0.55',
'--codec',
'webrtc',
...turnArgs,
],
env,
});
@@ -188,19 +296,26 @@ export async function startNgrokTunnelAsync({
subdomainPrefix,
baseDomain,
authtoken,
rewriteHostHeader,
logger,
}: {
port: number;
subdomainPrefix: string;
baseDomain: string;
authtoken: string;
rewriteHostHeader?: boolean;
logger: bunyan;
}): Promise<string> {
const domain = `${subdomainPrefix}-${randomBytes(8).toString('hex')}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
const listener = await ngrok.forward({ addr: port, authtoken, domain });
const listener = await ngrok.forward({
addr: port,
authtoken,
domain,
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
});
const url = listener.url();
if (!url) {
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "20.0.0",
"version": "20.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "20.0.0"
"@expo/steps": "20.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "20.0.0",
"version": "20.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
+290 -117
View File
File diff suppressed because it is too large. Load diff
+4079 -45
View File
File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "20.0.0",
"version": "20.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-json": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-json": "20.1.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "20.0.0",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.0.0",
"@expo/steps": "20.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -224,6 +224,9 @@
"update": {
"description": "manage individual updates"
},
"update:embedded": {
"description": "manage embedded updates registered with EAS Update"
},
"webhook": {
"description": "manage webhooks"
},
@@ -0,0 +1,22 @@
import chalk from 'chalk';
import { PostHogProjectData } from '../graphql/types/PostHogConnection';
import Log, { link } from '../log';
export function getPostHogProjectDashboardUrl(project: PostHogProjectData): string {
const host = project.posthogHost.replace(/\/$/, '');
return `${host}/project/${encodeURIComponent(project.posthogProjectIdentifier)}`;
}
export function formatPostHogProject(project: PostHogProjectData): string {
return [
`${chalk.bold('Name')}: ${project.posthogProjectName}`,
`${chalk.bold('Host')}: ${project.posthogHost}`,
`${chalk.bold('Region')}: ${project.posthogOrganizationConnection.posthogRegion}`,
`${chalk.bold('Dashboard')}: ${link(getPostHogProjectDashboardUrl(project), { dim: false })}`,
].join('\n');
}
export function logNoPostHogProject(projectName: string): void {
Log.warn(`No PostHog project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
@@ -0,0 +1,149 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import { AuditLogFragment } from '../../graphql/generated';
import { AuditLogQuery } from '../../graphql/queries/AuditLogQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { confirmAsync, selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import renderTextTable from '../../utils/renderTextTable';
const AUDIT_LOGS_LIMIT = 50;
export default class AccountAudit extends EasCommand {
static override description = 'view the audit logs for an account';
static override args = {
ACCOUNT_NAME: Args.string({
description:
'Account name to view audit logs for. If not provided, the account will be selected interactively (or defaults to the only account if there is just one)',
}),
};
static override flags = {
limit: getLimitFlagWithCustomValues({ defaultTo: AUDIT_LOGS_LIMIT, limit: 100 }),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
...EasJsonOnlyFlag,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { ACCOUNT_NAME: accountName },
flags: { limit, after, json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(AccountAudit);
const json = jsonFlag || nonInteractive;
if (json) {
enableJsonOutput();
}
const {
loggedIn: { graphqlClient, actor },
} = await this.getContextAsync(AccountAudit, { nonInteractive });
const pageSize = limit ?? AUDIT_LOGS_LIMIT;
let targetAccount: { id: string; name: string };
const availableAccounts = actor.accounts.map(a => a.name).join(', ');
if (accountName) {
const found = actor.accounts.find(a => a.name === accountName);
if (!found) {
throw new Error(
`Account "${accountName}" not found or you don't have access. Available accounts: ${availableAccounts}`
);
}
targetAccount = found;
} else if (nonInteractive) {
throw new Error(
'ACCOUNT_NAME argument must be provided when running in `--non-interactive` mode.'
);
} else if (actor.accounts.length === 1) {
targetAccount = actor.accounts[0];
} else {
targetAccount = await selectAsync(
'Select account to view audit logs for:',
actor.accounts.map(account => ({
title: account.name,
value: account,
}))
);
}
if (json) {
const spinner = ora(`Fetching audit logs for account ${targetAccount.name}`).start();
try {
const connection = await AuditLogQuery.getAllForAccountAsync(
graphqlClient,
targetAccount.id,
{
first: pageSize,
after,
}
);
spinner.stop();
printJsonOnlyOutput({
auditLogs: connection.edges.map(edge => edge.node),
pageInfo: connection.pageInfo,
});
} catch (error) {
spinner.fail(`Failed to fetch audit logs for account ${targetAccount.name}`);
throw error;
}
return;
}
let cursor = after;
do {
const spinner = ora(`Fetching audit logs for account ${targetAccount.name}`).start();
const connection = await AuditLogQuery.getAllForAccountAsync(
graphqlClient,
targetAccount.id,
{
first: pageSize,
after: cursor,
}
);
spinner.stop();
const logs = connection.edges.map(edge => edge.node);
renderPageOfAuditLogs(logs);
if (!connection.pageInfo.hasNextPage) {
break;
}
cursor = connection.pageInfo.endCursor ?? undefined;
} while (cursor && (await confirmAsync({ message: 'Load more audit logs?' })));
}
}
function renderPageOfAuditLogs(logs: AuditLogFragment[]): void {
if (logs.length === 0) {
Log.log('No audit logs found.');
return;
}
Log.log(
renderTextTable(
['Date', 'Actor', 'Action', 'Entity', 'Message'],
logs.map(log => [
new Date(log.createdAt).toLocaleString(),
log.actor?.displayName ?? 'Unknown',
log.targetEntityMutationType,
log.targetEntityTypePublicName,
log.websiteMessage,
])
)
);
Log.addNewLineIfNone();
}
@@ -0,0 +1,658 @@
import spawnAsync from '@expo/spawn-async';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentVariableScope,
EnvironmentVariableVisibility,
PostHogRegion,
Role,
} from '../../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
import { PostHogMutation } from '../../../../graphql/mutations/PostHogMutation';
import { EnvironmentVariablesQuery } from '../../../../graphql/queries/EnvironmentVariablesQuery';
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
import {
PostHogOrganizationConnectionData,
PostHogProjectData,
} from '../../../../graphql/types/PostHogConnection';
import Log from '../../../../log';
import { createOrModifyExpoConfigAsync } from '../../../../project/expoConfig';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts';
import { Actor } from '../../../../user/User';
import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsPostHogConnect from '../connect';
jest.mock('@expo/spawn-async');
jest.mock('fs-extra');
jest.mock('../../../../project/expoConfig');
jest.mock('../../../../graphql/queries/PostHogQuery');
jest.mock('../../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../../graphql/mutations/PostHogMutation');
jest.mock('../../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsPostHogConnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockActor: Actor = {
__typename: 'User',
id: 'test-user-id',
username: testAccountName,
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: { id: testAccountId, name: testAccountName, ownerUserActor: null, users: [] },
preferences: { onboarding: null },
accounts: [],
};
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }],
};
const mockConnection: PostHogOrganizationConnectionData = {
id: 'connection-1',
posthogOrganizationIdentifier: 'org-123',
posthogOrganizationName: 'Test Org',
posthogRegion: PostHogRegion.Us,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const mockProject: PostHogProjectData = {
id: 'project-1',
posthogProjectIdentifier: '467657',
posthogProjectName: 'testapp',
posthogProjectToken: 'phc_public_key',
posthogHost: 'https://us.posthog.com',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
posthogOrganizationConnection: mockConnection,
};
function createCommand(argv: string[], actor: Actor = mockActor): IntegrationsPostHogConnect {
const command = new IntegrationsPostHogConnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { name: 'testapp', slug: 'testapp', plugins: [] },
projectDir: '/test/project',
},
loggedIn: { graphqlClient, actor },
} as never);
return command;
}
// Helper: which features the interactive multiselect returns.
function mockFeatureSelection(features: string[]): void {
jest.mocked(promptAsync).mockImplementation((async (opts: any) => {
if (opts.name === 'features') {
return { features };
}
if (opts.name === 'apiKey') {
return { apiKey: 'phx_personal_key' };
}
return {};
}) as any);
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest.mocked(selectAsync).mockResolvedValue(PostHogRegion.Us);
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(spawnAsync).mockResolvedValue({} as any);
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as any);
// Default: reuse an existing connection + project.
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(mockConnection);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mockResolvedValue(mockConnection);
jest.mocked(PostHogMutation.setupPostHogProjectAsync).mockResolvedValue(mockProject);
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
id: 'env-var-1',
scope: EnvironmentVariableScope.Project,
} as any);
mockFeatureSelection(['analytics', 'session-replay', 'error-tracking']);
});
it('sets up all features: installs SDK + replay pkg, adds plugin, writes EXPO_PUBLIC_ + POSTHOG_CLI_ vars', async () => {
await createCommand([]).runAsync();
const installArgs = jest.mocked(spawnAsync).mock.calls[0][1];
expect(installArgs).toContain('posthog-react-native');
expect(installArgs).toContain('posthog-react-native-session-replay');
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
expect(fs.writeFile).toHaveBeenCalled();
const createdNames = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.map(call => call[1].name);
expect(createdNames).toEqual(
expect.arrayContaining([
'EXPO_PUBLIC_POSTHOG_API_KEY',
'EXPO_PUBLIC_POSTHOG_HOST',
'POSTHOG_CLI_API_KEY',
'POSTHOG_CLI_PROJECT_ID',
'POSTHOG_CLI_HOST',
])
);
const cliKeyVar = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.find(call => call[1].name === 'POSTHOG_CLI_API_KEY');
expect(cliKeyVar?.[1].visibility).toBe(EnvironmentVariableVisibility.Sensitive);
// The pasted personal key flows through verbatim — to EAS and to .env.local.
expect(cliKeyVar?.[1].value).toBe('phx_personal_key');
const written = jest.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(written).toContain('POSTHOG_CLI_API_KEY=phx_personal_key');
});
it('asks for features only after provisioning (fast-forward ordering)', async () => {
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
let provisionedBeforePrompt = false;
jest.mocked(promptAsync).mockImplementation((async (opts: any) => {
if (opts.name === 'features') {
provisionedBeforePrompt =
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mock.calls.length > 0 &&
jest.mocked(PostHogMutation.setupPostHogProjectAsync).mock.calls.length > 0;
return { features: ['analytics'] };
}
return {};
}) as any);
await createCommand(['--region', 'US']).runAsync();
expect(provisionedBeforePrompt).toBe(true);
});
it('analytics only: no replay pkg, no CLI vars, no key prompt', async () => {
mockFeatureSelection(['analytics']);
await createCommand([]).runAsync();
expect(jest.mocked(spawnAsync).mock.calls[0][1]).not.toContain(
'posthog-react-native-session-replay'
);
const createdNames = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.map(call => call[1].name);
expect(createdNames).toEqual(['EXPO_PUBLIC_POSTHOG_API_KEY', 'EXPO_PUBLIC_POSTHOG_HOST']);
expect(promptAsync).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'apiKey' }));
});
it('adds the config plugin and public env vars for session-replay-only (analytics deselected)', async () => {
mockFeatureSelection(['session-replay']);
await createCommand([]).runAsync();
// The plugin wires native modules replay needs, so it must be added even without analytics.
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
expect(jest.mocked(spawnAsync).mock.calls[0][1]).toContain(
'posthog-react-native-session-replay'
);
// The public key + host initialize the SDK for any feature, so they're still written.
const createdNames = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.map(call => call[1].name);
expect(createdNames).toEqual(['EXPO_PUBLIC_POSTHOG_API_KEY', 'EXPO_PUBLIC_POSTHOG_HOST']);
});
it('captures the install child output so --json stdout stays clean', async () => {
await createCommand(['--json', '--posthog-cli-api-key', 'phx_x']).runAsync();
expect(jest.mocked(spawnAsync).mock.calls[0][2]).not.toHaveProperty('stdio');
});
it('non-interactive defaults: analytics + replay, error tracking auto-skipped', async () => {
await createCommand(['--non-interactive']).runAsync();
expect(promptAsync).not.toHaveBeenCalled();
const createdNames = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.map(call => call[1].name);
expect(createdNames).not.toContain('POSTHOG_CLI_API_KEY');
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Skipping error tracking'));
// Session replay defaults on, so its package is still installed.
expect(jest.mocked(spawnAsync).mock.calls[0][1]).toContain(
'posthog-react-native-session-replay'
);
});
it('non-interactive --error-tracking without a key fails before any provisioning', async () => {
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
await expect(
createCommand(['--non-interactive', '--error-tracking']).runAsync()
).rejects.toThrow(/personal API key in non-interactive/);
expect(PostHogMutation.createPostHogAccountRequestAsync).not.toHaveBeenCalled();
});
it('non-interactive --error-tracking with --posthog-cli-api-key sets up CLI vars without prompting', async () => {
await createCommand([
'--non-interactive',
'--error-tracking',
'--posthog-cli-api-key',
'phx_from_flag',
]).runAsync();
expect(promptAsync).not.toHaveBeenCalled();
const cliKeyVar = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.find(call => call[1].name === 'POSTHOG_CLI_API_KEY');
expect(cliKeyVar?.[1].value).toBe('phx_from_flag');
});
it('treats a whitespace-only --posthog-cli-api-key as absent (fails like no key)', async () => {
await expect(
createCommand([
'--non-interactive',
'--error-tracking',
'--posthog-cli-api-key',
' ',
]).runAsync()
).rejects.toThrow(/personal API key in non-interactive/);
});
it('surfaces a manual plugin step and still writes env vars when the app config is dynamic', async () => {
mockFeatureSelection(['analytics']);
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({
type: 'warn',
message: 'Cannot automatically write to dynamic config',
} as any);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Add "posthog-react-native/expo" to the "plugins"')
);
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(fs.writeFile).toHaveBeenCalled();
});
it('surfaces the plugin manual step (without a cause) when the config write fails', async () => {
mockFeatureSelection(['analytics']);
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'fail' } as any);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Add "posthog-react-native/expo" to the "plugins"')
);
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
});
it('reuses an existing connection + project without provisioning', async () => {
await createCommand([]).runAsync();
expect(PostHogMutation.createPostHogAccountRequestAsync).not.toHaveBeenCalled();
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
});
it('surfaces the existing-account dead-end without provisioning a project', async () => {
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mockRejectedValue(
Object.assign(new Error('You already have a PostHog account.'), {
graphQLErrors: [{ extensions: { errorCode: 'POSTHOG_EXISTING_USER_NOT_SUPPORTED_ERROR' } }],
})
);
await expect(createCommand(['--region', 'US']).runAsync()).rejects.toThrow(
'You already have a PostHog account.'
);
// Fails (non-zero exit) without provisioning a project or writing any config.
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('emits JSON output with --json', async () => {
await createCommand(['--json', '--posthog-cli-api-key', 'phx_x']).runAsync();
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
project: expect.objectContaining({ apiKey: 'phc_public_key' }),
// --json supplies the key, so error tracking is on and its vars are reported.
features: expect.objectContaining({ analytics: true, errorTracking: true }),
dashboardUrl: expect.any(String),
environmentVariables: expect.arrayContaining([
'EXPO_PUBLIC_POSTHOG_API_KEY',
'POSTHOG_CLI_API_KEY',
'POSTHOG_CLI_PROJECT_ID',
'POSTHOG_CLI_HOST',
]),
})
);
});
it('requires --region in non-interactive mode when provisioning', async () => {
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
/region is required in non-interactive/
);
});
it('warns when --region differs from the already-connected region', async () => {
await createCommand(['--region', 'EU']).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('already connected to PostHog'));
});
it('provisions a new connection and project when none exist', async () => {
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
mockFeatureSelection(['analytics']);
await createCommand(['--region', 'US']).runAsync();
expect(PostHogMutation.createPostHogAccountRequestAsync).toHaveBeenCalledWith(graphqlClient, {
accountId: testAccountId,
region: PostHogRegion.Us,
});
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
posthogOrganizationConnectionId: mockConnection.id,
});
});
it('rethrows a non-dead-end provisioning failure', async () => {
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
jest
.mocked(PostHogMutation.createPostHogAccountRequestAsync)
.mockRejectedValue(new Error('network down'));
await expect(createCommand(['--region', 'US']).runAsync()).rejects.toThrow('network down');
});
it('rethrows when project setup fails', async () => {
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
jest
.mocked(PostHogMutation.setupPostHogProjectAsync)
.mockRejectedValue(new Error('setup failed'));
await expect(createCommand([]).runAsync()).rejects.toThrow('setup failed');
});
it('honors explicit --no-session-replay without showing the feature multiselect', async () => {
await createCommand(['--no-session-replay']).runAsync();
expect(promptAsync).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'features' }));
expect(jest.mocked(spawnAsync).mock.calls[0][1]).not.toContain(
'posthog-react-native-session-replay'
);
});
it('prompts for a personal API key and validates it', async () => {
await createCommand([]).runAsync();
const apiKeyPrompt = jest
.mocked(promptAsync)
.mock.calls.map(call => call[0])
.find((opts: any) => opts.name === 'apiKey') as any;
expect(apiKeyPrompt).toBeDefined();
expect(apiKeyPrompt.validate('')).toBe('Personal API key cannot be empty');
expect(apiKeyPrompt.validate('phx_real')).toBe(true);
});
it('continues (does not abort) and still writes env vars when SDK installation fails', async () => {
mockFeatureSelection(['analytics']);
jest.mocked(spawnAsync).mockRejectedValue(new Error('npm exploded'));
await createCommand([]).runAsync();
// Install failure is non-fatal: provisioning + env vars still happen, with a manual follow-up.
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(fs.writeFile).toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install'));
});
it('on a dynamic config, surfaces only the plugin step (not a reinstall step) when expo install reports the dynamic-config error', async () => {
mockFeatureSelection(['analytics']);
jest.mocked(spawnAsync).mockRejectedValue(
Object.assign(new Error('Process exited with non-zero code: 1'), {
stdout: 'Cannot automatically write to dynamic config at: app.config.js\n',
stderr: '',
})
);
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({
type: 'warn',
message: 'Cannot automatically write to dynamic config at: app.config.js',
} as any);
await createCommand([]).runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Add "posthog-react-native/expo" to the "plugins"')
);
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining("didn't install"));
});
it('skips the config plugin when it is already configured', async () => {
mockFeatureSelection(['analytics']);
const command = createCommand([]);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { name: 'testapp', slug: 'testapp', plugins: ['posthog-react-native/expo'] },
projectDir: '/test/project',
},
loggedIn: { graphqlClient, actor: mockActor },
} as never);
await command.runAsync();
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
});
it('does nothing local when no features are selected (no install, no writes)', async () => {
mockFeatureSelection([]);
await createCommand([]).runAsync();
expect(spawnAsync).not.toHaveBeenCalled();
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No PostHog features selected'));
// Doesn't falsely claim the SDK is wired up when nothing was configured.
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('PostHog is connected!'));
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Re-run to add'));
});
it('merges into an existing .env.local, replacing matching keys and appending new ones', async () => {
mockFeatureSelection(['analytics']);
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest
.mocked(fs.readFile)
.mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\nOTHER=keep' as never);
await createCommand([]).runAsync();
const written = jest.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(written).toContain('EXPO_PUBLIC_POSTHOG_API_KEY=phc_public_key');
expect(written).toContain('OTHER=keep');
expect(written).toContain('EXPO_PUBLIC_POSTHOG_HOST=https://us.posthog.com');
});
it('prompts for a region interactively when provisioning without --region', async () => {
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
mockFeatureSelection(['analytics']);
await createCommand([]).runAsync();
expect(selectAsync).toHaveBeenCalledWith('Select a PostHog region', expect.any(Array));
expect(PostHogMutation.createPostHogAccountRequestAsync).toHaveBeenCalledWith(graphqlClient, {
accountId: testAccountId,
region: PostHogRegion.Us,
});
});
it('skips .env.local when the user declines to overwrite a conflict', async () => {
mockFeatureSelection(['analytics']);
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\n' as never);
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(fs.writeFile).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped updating'));
});
it('--overwrite writes .env.local without prompting on a conflict', async () => {
mockFeatureSelection(['analytics']);
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\n' as never);
await createCommand(['--overwrite']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
const written = jest.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(written).toContain('EXPO_PUBLIC_POSTHOG_API_KEY=phc_public_key');
});
it('non-interactively skips an existing .env.local key without --overwrite', async () => {
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\n' as never);
await createCommand(['--non-interactive']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('pass --overwrite to replace'));
});
it('non-interactively overwrites an existing .env.local key with --overwrite', async () => {
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\n' as never);
await createCommand(['--non-interactive', '--overwrite']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
const written = jest.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(written).toContain('EXPO_PUBLIC_POSTHOG_API_KEY=phc_public_key');
});
it('writes a personal API key containing $ verbatim (no replacement-pattern expansion)', async () => {
const command = createCommand([]);
const merged = (command as any).mergeEnvContent('POSTHOG_CLI_API_KEY=old\n', {
POSTHOG_CLI_API_KEY: 'phx_a$2b$&c',
});
expect(merged).toContain('POSTHOG_CLI_API_KEY=phx_a$2b$&c');
});
it('updates an existing EAS environment variable with --overwrite', async () => {
mockFeatureSelection(['analytics']);
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
.mockResolvedValue([{ id: 'existing-1', scope: EnvironmentVariableScope.Project }] as any);
jest
.mocked(EnvironmentVariableMutation.updateAsync)
.mockResolvedValue({ id: 'existing-1' } as any);
await createCommand(['--overwrite']).runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
});
it('honors explicit --error-tracking interactively and detects an array-form plugin', async () => {
const command = createCommand(['--error-tracking', '--posthog-cli-api-key', 'phx_flag']);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { name: 'testapp', slug: 'testapp', plugins: [['expo-router', {}]] },
projectDir: '/test/project',
},
loggedIn: { graphqlClient, actor: mockActor },
} as never);
await command.runAsync();
expect(promptAsync).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'features' }));
const cliKeyVar = jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mock.calls.find(call => call[1].name === 'POSTHOG_CLI_API_KEY');
expect(cliKeyVar?.[1].value).toBe('phx_flag');
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
});
it('skips an existing EAS environment variable non-interactively without --overwrite', async () => {
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
.mockResolvedValue([{ id: 'existing-1', scope: EnvironmentVariableScope.Project }] as any);
await createCommand(['--non-interactive', '--region', 'US']).runAsync();
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('pass --overwrite to replace it')
);
});
it('skips an existing EAS environment variable when overwrite is declined', async () => {
mockFeatureSelection(['analytics']);
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
.mockResolvedValue([{ id: 'existing-1', scope: EnvironmentVariableScope.Project }] as any);
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Skipped updating EAS environment variable')
);
});
});
@@ -0,0 +1,160 @@
import openBrowserAsync from 'better-opn';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { PostHogRegion } from '../../../../graphql/generated';
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
import {
PostHogOrganizationConnectionData,
PostHogProjectData,
} from '../../../../graphql/types/PostHogConnection';
import Log from '../../../../log';
import { ora } from '../../../../ora';
import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsPostHogDashboard from '../dashboard';
jest.mock('better-opn');
jest.mock('../../../../graphql/queries/PostHogQuery');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
})),
}));
describe(IntegrationsPostHogDashboard, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: PostHogOrganizationConnectionData = {
id: 'connection-1',
posthogOrganizationIdentifier: 'org-123',
posthogOrganizationName: 'Test Org',
posthogRegion: PostHogRegion.Us,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const mockProject: PostHogProjectData = {
id: 'project-1',
posthogProjectIdentifier: 'res-123',
posthogProjectName: 'Test Project',
posthogProjectToken: 'phc_public_key',
posthogHost: 'https://us.posthog.com',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
posthogOrganizationConnection: mockConnection,
};
function createCommand(argv: string[] = []): IntegrationsPostHogDashboard {
const command = new IntegrationsPostHogDashboard(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
} as any);
});
it('opens the linked PostHog project dashboard', async () => {
await createCommand().runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/res-123');
});
it('normalizes a trailing-slash host and percent-encodes the project identifier', async () => {
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue({
...mockProject,
posthogHost: 'https://us.posthog.com/',
posthogProjectIdentifier: 'team a/b',
});
await createCommand().runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/team%20a%2Fb');
});
it('logs an empty state when no project link exists', async () => {
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
await createCommand().runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No PostHog project is linked to Expo app')
);
});
it('fails the spinner when the browser cannot be opened', async () => {
jest.mocked(openBrowserAsync).mockResolvedValue(false);
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await createCommand().runAsync();
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
it('prints the dashboard URL in non-interactive mode without opening a browser', async () => {
await createCommand(['--non-interactive']).runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith('https://us.posthog.com/project/res-123');
});
it('emits the dashboard URL as JSON with --json', async () => {
await createCommand(['--json']).runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
dashboardUrl: 'https://us.posthog.com/project/res-123',
});
});
it('emits a null dashboardUrl as JSON when no project is linked', async () => {
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
await createCommand(['--json']).runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ dashboardUrl: null });
});
it('fails the spinner and rethrows when opening the browser throws', async () => {
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await expect(createCommand().runAsync()).rejects.toThrow('no browser');
expect(spinner.fail).toHaveBeenCalled();
});
});
@@ -0,0 +1,158 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { PostHogRegion } from '../../../../graphql/generated';
import { PostHogMutation } from '../../../../graphql/mutations/PostHogMutation';
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
import {
PostHogOrganizationConnectionData,
PostHogProjectData,
} from '../../../../graphql/types/PostHogConnection';
import Log from '../../../../log';
import { confirmAsync } from '../../../../prompts';
import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsPostHogDisconnect from '../disconnect';
jest.mock('../../../../graphql/queries/PostHogQuery');
jest.mock('../../../../graphql/mutations/PostHogMutation');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsPostHogDisconnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: PostHogOrganizationConnectionData = {
id: 'connection-1',
posthogOrganizationIdentifier: 'org-123',
posthogOrganizationName: 'Test Org',
posthogRegion: PostHogRegion.Us,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const mockProject: PostHogProjectData = {
id: 'project-1',
posthogProjectIdentifier: 'res-123',
posthogProjectName: 'Test Project',
posthogProjectToken: 'phc_public_key',
posthogHost: 'https://us.posthog.com',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
posthogOrganizationConnection: mockConnection,
};
function createCommand(argv: string[]): IntegrationsPostHogDisconnect {
const command = new IntegrationsPostHogDisconnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(PostHogMutation.deletePostHogProjectAsync).mockResolvedValue(mockProject.id);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('removes the project link after confirmation', async () => {
await createCommand([]).runAsync();
expect(PostHogMutation.deletePostHogProjectAsync).toHaveBeenCalledWith(
graphqlClient,
mockProject.id
);
});
it('skips the prompt with --yes', async () => {
await createCommand(['--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(PostHogMutation.deletePostHogProjectAsync).toHaveBeenCalledWith(
graphqlClient,
mockProject.id
);
});
it('does nothing when the user declines confirmation', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(PostHogMutation.deletePostHogProjectAsync).not.toHaveBeenCalled();
});
it('deletes after a warning in non-interactive mode, noting PostHog data is preserved', async () => {
await createCommand(['--non-interactive']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('does not delete the project on PostHog')
);
expect(PostHogMutation.deletePostHogProjectAsync).toHaveBeenCalledWith(
graphqlClient,
mockProject.id
);
});
it('logs an empty state when no project link exists', async () => {
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(PostHogMutation.deletePostHogProjectAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No PostHog project is linked to Expo app')
);
});
it('emits JSON output with --json and skips the prompt', async () => {
await createCommand(['--json']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(PostHogMutation.deletePostHogProjectAsync).toHaveBeenCalledWith(
graphqlClient,
mockProject.id
);
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
id: mockProject.id,
name: mockProject.posthogProjectName,
});
});
it('emits a null id as JSON when no project is linked', async () => {
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
await createCommand(['--json']).runAsync();
expect(PostHogMutation.deletePostHogProjectAsync).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ id: null });
});
it('rethrows when the delete mutation fails', async () => {
jest
.mocked(PostHogMutation.deletePostHogProjectAsync)
.mockRejectedValue(new Error('delete boom'));
await expect(createCommand(['--yes']).runAsync()).rejects.toThrow('delete boom');
});
});
@@ -0,0 +1,568 @@
import { ExpoConfig } from '@expo/config';
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
import { DefaultEnvironment } from '../../../build/utils/environment';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { getPostHogProjectDashboardUrl } from '../../../commandUtils/posthog';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
PostHogRegion,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { PostHogMutation } from '../../../graphql/mutations/PostHogMutation';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import { PostHogQuery } from '../../../graphql/queries/PostHogQuery';
import { PostHogProjectData } from '../../../graphql/types/PostHogConnection';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { Choice, confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
const POSTHOG_REGIONS = [
{ title: 'United States (US)', value: PostHogRegion.Us },
{ title: 'European Union (EU)', value: PostHogRegion.Eu },
];
const EAS_POSTHOG_ENVIRONMENTS = [
DefaultEnvironment.Production,
DefaultEnvironment.Preview,
DefaultEnvironment.Development,
];
const SDK_PACKAGES = [
'posthog-react-native',
'expo-file-system',
'expo-application',
'expo-device',
'expo-localization',
];
const SESSION_REPLAY_PACKAGE = 'posthog-react-native-session-replay';
const CONFIG_PLUGIN = 'posthog-react-native/expo';
const EAS_POSTHOG_API_KEY_ENV_VAR_NAME = 'EXPO_PUBLIC_POSTHOG_API_KEY';
const EAS_POSTHOG_HOST_ENV_VAR_NAME = 'EXPO_PUBLIC_POSTHOG_HOST';
const POSTHOG_CLI_API_KEY_ENV_VAR_NAME = 'POSTHOG_CLI_API_KEY';
const POSTHOG_CLI_PROJECT_ID_ENV_VAR_NAME = 'POSTHOG_CLI_PROJECT_ID';
const POSTHOG_CLI_HOST_ENV_VAR_NAME = 'POSTHOG_CLI_HOST';
const PERSONAL_API_KEY_SETTINGS_PATH = '/settings/user-api-keys';
const ERROR_TRACKING_NEEDS_KEY_MESSAGE = `Error tracking needs a PostHog personal API key in non-interactive mode. Pass --posthog-cli-api-key (create one in PostHog under Settings → Personal API keys (${PERSONAL_API_KEY_SETTINGS_PATH}) with the "Source map upload" preset), or drop --error-tracking.`;
type Features = { analytics: boolean; sessionReplay: boolean; errorTracking: boolean };
type EnvVar = { name: string; value: string; visibility: EnvironmentVariableVisibility };
function getSpawnErrorOutput(error: unknown): string {
const { stdout, stderr } = (error ?? {}) as { stdout?: string; stderr?: string };
return `${stdout ?? ''}${stderr ?? ''}`;
}
export default class IntegrationsPostHogConnect extends EasCommand {
static override description = 'connect PostHog to your Expo project';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
region: Flags.string({
description: 'PostHog region',
options: POSTHOG_REGIONS.map(r => r.value),
}),
'session-replay': Flags.boolean({
allowNo: true,
description: 'Set up PostHog session replay (default: yes)',
}),
'error-tracking': Flags.boolean({
allowNo: true,
description: 'Set up PostHog error tracking / source maps (requires a personal API key)',
}),
'posthog-cli-api-key': Flags.string({
description:
'PostHog personal API key for error-tracking source-map uploads (enables error tracking non-interactively)',
}),
overwrite: Flags.boolean({
description: 'Overwrite existing PostHog environment variables without prompting',
default: false,
}),
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsPostHogConnect);
const { region: regionFlag, overwrite } = flags;
const cliApiKeyFlag =
(flags['posthog-cli-api-key'] ?? process.env[POSTHOG_CLI_API_KEY_ENV_VAR_NAME])?.trim() ||
undefined;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
if (nonInteractive && flags['error-tracking'] === true && !cliApiKeyFlag) {
throw new Error(ERROR_TRACKING_NEEDS_KEY_MESSAGE);
}
const {
privateProjectConfig: { projectId, exp, projectDir },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsPostHogConnect, {
nonInteractive,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let connection = await PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync(
graphqlClient,
account.id
);
if (connection) {
if (regionFlag && regionFlag !== connection.posthogRegion) {
Log.warn(
`This account is already connected to PostHog in the ${connection.posthogRegion} region; --region ${regionFlag} is ignored. An account has a single PostHog organization and its region can't be changed.`
);
}
Log.withTick(
`Using existing PostHog organization ${chalk.bold(connection.posthogOrganizationName)} (${connection.posthogRegion})`
);
} else {
const region = await this.resolveRegionAsync(regionFlag, nonInteractive);
const spinner = ora('Creating PostHog organization').start();
try {
connection = await PostHogMutation.createPostHogAccountRequestAsync(graphqlClient, {
accountId: account.id,
region,
});
spinner.succeed(
`Created PostHog organization ${chalk.bold(connection.posthogOrganizationName)}`
);
} catch (error) {
spinner.fail('Failed to create PostHog organization');
throw error;
}
}
let project = await PostHogQuery.getPostHogProjectByAppIdAsync(graphqlClient, projectId);
if (project) {
Log.withTick(`Using existing PostHog project ${chalk.bold(project.posthogProjectName)}`);
} else {
const spinner = ora('Setting up PostHog project').start();
try {
project = await PostHogMutation.setupPostHogProjectAsync(graphqlClient, {
appId: projectId,
posthogOrganizationConnectionId: connection.id,
});
spinner.succeed(`Created PostHog project ${chalk.bold(project.posthogProjectName)}`);
} catch (error) {
spinner.fail('Failed to set up PostHog project');
throw error;
}
}
const features = await this.resolveFeaturesAsync(flags, cliApiKeyFlag, nonInteractive);
const anyFeatureSelected =
features.analytics || features.sessionReplay || features.errorTracking;
const envVars: EnvVar[] = [];
if (anyFeatureSelected) {
envVars.push(
{
name: EAS_POSTHOG_API_KEY_ENV_VAR_NAME,
value: project.posthogProjectToken,
visibility: EnvironmentVariableVisibility.Public,
},
{
name: EAS_POSTHOG_HOST_ENV_VAR_NAME,
value: project.posthogHost,
visibility: EnvironmentVariableVisibility.Public,
}
);
}
if (features.errorTracking) {
const cliApiKey = await this.resolveCliApiKeyAsync(cliApiKeyFlag, project.posthogHost);
envVars.push(
{
name: POSTHOG_CLI_API_KEY_ENV_VAR_NAME,
value: cliApiKey,
visibility: EnvironmentVariableVisibility.Sensitive,
},
{
name: POSTHOG_CLI_PROJECT_ID_ENV_VAR_NAME,
value: project.posthogProjectIdentifier,
visibility: EnvironmentVariableVisibility.Public,
},
{
name: POSTHOG_CLI_HOST_ENV_VAR_NAME,
value: project.posthogHost,
visibility: EnvironmentVariableVisibility.Public,
}
);
}
const manualSteps: string[] = [];
if (anyFeatureSelected) {
const packages = [...SDK_PACKAGES];
if (features.sessionReplay) {
packages.push(SESSION_REPLAY_PACKAGE);
}
const installResult = await this.installSdkPackagesAsync(projectDir, packages, jsonFlag);
if (installResult === 'failed') {
manualSteps.push(
`The PostHog SDK packages didn't install. Run npx expo install ${packages.join(' ')} from your project directory.`
);
}
const pluginManualStep = await this.addConfigPluginAsync(projectDir, exp);
if (pluginManualStep) {
manualSteps.push(pluginManualStep);
}
}
await this.writeEnvLocalAsync(projectDir, envVars, nonInteractive, overwrite);
await Promise.all(
envVars.map(envVar =>
this.upsertEasEnvVarAsync(graphqlClient, projectId, envVar, nonInteractive, overwrite)
)
);
if (jsonFlag) {
printJsonOnlyOutput({
organizationConnection: {
id: connection.id,
name: connection.posthogOrganizationName,
region: connection.posthogRegion,
},
project: {
id: project.id,
name: project.posthogProjectName,
apiKey: project.posthogProjectToken,
host: project.posthogHost,
},
features,
dashboardUrl: getPostHogProjectDashboardUrl(project),
environmentVariables: envVars.map(v => v.name),
manualSteps,
});
return;
}
if (!anyFeatureSelected) {
Log.addNewLineIfNone();
Log.log(chalk.green('PostHog project created.'));
Log.newLine();
Log.log(
`${chalk.bold('Dashboard')}: ${link(getPostHogProjectDashboardUrl(project), { dim: false })}`
);
Log.newLine();
Log.warn(
'No PostHog features selected, so no SDK, config plugin, or environment variables were set up. Re-run to add analytics, session replay, or error tracking.'
);
return;
}
this.printNextSteps(project, features, manualSteps);
}
private async resolveFeaturesAsync(
flags: { 'session-replay'?: boolean; 'error-tracking'?: boolean },
cliApiKey: string | undefined,
nonInteractive: boolean
): Promise<Features> {
const sessionReplayFlag = flags['session-replay'];
const errorTrackingFlag = flags['error-tracking'];
const hasCliApiKey = !!cliApiKey;
if (nonInteractive) {
const errorTracking = errorTrackingFlag ?? hasCliApiKey;
if (errorTrackingFlag === undefined && !hasCliApiKey) {
Log.warn(
'Skipping error tracking (source maps) — it needs a personal API key. Re-run interactively, or pass --posthog-cli-api-key to enable it non-interactively.'
);
}
return { analytics: true, sessionReplay: sessionReplayFlag ?? true, errorTracking };
}
if (sessionReplayFlag !== undefined || errorTrackingFlag !== undefined) {
return {
analytics: true,
sessionReplay: sessionReplayFlag ?? true,
errorTracking: errorTrackingFlag ?? true,
};
}
const { features } = await promptAsync({
type: 'multiselect',
name: 'features',
message: 'PostHog features to set up',
choices: [
{ title: 'Analytics', value: 'analytics', selected: true },
{ title: 'Session replay', value: 'session-replay', selected: true },
{
title: 'Error tracking (source maps) — requires a personal API key',
value: 'error-tracking',
selected: true,
},
] satisfies Choice[],
instructions: false,
min: 0,
});
const selected = new Set<string>(features as string[]);
return {
analytics: selected.has('analytics'),
sessionReplay: selected.has('session-replay'),
errorTracking: selected.has('error-tracking'),
};
}
private async resolveCliApiKeyAsync(
cliApiKey: string | undefined,
host: string
): Promise<string> {
if (cliApiKey) {
return cliApiKey;
}
const settingsUrl = `${host.replace(/\/$/, '')}${PERSONAL_API_KEY_SETTINGS_PATH}`;
const { apiKey } = await promptAsync({
type: 'password',
name: 'apiKey',
message: `Paste a PostHog personal API key for source-map uploads.\nCreate one at ${settingsUrl} using the "Source map upload" preset.`,
validate: (value: string) => (value.trim() ? true : 'Personal API key cannot be empty'),
});
return apiKey.trim();
}
private async resolveRegionAsync(
flagValue: string | undefined,
nonInteractive: boolean
): Promise<PostHogRegion> {
const flagRegion = POSTHOG_REGIONS.find(r => r.value === flagValue);
if (flagRegion) {
return flagRegion.value;
}
if (nonInteractive) {
throw new Error(
'A PostHog region is required in non-interactive mode. Pass --region US or --region EU. The region sets data residency and cannot be changed after connecting.'
);
}
return await selectAsync('Select a PostHog region', POSTHOG_REGIONS);
}
private async installSdkPackagesAsync(
projectDir: string,
packages: string[],
jsonFlag: boolean
): Promise<'installed' | 'failed'> {
const spinner = jsonFlag ? null : ora('Installing the PostHog SDK packages').start();
try {
await spawnAsync('npx', ['expo', 'install', ...packages], { cwd: projectDir });
spinner?.succeed('Installed the PostHog SDK packages');
return 'installed';
} catch (error) {
const output = getSpawnErrorOutput(error);
Log.debug(output || error);
if (output.includes('Cannot automatically write to dynamic config')) {
spinner?.succeed('Installed the PostHog SDK packages');
return 'installed';
}
spinner?.fail('Failed to install the PostHog SDK packages');
return 'failed';
}
}
private async addConfigPluginAsync(projectDir: string, exp: ExpoConfig): Promise<string | null> {
const plugins = exp.plugins ?? [];
const alreadyAdded = plugins.some(p => (Array.isArray(p) ? p[0] : p) === CONFIG_PLUGIN);
if (alreadyAdded) {
Log.withTick(`Config plugin ${chalk.bold(CONFIG_PLUGIN)} is already configured`);
return null;
}
const modification = await createOrModifyExpoConfigAsync(
projectDir,
{ plugins: [...plugins, CONFIG_PLUGIN] },
{ skipSDKVersionRequirement: true }
);
if (modification.type === 'success') {
Log.withTick(`Added the ${chalk.bold(CONFIG_PLUGIN)} config plugin`);
return null;
}
if (modification.type === 'warn') {
return `${modification.message} Add ${JSON.stringify(CONFIG_PLUGIN)} to the "plugins" array in your app config.`;
}
return `Add ${JSON.stringify(CONFIG_PLUGIN)} to the "plugins" array in your app config.`;
}
private async writeEnvLocalAsync(
projectDir: string,
envVars: EnvVar[],
nonInteractive: boolean,
overwrite: boolean
): Promise<void> {
if (envVars.length === 0) {
return;
}
const envPath = path.join(projectDir, '.env.local');
let rawContent = '';
if (await fs.pathExists(envPath)) {
rawContent = await fs.readFile(envPath, 'utf8');
const existing = dotenv.parse(rawContent);
const conflicts = envVars.filter(v => existing[v.name] !== undefined);
if (conflicts.length > 0 && !overwrite) {
if (nonInteractive) {
Log.warn(
`.env.local already defines ${conflicts
.map(v => v.name)
.join(', ')}; skipped (pass --overwrite to replace).`
);
return;
}
const confirmed = await confirmAsync({
message: `.env.local already defines ${conflicts
.map(v => v.name)
.join(', ')}. Overwrite with the PostHog values?`,
});
if (!confirmed) {
Log.warn(`Skipped updating ${chalk.bold('.env.local')}.`);
return;
}
}
}
const updatedContent = this.mergeEnvContent(
rawContent,
Object.fromEntries(envVars.map(v => [v.name, v.value]))
);
await fs.writeFile(envPath, updatedContent);
Log.withTick(`Wrote PostHog config to ${chalk.bold('.env.local')}`);
}
private mergeEnvContent(rawContent: string, newVars: Record<string, string>): string {
let content = rawContent;
const keysToAdd: Record<string, string> = { ...newVars };
for (const [key, value] of Object.entries(newVars)) {
const regex = new RegExp(`^${key}=.*$`, 'm');
if (regex.test(content)) {
content = content.replace(regex, () => `${key}=${value}`);
delete keysToAdd[key];
}
}
const remaining = Object.entries(keysToAdd);
if (remaining.length > 0) {
if (content.length > 0 && !content.endsWith('\n')) {
content += '\n';
}
for (const [key, value] of remaining) {
content += `${key}=${value}\n`;
}
}
return content;
}
private async upsertEasEnvVarAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
envVar: EnvVar,
nonInteractive: boolean,
overwrite: boolean
): Promise<void> {
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
filterNames: [envVar.name],
});
const existingProjectVariable = existingVariables.find(
variable => variable.scope === EnvironmentVariableScope.Project
);
if (existingProjectVariable) {
const shouldOverwrite =
overwrite ||
(!nonInteractive &&
(await confirmAsync({
message: `EAS already has an ${envVar.name} environment variable for this project. Overwrite it?`,
})));
if (!shouldOverwrite) {
Log.warn(
`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}${
nonInteractive ? ' (pass --overwrite to replace it)' : ''
}.`
);
return;
}
await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id: existingProjectVariable.id,
name: envVar.name,
value: envVar.value,
environments: EAS_POSTHOG_ENVIRONMENTS,
visibility: envVar.visibility,
type: EnvironmentSecretType.String,
});
Log.withTick(`Updated EAS environment variable ${chalk.bold(envVar.name)} for builds`);
return;
}
await EnvironmentVariableMutation.createForAppAsync(
graphqlClient,
{
name: envVar.name,
value: envVar.value,
environments: EAS_POSTHOG_ENVIRONMENTS,
visibility: envVar.visibility,
type: EnvironmentSecretType.String,
},
projectId
);
Log.withTick(`Created EAS environment variable ${chalk.bold(envVar.name)} for builds`);
}
private printNextSteps(
project: PostHogProjectData,
features: Features,
manualSteps: string[]
): void {
Log.addNewLineIfNone();
Log.log(chalk.green('PostHog is connected!'));
Log.newLine();
Log.log(
`${chalk.bold('Dashboard')}: ${link(getPostHogProjectDashboardUrl(project), { dim: false })}`
);
Log.newLine();
Log.log('Next steps:');
const steps: string[] = [
`Wrap your app in ${chalk.cyan('<PostHogProvider>')} following our guide (${chalk.cyan('https://docs.expo.dev/guides/using-posthog')}).`,
];
if (features.sessionReplay) {
steps.push(
`Enable session replay in the provider options (the ${chalk.bold(SESSION_REPLAY_PACKAGE)} package is installed).`
);
}
if (features.errorTracking) {
steps.push(
`Error-tracking source maps: your ${chalk.bold('POSTHOG_CLI_*')} env vars are set. Wrap ${chalk.bold('metro.config.js')} with ${chalk.cyan('getPostHogExpoConfig')} (see ${chalk.cyan('https://docs.expo.dev/guides/using-posthog#source-maps')}); uploads then run automatically on EAS Build, and via ${chalk.cyan('posthog-cli hermes upload')} for EAS Update.`
);
}
steps.forEach((step, index) => {
Log.log(` ${index + 1}. ${step}`);
});
if (manualSteps.length > 0) {
Log.newLine();
Log.warn('Finish setup manually:');
manualSteps.forEach(step => {
Log.warn(` • ${step}`);
});
}
}
}
@@ -0,0 +1,80 @@
import openBrowserAsync from 'better-opn';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { getPostHogProjectDashboardUrl, logNoPostHogProject } from '../../../commandUtils/posthog';
import { PostHogQuery } from '../../../graphql/queries/PostHogQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsPostHogDashboard extends EasCommand {
static override description = 'open the PostHog dashboard for the linked PostHog project';
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsPostHogDashboard);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsPostHogDashboard, {
nonInteractive,
withServerSideEnvironment: null,
});
const posthogProject = await PostHogQuery.getPostHogProjectByAppIdAsync(
graphqlClient,
projectId
);
if (!posthogProject) {
if (jsonFlag) {
printJsonOnlyOutput({ dashboardUrl: null });
} else {
logNoPostHogProject(exp.slug);
}
return;
}
const dashboardUrl = getPostHogProjectDashboardUrl(posthogProject);
if (jsonFlag) {
printJsonOnlyOutput({ dashboardUrl });
return;
}
if (nonInteractive) {
Log.log(dashboardUrl);
return;
}
const failedMessage = `Unable to open a web browser. PostHog dashboard is available at: ${dashboardUrl}`;
const spinner = ora(`Opening ${dashboardUrl}`).start();
try {
const opened = await openBrowserAsync(dashboardUrl);
if (opened) {
spinner.succeed(`Opened ${dashboardUrl}`);
} else {
spinner.fail(failedMessage);
}
} catch (error) {
spinner.fail(failedMessage);
throw error;
}
}
}
@@ -0,0 +1,100 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { formatPostHogProject, logNoPostHogProject } from '../../../commandUtils/posthog';
import { PostHogMutation } from '../../../graphql/mutations/PostHogMutation';
import { PostHogQuery } from '../../../graphql/queries/PostHogQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { confirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsPostHogDisconnect extends EasCommand {
static override description =
'remove the PostHog project link for the current Expo app from EAS servers';
static override flags = {
...EasNonInteractiveAndJsonFlags,
yes: Flags.boolean({
char: 'y',
description: 'Skip confirmation prompt',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsPostHogDisconnect);
const { yes } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsPostHogDisconnect, {
nonInteractive,
withServerSideEnvironment: null,
});
const posthogProject = await PostHogQuery.getPostHogProjectByAppIdAsync(
graphqlClient,
projectId
);
if (!posthogProject) {
if (jsonFlag) {
printJsonOnlyOutput({ id: null });
} else {
logNoPostHogProject(exp.slug);
}
return;
}
if (!jsonFlag) {
Log.addNewLineIfNone();
Log.log(formatPostHogProject(posthogProject));
Log.newLine();
}
if (!nonInteractive && !yes) {
const confirmed = await confirmAsync({
message:
'Remove this PostHog project link from EAS servers? This does not delete the project on PostHog.',
});
if (!confirmed) {
Log.warn('Canceled removal of the PostHog project link.');
return;
}
} else if (!jsonFlag) {
Log.warn(
'Removing the PostHog project link from EAS servers. This does not delete the project on PostHog.'
);
}
const spinner = jsonFlag ? null : ora('Removing PostHog project link').start();
try {
await PostHogMutation.deletePostHogProjectAsync(graphqlClient, posthogProject.id);
spinner?.succeed(
`Removed PostHog project ${chalk.bold(posthogProject.posthogProjectName)} from EAS servers`
);
} catch (error) {
spinner?.fail('Failed to remove PostHog project link');
throw error;
}
if (jsonFlag) {
printJsonOnlyOutput({ id: posthogProject.id, name: posthogProject.posthogProjectName });
}
}
}
@@ -62,6 +62,11 @@ export default class SimulatorStart extends EasCommand {
description:
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
'max-duration-minutes': Flags.integer({
description:
'Maximum duration of the device run session in minutes before it is automatically stopped. Only customizable on paid plans. Defaults to a value derived from the job run priority when omitted.',
min: 0,
}),
force: Flags.boolean({
description:
'[default: true] Create a new device session even when an existing simulator session is present in the environment.',
@@ -125,6 +130,7 @@ export default class SimulatorStart extends EasCommand {
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
maxRunTimeMinutes: flags['max-duration-minutes'],
});
deviceRunSessionId = session.id;
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
@@ -0,0 +1,314 @@
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
import { DirectoryJSON, vol } from 'memfs';
import { instance, mock } from 'ts-mockito';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
import { PrivateProjectConfigContextField } from '../../../commandUtils/context/PrivateProjectConfigContextField';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { UpdateFragment } from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
import UpdateRepublish from '../republish';
import UpdateRollBackToEmbedded from '../roll-back-to-embedded';
import UpdateRollback from '../rollback';
const projectRoot = '/test-project';
const commandOptions = getMockOclifConfig({ root: projectRoot });
const updateStub: UpdateFragment = {
id: 'update-1234',
group: 'group-source',
branch: { id: 'branch-1234', name: 'main' },
message: 'source message',
runtimeVersion: 'exposdk:47.0.0',
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
codeSigningInfo: null,
createdAt: '2022-01-01T12:00:00Z',
};
jest.mock('fs');
jest.mock('@expo/config');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../graphql/queries/UpdateQuery');
describe(UpdateRollback.name, () => {
beforeEach(() => {
jest.spyOn(UpdateRepublish, 'run').mockResolvedValue(undefined as any);
jest.spyOn(UpdateRollBackToEmbedded, 'run').mockResolvedValue(undefined as any);
});
afterEach(() => {
vol.reset();
jest.restoreAllMocks();
jest.resetAllMocks();
});
it('errors in non-interactive mode when no group ID is provided', async () => {
const flags = ['--non-interactive'];
mockTestProject();
await expect(new UpdateRollback(flags, commandOptions).run()).rejects.toThrow(
'The update group ID argument is required in non-interactive mode.'
);
expect(UpdateRepublish.run).not.toHaveBeenCalled();
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
});
it('republishes the previous update group when the source group is the latest', async () => {
const flags = ['group-source', '--non-interactive'];
mockTestProject();
jest
.mocked(UpdateQuery.viewUpdateGroupAsync)
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
// Most-recent-first: the source group is the latest, the previous group follows.
jest
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
.mockResolvedValue([
[{ ...updateStub, group: 'group-source', message: 'source message' }],
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
]);
await new UpdateRollback(flags, commandOptions).run();
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
expect(UpdateRepublish.run).toHaveBeenCalledWith([
'--group',
'group-previous',
'--message',
'Roll back to "previous message" (group: group-previous)',
'--non-interactive',
'--platform',
'all',
]);
});
it('rolls back to embedded when the source group is the only update for its runtime version', async () => {
const flags = ['group-source', '--non-interactive'];
mockTestProject();
jest
.mocked(UpdateQuery.viewUpdateGroupAsync)
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
// Only the source group exists for this runtime version -> no previous group.
jest
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
.mockResolvedValue([[{ ...updateStub, group: 'group-source', message: 'source message' }]]);
await new UpdateRollback(flags, commandOptions).run();
expect(UpdateRepublish.run).not.toHaveBeenCalled();
expect(UpdateRollBackToEmbedded.run).toHaveBeenCalledWith([
'--branch',
'main',
'--runtime-version',
'exposdk:47.0.0',
'--message',
'Roll back to embedded',
'--non-interactive',
'--platform',
'all',
]);
});
it('forwards --message, --platform, and --private-key-path to the republish path', async () => {
const flags = [
'group-source',
'--non-interactive',
'--message',
'custom rollback message',
'--platform',
'ios',
'--private-key-path',
'./keys/private-key.pem',
];
mockTestProject();
jest
.mocked(UpdateQuery.viewUpdateGroupAsync)
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
jest
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
.mockResolvedValue([
[{ ...updateStub, group: 'group-source', message: 'source message' }],
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
]);
await new UpdateRollback(flags, commandOptions).run();
expect(UpdateRepublish.run).toHaveBeenCalledWith([
'--group',
'group-previous',
'--message',
'custom rollback message',
'--non-interactive',
'--platform',
'ios',
'--private-key-path',
'./keys/private-key.pem',
]);
});
it('forwards --json to the republish path', async () => {
const flags = ['group-source', '--json', '--non-interactive'];
mockTestProject();
jest
.mocked(UpdateQuery.viewUpdateGroupAsync)
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
jest
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
.mockResolvedValue([
[{ ...updateStub, group: 'group-source', message: 'source message' }],
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
]);
await new UpdateRollback(flags, commandOptions).run();
expect(UpdateRepublish.run).toHaveBeenCalledWith(expect.arrayContaining(['--json']));
});
it('forwards --platform and --private-key-path to the embedded rollback path', async () => {
const flags = [
'group-source',
'--non-interactive',
'--platform',
'ios',
'--private-key-path',
'./keys/private-key.pem',
];
mockTestProject();
jest
.mocked(UpdateQuery.viewUpdateGroupAsync)
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
// No previous group -> roll back to embedded.
jest
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
.mockResolvedValue([[{ ...updateStub, group: 'group-source', message: 'source message' }]]);
await new UpdateRollback(flags, commandOptions).run();
expect(UpdateRepublish.run).not.toHaveBeenCalled();
expect(UpdateRollBackToEmbedded.run).toHaveBeenCalledWith([
'--branch',
'main',
'--runtime-version',
'exposdk:47.0.0',
'--message',
'Roll back to embedded',
'--non-interactive',
'--platform',
'ios',
'--private-key-path',
'./keys/private-key.pem',
]);
});
it('errors when the source group is not the latest update for its runtime version', async () => {
const flags = ['group-source', '--non-interactive'];
mockTestProject();
jest
.mocked(UpdateQuery.viewUpdateGroupAsync)
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
// A newer group is the latest for this runtime version, so the source group is stale.
jest
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
.mockResolvedValue([
[{ ...updateStub, group: 'group-newer', message: 'newer message' }],
[{ ...updateStub, group: 'group-source', message: 'source message' }],
]);
await expect(new UpdateRollback(flags, commandOptions).run()).rejects.toThrow(
'Update group "group-source" is not the latest update on branch "main" for runtime version "exposdk:47.0.0" (the latest is "group-newer"). Only the latest update can be rolled back.'
);
expect(UpdateRepublish.run).not.toHaveBeenCalled();
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
});
});
function mockTestProject({
configuredProjectId = '1234',
extraManifest,
extraVol,
}: {
configuredProjectId?: string;
extraManifest?: Partial<ExpoConfig>;
extraVol?: DirectoryJSON;
} = {}): void {
const packageJSON: PackageJSONConfig = {
name: 'testing123',
version: '0.1.0',
description: 'fake description',
main: 'index.js',
};
const appJSON: AppJSONConfig = {
expo: {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '33.0.0',
owner: jester.accounts[0].name,
extra: {
eas: {
projectId: configuredProjectId,
},
},
...extraManifest,
},
};
vol.fromJSON(
{
'package.json': JSON.stringify(packageJSON),
'app.json': JSON.stringify(appJSON),
...extraVol,
},
projectRoot
);
const mockManifest = { exp: appJSON.expo };
const graphqlClient = instance(mock<ExpoGraphqlClient>({}));
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
jest.spyOn(PrivateProjectConfigContextField.prototype, 'getValueAsync').mockResolvedValue({
exp: mockManifest.exp,
projectDir: projectRoot,
projectId: configuredProjectId,
});
jest.spyOn(LoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({
actor: jester,
featureGating: new FeatureGating({}, new FeatureGateEnvOverrides()),
graphqlClient,
authenticationInfo: { accessToken: null, sessionSecret: '1234' },
});
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
}
@@ -7,7 +7,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import UpdateView from '../view';
jest.mock('../../../graphql/queries/UpdateQuery', () => ({
UpdateQuery: { viewUpdateGroupAsync: jest.fn() },
UpdateQuery: { viewUpdateGroupAsync: jest.fn(), viewByUpdateAsync: jest.fn() },
}));
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
@@ -16,6 +16,7 @@ jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewUpdateGroupAsync = jest.mocked(UpdateQuery.viewUpdateGroupAsync);
const mockViewByUpdateAsync = jest.mocked(UpdateQuery.viewByUpdateAsync);
const mockViewUpdateGroupInsightsAsync = jest.mocked(
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
);
@@ -155,4 +156,42 @@ describe(UpdateView, () => {
expect(arg.insights).toBeDefined();
expect(arg.insights.platforms[0].totals.installs).toBe(990);
});
it('does not look up a single update when the ID resolves as a group', async () => {
const command = createCommand(['group-1']);
await command.runAsync();
expect(mockViewUpdateGroupAsync).toHaveBeenCalledWith(graphqlClient, { groupId: 'group-1' });
expect(mockViewByUpdateAsync).not.toHaveBeenCalled();
});
it('resolves a platform-specific update ID to its group', async () => {
mockViewUpdateGroupAsync.mockReset();
mockViewUpdateGroupAsync
.mockRejectedValueOnce(new Error('Could not find any updates with group ID: "u1"'))
.mockResolvedValueOnce(updateGroup);
mockViewByUpdateAsync.mockResolvedValue({ ...updateGroup[0], group: 'group-1' });
const command = createCommand(['u1', '--insights']);
await command.runAsync();
expect(mockViewByUpdateAsync).toHaveBeenCalledWith(graphqlClient, { updateId: 'u1' });
expect(mockViewUpdateGroupAsync).toHaveBeenLastCalledWith(graphqlClient, {
groupId: 'group-1',
});
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ groupId: 'group-1' })
);
});
it('surfaces the original group lookup error when the ID matches neither a group nor an update', async () => {
mockViewUpdateGroupAsync.mockReset();
mockViewUpdateGroupAsync.mockRejectedValue(
new Error('Could not find any updates with group ID: "bogus"')
);
mockViewByUpdateAsync.mockRejectedValue(new Error('update not found'));
const command = createCommand(['bogus']);
await expect(command.runAsync()).rejects.toThrow(/Could not find any updates with group ID/);
});
});
@@ -0,0 +1,130 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EmbeddedUpdateMutation } from '../../../../graphql/mutations/EmbeddedUpdateMutation';
import Log from '../../../../log';
import * as prompts from '../../../../prompts';
import * as json from '../../../../utils/json';
import UpdateEmbeddedDelete from '../delete';
jest.mock('../../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
EmbeddedUpdateMutation: { deleteEmbeddedUpdateAsync: jest.fn() },
}));
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../prompts');
const mockDelete = jest.mocked(EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync);
const mockToggleConfirm = jest.mocked(prompts.toggleConfirmAsync);
const mockLogLog = jest.mocked(Log.log);
const mockLogWithTick = jest.mocked(Log.withTick);
const mockLogError = jest.mocked(Log.error);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
const MOCK_CONTEXT = {
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
describe(UpdateEmbeddedDelete, () => {
const mockConfig = getMockOclifConfig();
let processExitSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
mockDelete.mockResolvedValue({ id: VALID_UUID });
mockToggleConfirm.mockResolvedValue(true);
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as never);
});
afterEach(() => {
processExitSpy.mockRestore();
});
function createCommand(argv: string[]): UpdateEmbeddedDelete {
const command = new UpdateEmbeddedDelete(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('deletes when the user confirms', async () => {
await createCommand([VALID_UUID]).run();
expect(mockToggleConfirm).toHaveBeenCalledTimes(1);
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
});
it('skips confirmation in non-interactive mode', async () => {
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockToggleConfirm).not.toHaveBeenCalled();
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
});
it('aborts (process.exit) without calling the mutation when the user declines', async () => {
mockToggleConfirm.mockResolvedValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
expect(mockDelete).not.toHaveBeenCalled();
expect(mockLogWithTick).not.toHaveBeenCalled();
});
it('--json prints { id } and skips the human-readable success line', async () => {
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
expect(mockLogWithTick).not.toHaveBeenCalled();
});
it('rethrows unexpected mutation errors', async () => {
const boom = new Error('boom');
mockDelete.mockRejectedValue(boom);
await expect(createCommand([VALID_UUID, '--non-interactive']).run()).rejects.toThrow('boom');
});
it('succeeds for an unknown id (server best-effort)', async () => {
// Server returns { id } even when nothing was deleted, so we should still tick.
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
});
it('prints an explanatory message before prompting (interactive)', async () => {
await createCommand([VALID_UUID]).run();
const lines = mockLogLog.mock.calls.map(c => String(c[0])).join('\n');
expect(lines).toContain(`permanently delete embedded update: "${VALID_UUID}"`);
expect(lines).toContain('Diff patches already generated');
expect(lines).toContain('re-upload');
});
it('logs a cancel message when the user declines', async () => {
mockToggleConfirm.mockResolvedValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
expect(mockLogError).toHaveBeenCalledWith(
`Canceled deletion of embedded update: "${VALID_UUID}".`
);
});
it('passes the id through to deleteEmbeddedUpdateAsync', async () => {
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
});
it('--json + --non-interactive: skips prompt, skips tick, prints { id }', async () => {
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
expect(mockToggleConfirm).not.toHaveBeenCalled();
expect(mockLogWithTick).not.toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
});
});
@@ -0,0 +1,280 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../../graphql/generated';
import { ChannelQuery } from '../../../../graphql/queries/ChannelQuery';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../../log';
import { selectAsync } from '../../../../prompts';
import * as json from '../../../../utils/json';
import UpdateEmbeddedList from '../list';
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
EmbeddedUpdateQuery: { viewPaginatedAsync: jest.fn() },
}));
jest.mock('../../../../graphql/queries/ChannelQuery', () => ({
ChannelQuery: { viewUpdateChannelsOnAppAsync: jest.fn() },
}));
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
const mockPaginated = jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync);
const mockViewChannels = jest.mocked(ChannelQuery.viewUpdateChannelsOnAppAsync);
const mockSelectAsync = jest.mocked(selectAsync);
const mockLogLog = jest.mocked(Log.log);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const MOCK_CONTEXT = {
projectId: 'project-123',
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
const ROW_A: EmbeddedUpdateFragment = {
id: 'aaaaaaaa-1111-4000-8000-000000000001',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2026-05-29T00:00:00Z',
launchAsset: { id: 'asset-a', fileSize: 1024, finalFileSize: 768, fileSHA256: 'abc123' },
};
const ROW_B: EmbeddedUpdateFragment = {
id: 'bbbbbbbb-2222-4000-8000-000000000002',
platform: AppPlatform.Android,
runtimeVersion: '1.0.1',
channel: 'preview',
createdAt: '2026-05-30T00:00:00Z',
launchAsset: { id: 'asset-b', fileSize: 2048, finalFileSize: null, fileSHA256: 'def456' },
};
function emptyConnection(): {
edges: { cursor: string; node: EmbeddedUpdateFragment }[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
} {
return {
edges: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null },
};
}
describe(UpdateEmbeddedList, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
// Default: no channels on the project (skips the prompt).
mockViewChannels.mockResolvedValue([]);
});
function createCommand(argv: string[]): UpdateEmbeddedList {
const command = new UpdateEmbeddedList(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('prints each row when results exist', async () => {
mockPaginated.mockResolvedValue({
edges: [
{ cursor: 'c1', node: ROW_A },
{ cursor: 'c2', node: ROW_B },
],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c2' },
});
await createCommand(['--non-interactive']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_A.id))).toBe(true);
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_B.id))).toBe(true);
});
it('prints empty message when no results', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive']).run();
expect(mockLogLog).toHaveBeenCalledWith('No embedded updates found.');
});
it('--json prints connection payload and skips formatted output', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--json', '--non-interactive']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({
embeddedUpdates: [ROW_A],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
expect(mockLogLog.mock.calls.every(c => !String(c[0]).includes(ROW_A.id))).toBe(true);
});
it('passes filter when flags supplied', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([
'--non-interactive',
'--platform',
'ios',
'--runtime-version',
'1.2.0',
'--channel',
'preview',
]).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.2.0', channel: 'preview' },
first: 25,
after: undefined,
});
});
it('shows next-page hint when hasNextPage', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('--after-cursor c1'))).toBe(true);
});
it('prompts for channel in interactive mode and applies the selected channel', async () => {
mockViewChannels.mockResolvedValue([
{ id: 'ch1', name: 'production' } as any,
{ id: 'ch2', name: 'preview' } as any,
]);
mockSelectAsync.mockResolvedValue('preview');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: { platform: undefined, runtimeVersion: undefined, channel: 'preview' },
first: 25,
after: undefined,
});
});
it('skips channel filter when "All channels" is selected', async () => {
mockViewChannels.mockResolvedValue([{ id: 'ch1', name: 'production' } as any]);
// The sentinel value returned by selectAsync for the "All channels" option.
mockSelectAsync.mockResolvedValue('__embedded_update_list__all_channels__');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
});
it('skips the channel prompt when --channel is supplied', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--channel', 'preview']).run();
expect(mockSelectAsync).not.toHaveBeenCalled();
expect(mockViewChannels).not.toHaveBeenCalled();
});
it('treats --channel all as no channel filter', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--channel', 'all']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
});
it('skips the channel prompt when the project has no channels', async () => {
mockViewChannels.mockResolvedValue([]);
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockSelectAsync).not.toHaveBeenCalled();
});
it('passes --after-cursor through to viewPaginatedAsync', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive', '--after-cursor', 'cursor-123']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: 'cursor-123',
});
});
it('honors --limit', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive', '--limit', '5']).run();
expect(mockPaginated).toHaveBeenCalledWith(
MOCK_CONTEXT.loggedIn.graphqlClient,
expect.objectContaining({ first: 5 })
);
});
it('prints the section header with row count', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1)'))).toBe(
true
);
});
it('suffixes the section header count with "+" when hasNextPage', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1+)'))).toBe(
true
);
});
it('shows relative-time "ago" hints in formatted rows', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).toBe(true);
});
it('puts "All channels" first in the prompt, then channels in order', async () => {
mockViewChannels.mockResolvedValue([
{ id: 'ch1', name: 'production' } as any,
{ id: 'ch2', name: 'preview' } as any,
]);
mockSelectAsync.mockResolvedValue('production');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
const [, choices] = mockSelectAsync.mock.calls[0];
const titles = (choices as any[]).map(c => c.title);
expect(titles).toEqual(['All channels', 'production', 'preview']);
});
});
@@ -0,0 +1,144 @@
import { CombinedError } from '@urql/core';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../../graphql/generated';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
isEmbeddedUpdateNotFoundError,
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../../log';
import * as json from '../../../../utils/json';
import UpdateEmbeddedView from '../view';
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
EmbeddedUpdateQuery: { viewByIdAsync: jest.fn() },
isEmbeddedUpdateNotFoundError: jest.fn(),
}));
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
const mockView = jest.mocked(EmbeddedUpdateQuery.viewByIdAsync);
const mockIsNotFound = jest.mocked(isEmbeddedUpdateNotFoundError);
const mockLogLog = jest.mocked(Log.log);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
const MOCK_CONTEXT = {
projectId: 'project-123',
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
const MOCK_EMBEDDED_UPDATE: EmbeddedUpdateFragment = {
id: VALID_UUID,
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2026-05-29T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: 768,
fileSHA256: 'abc123',
},
};
describe(UpdateEmbeddedView, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockView.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
mockIsNotFound.mockReturnValue(false);
});
function createCommand(argv: string[]): UpdateEmbeddedView {
const command = new UpdateEmbeddedView(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('prints formatted details on success', async () => {
await createCommand([VALID_UUID]).run();
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
embeddedUpdateId: VALID_UUID,
appId: MOCK_CONTEXT.projectId,
});
// Header line + body line
expect(mockLogLog).toHaveBeenCalledTimes(2);
expect(mockLogLog.mock.calls[0][0]).toContain('Embedded update');
expect(mockLogLog.mock.calls[1][0]).toContain(VALID_UUID);
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle size');
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle SHA-256');
});
it('--json prints the raw embedded update and skips formatted output', async () => {
await createCommand([VALID_UUID, '--json']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith(MOCK_EMBEDDED_UPDATE);
expect(mockLogLog).not.toHaveBeenCalled();
});
it('exits with a friendly message when the server returns NOT_FOUND', async () => {
const notFound = new CombinedError({ graphQLErrors: [] });
mockView.mockRejectedValue(notFound);
mockIsNotFound.mockReturnValue(true);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow();
expect(mockIsNotFound).toHaveBeenCalledWith(notFound);
});
it('rethrows unexpected errors', async () => {
const boom = new Error('boom');
mockView.mockRejectedValue(boom);
mockIsNotFound.mockReturnValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('boom');
});
it('renders bundle size from finalFileSize when present', async () => {
mockView.mockResolvedValue({
...MOCK_EMBEDDED_UPDATE,
launchAsset: { id: 'asset-x', fileSize: 2048, finalFileSize: 768, fileSHA256: 'abc' },
});
await createCommand([VALID_UUID]).run();
// 768 B; not 2.0 KB (which would be fileSize).
expect(mockLogLog.mock.calls[1][0]).toContain('768 B');
});
it('falls back to fileSize when finalFileSize is null', async () => {
mockView.mockResolvedValue({
...MOCK_EMBEDDED_UPDATE,
launchAsset: { id: 'asset-y', fileSize: 2048, finalFileSize: null, fileSHA256: 'abc' },
});
await createCommand([VALID_UUID]).run();
expect(mockLogLog.mock.calls[1][0]).toContain('2.0 KB');
});
it('shows runtime version, channel, and SHA-256 in the body', async () => {
await createCommand([VALID_UUID]).run();
const body = String(mockLogLog.mock.calls[1][0]);
expect(body).toContain('1.0.0');
expect(body).toContain('production');
expect(body).toContain('abc123');
});
it('shows a relative-time hint in the Created at row', async () => {
await createCommand([VALID_UUID]).run();
expect(mockLogLog.mock.calls[1][0]).toMatch(/\(.+ago\)/);
});
it('passes the project id and uuid through to viewByIdAsync', async () => {
await createCommand([VALID_UUID]).run();
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
embeddedUpdateId: VALID_UUID,
appId: MOCK_CONTEXT.projectId,
});
});
});
@@ -0,0 +1,72 @@
import { Args } from '@oclif/core';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { EmbeddedUpdateMutation } from '../../../graphql/mutations/EmbeddedUpdateMutation';
import Log from '../../../log';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class UpdateEmbeddedDelete extends EasCommand {
static override description = 'delete an embedded update registered with EAS Update';
static override args = {
id: Args.string({
required: true,
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { id: embeddedUpdateId },
flags,
} = await this.parse(UpdateEmbeddedDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedDelete, { nonInteractive });
if (jsonFlag) {
enableJsonOutput();
}
if (!nonInteractive) {
Log.log(
`You are about to permanently delete embedded update: "${embeddedUpdateId}". ` +
`Diff patches already generated against this bundle keep serving, but new diffs ` +
`can't be generated until you re-upload it.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of embedded update: "${embeddedUpdateId}".`);
process.exit(1);
}
}
// Best-effort delete on the server: deleting an unknown id succeeds (idempotent),
// so we don't need a not-found branch here.
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(graphqlClient, {
id: embeddedUpdateId,
});
if (jsonFlag) {
printJsonOnlyOutput({ id: embeddedUpdateId });
return;
}
Log.withTick(`Deleted embedded update ${embeddedUpdateId}`);
}
}
@@ -0,0 +1,172 @@
import { Platform } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
import { AppPlatform } from '../../../graphql/generated';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
} from '../../../graphql/queries/EmbeddedUpdateQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import { selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
import formatFields from '../../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
const DEFAULT_LIMIT = 25;
const MAX_LIMIT = 50;
const CHANNELS_LIMIT = 50;
export default class UpdateEmbeddedList extends EasCommand {
static override description = 'list embedded updates registered with EAS Update for this project';
static override flags = {
platform: Flags.option({
char: 'p',
description: 'Filter by platform',
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
'runtime-version': Flags.string({
description: 'Filter by runtime version',
}),
channel: Flags.string({
description: 'Filter by channel name (pass "all" to skip the channel prompt)',
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
'after-cursor': Flags.string({
description: 'Return items after this cursor (for pagination)',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateEmbeddedList);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedList, { nonInteractive });
if (jsonFlag) {
enableJsonOutput();
}
const platform: AppPlatform | undefined = flags.platform
? toAppPlatform(flags.platform)
: undefined;
// Resolve channel filter:
// - `--channel <name>`: use it
// - `--channel all` (or no flag in non-interactive / json): no channel filter
// - no flag in interactive: prompt with the project's channels + "All channels"
let channel: string | undefined;
if (flags.channel) {
channel = flags.channel.toLowerCase() === 'all' ? undefined : flags.channel;
} else if (!nonInteractive && !jsonFlag) {
channel = await promptForChannelAsync(graphqlClient, projectId);
}
const filter =
platform || flags['runtime-version'] || channel
? {
platform,
runtimeVersion: flags['runtime-version'],
channel,
}
: undefined;
const limit = flags.limit ?? DEFAULT_LIMIT;
const connection = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, {
appId: projectId,
filter,
first: limit,
after: flags['after-cursor'],
});
const embeddedUpdates = connection.edges.map(e => e.node);
if (jsonFlag) {
printJsonOnlyOutput({
embeddedUpdates,
pageInfo: connection.pageInfo,
});
return;
}
if (embeddedUpdates.length === 0) {
Log.log('No embedded updates found.');
return;
}
Log.addNewLineIfNone();
Log.log(
chalk.bold(
`Embedded updates (${embeddedUpdates.length}${connection.pageInfo.hasNextPage ? '+' : ''}):`
)
);
Log.newLine();
Log.log(embeddedUpdates.map(formatEmbeddedUpdateRow).join(`\n\n${chalk.dim('———')}\n\n`));
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
Log.newLine();
Log.log(
chalk.dim(
`Showing ${embeddedUpdates.length}. For the next page, run with --after-cursor ${connection.pageInfo.endCursor}`
)
);
}
}
}
// Sentinel for the "All channels" option. We can't use `undefined` here because
// the underlying `prompts` library substitutes a choice's index when its value
// is undefined, which then leaks into the GraphQL filter.
const ALL_CHANNELS = '__embedded_update_list__all_channels__';
async function promptForChannelAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<string | undefined> {
const channels = await ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: CHANNELS_LIMIT,
});
if (channels.length === 0) {
// Nothing to choose from — fall back to listing everything.
return undefined;
}
const selected = await selectAsync<string>('Filter embedded updates by which channel?', [
{ title: 'All channels', value: ALL_CHANNELS },
...channels.map(c => ({ title: c.name, value: c.name })),
]);
return selected === ALL_CHANNELS ? undefined : selected;
}
function formatEmbeddedUpdateRow(embeddedUpdate: EmbeddedUpdateFragment): string {
const createdAt = new Date(embeddedUpdate.createdAt);
return formatFields([
{ label: 'ID', value: embeddedUpdate.id },
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
{ label: 'Channel', value: embeddedUpdate.channel },
{ label: 'Created', value: `${fromNow(createdAt)} ago` },
]);
}
@@ -0,0 +1,95 @@
import { Args, Errors } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../../commandUtils/flags';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
isEmbeddedUpdateNotFoundError,
} from '../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../log';
import { fromNow } from '../../../utils/date';
import { formatBytes } from '../../../utils/files';
import formatFields from '../../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class UpdateEmbeddedView extends EasCommand {
static override description = 'view details of an embedded update registered with EAS Update';
static override args = {
id: Args.string({
required: true,
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
}),
};
static override flags = {
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { id: embeddedUpdateId },
flags: { json: jsonFlag },
} = await this.parse(UpdateEmbeddedView);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedView, { nonInteractive: true });
if (jsonFlag) {
enableJsonOutput();
}
let embeddedUpdate;
try {
embeddedUpdate = await EmbeddedUpdateQuery.viewByIdAsync(graphqlClient, {
embeddedUpdateId,
appId: projectId,
});
} catch (e: unknown) {
if (isEmbeddedUpdateNotFoundError(e)) {
Errors.error(
`No embedded update found with id "${embeddedUpdateId}" for this project. ` +
`Run "eas update:embedded:list" to see the embedded updates registered for this app.`,
{ exit: 1 }
);
}
throw e;
}
if (jsonFlag) {
printJsonOnlyOutput(embeddedUpdate);
return;
}
Log.addNewLineIfNone();
Log.log(chalk.bold('Embedded update:'));
Log.log(formatEmbeddedUpdate(embeddedUpdate));
}
}
export function formatEmbeddedUpdate(embeddedUpdate: EmbeddedUpdateFragment): string {
const createdAt = new Date(embeddedUpdate.createdAt);
const bundleSize =
embeddedUpdate.launchAsset.finalFileSize ?? embeddedUpdate.launchAsset.fileSize;
return formatFields([
{ label: 'ID', value: embeddedUpdate.id },
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
{ label: 'Channel', value: embeddedUpdate.channel },
{ label: 'Bundle size', value: formatBytes(bundleSize) },
{ label: 'Bundle SHA-256', value: embeddedUpdate.launchAsset.fileSHA256 },
{
label: 'Created at',
value: `${createdAt.toLocaleString()} (${fromNow(createdAt)} ago)`,
},
]);
}
+169 -22
View File
@@ -1,41 +1,188 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import UpdateRepublish from './republish';
import UpdateRollBackToEmbedded from './roll-back-to-embedded';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import { promptAsync } from '../../prompts';
export default class UpdateRollback extends EasCommand {
static override description =
'Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".';
const defaultRollbackPlatforms = ['android', 'ios'] as const;
static override flags = {
'private-key-path': Flags.string({
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
type SourceUpdateGroup = {
groupId: string;
branchName: string;
runtimeVersion: string;
};
type PreviousUpdateGroup = {
groupId: string;
message: string | null;
};
export default class UpdateRollback extends EasCommand {
static override description = 'roll back to an embedded update or an existing update';
static override args = {
groupId: Args.string({
description:
'The ID of the update group to roll back. Must be the latest update for its branch and runtime version. The update group published before it is republished; if there is none, a roll back to the embedded update is published. Required in non-interactive mode.',
required: false,
}),
};
static override flags = {
message: Flags.string({
char: 'm',
description: 'Short message describing the rollback update',
required: false,
}),
platform: Flags.option({
char: 'p',
options: [...defaultRollbackPlatforms, 'all'] as const,
default: 'all',
required: false,
})(),
'private-key-path': Flags.string({
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateRollback);
const { choice } = await promptAsync({
type: 'select',
message: 'Which type of update would you like to roll back to?',
name: 'choice',
choices: [
{ title: 'Published Update', value: 'published' },
{ title: 'Embedded Update', value: 'embedded' },
],
});
const { args, flags } = await this.parse(UpdateRollback);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const groupId = args.groupId;
const platform = flags.platform;
const messageArg = flags.message;
const privateKeyPathArg = flags['private-key-path']
? ['--private-key-path', flags['private-key-path']]
: [];
if (choice === 'published') {
await UpdateRepublish.run(privateKeyPathArg);
if (!groupId) {
if (nonInteractive) {
throw new Error('The update group ID argument is required in non-interactive mode.');
}
const { choice } = await promptAsync({
type: 'select',
message: 'Which type of update would you like to roll back to?',
name: 'choice',
choices: [
{ title: 'Published Update', value: 'published' },
{ title: 'Embedded Update', value: 'embedded' },
],
});
if (choice === 'published') {
await UpdateRepublish.run(privateKeyPathArg);
} else {
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
}
return;
}
const {
loggedIn: { graphqlClient },
privateProjectConfig: { projectId },
} = await this.getContextAsync(UpdateRollback, {
nonInteractive,
withServerSideEnvironment: null,
});
const sourceGroup = await getSourceUpdateGroupAsync(graphqlClient, groupId);
const previousGroup = await getPreviousUpdateGroupAsync(graphqlClient, projectId, sourceGroup);
const commonArgs = [
'--non-interactive',
'--platform',
platform,
...privateKeyPathArg,
...(json ? ['--json'] : []),
];
if (previousGroup) {
const message =
messageArg ??
`Roll back to "${previousGroup.message ?? ''}" (group: ${previousGroup.groupId})`;
await UpdateRepublish.run([
'--group',
previousGroup.groupId,
'--message',
message,
...commonArgs,
]);
} else {
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
const message = messageArg ?? 'Roll back to embedded';
await UpdateRollBackToEmbedded.run([
'--branch',
sourceGroup.branchName,
'--runtime-version',
sourceGroup.runtimeVersion,
'--message',
message,
...commonArgs,
]);
}
}
}
async function getSourceUpdateGroupAsync(
graphqlClient: ExpoGraphqlClient,
groupId: string
): Promise<SourceUpdateGroup> {
// viewUpdateGroupAsync throws if no updates are found for the group ID.
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
const arbitraryUpdate = updateGroup[0];
return {
groupId,
branchName: arbitraryUpdate.branch.name,
runtimeVersion: arbitraryUpdate.runtimeVersion,
};
}
async function getPreviousUpdateGroupAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
sourceGroup: SourceUpdateGroup
): Promise<PreviousUpdateGroup | null> {
// Clients on a given runtime version are served the latest update group on the branch,
// so a rollback is only meaningful when the source group is that latest group. Fetch the
// two most recent groups for the runtime version (returned most-recent-first): the first
// must be the source group, and the second (if any) is the update to roll back to.
const latestGroups = await UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync(graphqlClient, {
appId: projectId,
branchName: sourceGroup.branchName,
first: 2,
filter: { runtimeVersions: [sourceGroup.runtimeVersion] },
});
const latestGroup = latestGroups[0];
if (!latestGroup?.length || latestGroup[0].group !== sourceGroup.groupId) {
throw new Error(
`Update group "${sourceGroup.groupId}" is not the latest update on branch "${
sourceGroup.branchName
}" for runtime version "${sourceGroup.runtimeVersion}"${
latestGroup?.length ? ` (the latest is "${latestGroup[0].group}")` : ''
}. Only the latest update can be rolled back.`
);
}
// Source group is the only update for this runtime version: roll back to embedded.
const previousGroup = latestGroups[1];
if (!previousGroup?.length) {
return null;
}
return { groupId: previousGroup[0].group, message: previousGroup[0].message ?? null };
}
+38 -3
View File
@@ -3,6 +3,8 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { UpdateFragment } from '../../graphql/generated';
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
@@ -26,7 +28,7 @@ export default class UpdateView extends EasCommand {
static override args = {
groupId: Args.string({
required: true,
description: 'The ID of an update group.',
description: 'The ID of an update group, or the ID of a platform-specific update.',
}),
};
@@ -58,7 +60,7 @@ export default class UpdateView extends EasCommand {
async runAsync(): Promise<void> {
const {
args: { groupId },
args: { groupId: idArg },
flags: { json: jsonFlag, insights: insightsFlag, days, start, end },
} = await this.parse(UpdateView);
@@ -74,7 +76,10 @@ export default class UpdateView extends EasCommand {
enableJsonOutput();
}
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
const { groupId, updatesByGroup } = await UpdateView.resolveUpdateGroupAsync(
graphqlClient,
idArg
);
let insightsSummary: UpdateInsightsSummary | null = null;
if (insightsFlag) {
@@ -112,4 +117,34 @@ export default class UpdateView extends EasCommand {
}
}
}
/**
* Resolves the provided ID into an update group and its updates. The ID may be either an
* update group ID or the ID of a single platform-specific update. We first try to look it up
* as a group; if no updates are found, we fall back to resolving it as a platform-specific
* update and then fetch the group that update belongs to.
*/
private static async resolveUpdateGroupAsync(
graphqlClient: ExpoGraphqlClient,
id: string
): Promise<{ groupId: string; updatesByGroup: UpdateFragment[] }> {
try {
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId: id });
return { groupId: id, updatesByGroup };
} catch (groupError) {
let update: UpdateFragment | undefined;
try {
update = await UpdateQuery.viewByUpdateAsync(graphqlClient, { updateId: id });
} catch {
// The ID is neither a valid update group nor a valid platform-specific update; surface
// the original group lookup error since the group ID is the primary input.
throw groupError;
}
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
groupId: update.group,
});
return { groupId: update.group, updatesByGroup };
}
}
}
@@ -5,7 +5,9 @@ import path from 'path';
import { UserRole } from '@expo/apple-utils';
import { formatAppleTeam } from './AppleTeamFormatting';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log, { learnMore } from '../../../log';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
@@ -14,7 +16,11 @@ import {
getCredentialsFromUserAsync,
shouldAutoGenerateCredentialsAsync,
} from '../../utils/promptForCredentials';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AscApiKey } from '../appstore/Credentials.types';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import {
AscApiKeyPath,
MinimalAscApiKey,
@@ -277,3 +283,78 @@ export function formatAscApiKey(ascApiKey: AppStoreConnectApiKeyFragment): strin
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago`);
return line;
}
export async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}
/**
* Best-effort helper that populates `ctx.appStore.authCtx` in non-interactive mode
* by loading an App Store Connect API key (from env vars, or by fetching the app's
* stored key from the www GraphQL API).
*
* Returns true if `ctx.appStore.authCtx` is set after the call, false otherwise.
* Never throws.
*/
export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
ctx: CredentialsContext,
app: AppLookupParams,
teamType: AppleTeamType
): Promise<boolean> {
if (ctx.appStore.authCtx) {
return true;
}
try {
if (hasAscEnvVars()) {
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
teamType,
});
return !!ctx.appStore.authCtx;
}
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
graphqlClient: ctx.graphqlClient,
app,
});
if (!resolvedKey) {
return false;
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
teamId: resolvedKey.teamId,
teamName: resolvedKey.teamName,
teamType,
});
return !!ctx.appStore.authCtx;
} catch (err: any) {
Log.warn(
`Failed to authenticate with the App Store Connect API key from EAS credentials service: ${err.message ?? err}`
);
return false;
}
}
@@ -16,7 +16,7 @@ import {
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
import { AuthCtx } from '../appstore/authenticateTypes';
import { Target } from '../types';
export class ConfigureProvisioningProfile {
@@ -34,12 +34,9 @@ export class ConfigureProvisioningProfile {
throw new ForbidCredentialModificationError(
'Remove the --freeze-credentials flag to configure a Provisioning Profile.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
@@ -14,7 +14,7 @@ import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
import { AuthCtx } from '../appstore/authenticateTypes';
import { provisioningProfileSchema } from '../credentials';
import { Target } from '../types';
@@ -30,12 +30,9 @@ export class CreateProvisioningProfile {
throw new ForbidCredentialModificationError(
'Run this command again without the --freeze-credentials flag in order to generate a new Provisioning Profile.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
@@ -11,6 +11,7 @@ import {
filterDevicesForApplePlatform,
formatDeviceLabel,
} from './DeviceUtils';
import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils';
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
import {
@@ -22,8 +23,6 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import {
@@ -35,13 +34,11 @@ import {
import differenceBy from '../../../utils/expodash/differenceBy';
import { CredentialsContext } from '../../context';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { ApplePlatform } from '../appstore/constants';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import { MinimalAscApiKey } from '../credentials';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
@@ -424,6 +421,7 @@ export class SetUpAdhocProvisioningProfile {
);
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
@@ -436,35 +434,6 @@ export class SetUpAdhocProvisioningProfile {
}
}
async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}
export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
const setA = new Set(udidsA);
const setB = new Set(udidsB);
@@ -1,5 +1,6 @@
import nullthrows from 'nullthrows';
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils';
import {
assignBuildCredentialsAsync,
getBuildCredentialsAsync,
@@ -15,7 +16,7 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { learnMore } from '../../../log';
import Log, { learnMore } from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import { confirmAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
@@ -25,7 +26,8 @@ import {
} from '../../errors';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
import { AuthenticationMode } from '../appstore/authenticateTypes';
import { resolveAppleTeamTypeFromEnvironment } from '../appstore/resolveCredentials';
import { AppleTeamType } from '../appstore/authenticateTypes';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
@@ -102,8 +104,28 @@ export class SetUpProvisioningProfile {
this.app,
this.distributionType
).runAsync(ctx);
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
ctx,
this.app,
this.resolveTeamTypeForAuthentication()
);
}
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
let areBuildCredentialsSetup: boolean;
try {
areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
} catch (error: any) {
if (ctx.nonInteractive) {
Log.warn(
'Skipping Provisioning Profile validation on Apple servers due to an unexpected validation error. Continuing with local validation result.'
);
Log.debug('Provisioning profile validation on Apple servers failed:', error);
areBuildCredentialsSetup = true;
} else {
throw error;
}
}
if (areBuildCredentialsSetup) {
return nullthrows(await getBuildCredentialsAsync(ctx, this.app, this.distributionType));
}
@@ -111,12 +133,11 @@ export class SetUpProvisioningProfile {
throw new ForbidCredentialModificationError(
'Provisioning profile is not configured correctly. Remove the --freeze-credentials flag to configure it.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
}
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
@@ -167,6 +188,24 @@ export class SetUpProvisioningProfile {
return updatedProfile;
}
/**
* The team type determines `team.inHouse`, which in turn selects the Apple profile
* type used for every subsequent profile lookup and creation (IOS_APP_INHOUSE for
* enterprise vs IOS_APP_STORE otherwise). We derive it from the distribution
* type, which is exactly what the requested operation needs: enterprise
* builds require an in-house team, other distribution types don't.
* A genuine team/distribution mismatch is rejected by Apple regardless of this value.
*/
private getDerivedTeamTypeForAuthentication(): AppleTeamType {
return this.distributionType === IosDistributionType.Enterprise
? AppleTeamType.IN_HOUSE
: AppleTeamType.COMPANY_OR_ORGANIZATION;
}
private resolveTeamTypeForAuthentication(): AppleTeamType {
return resolveAppleTeamTypeFromEnvironment() ?? this.getDerivedTeamTypeForAuthentication();
}
private getCurrentProfileStoreInfo(
profiles: ProvisioningProfileStoreInfo[],
currentProfile: AppleProvisioningProfileFragment
@@ -19,8 +19,9 @@ import {
ForbidCredentialModificationError,
InsufficientAuthenticationNonInteractiveError,
} from '../../../errors';
import { AuthenticationMode } from '../../appstore/authenticateTypes';
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
import { validateProvisioningProfileAsync } from '../../validators/validateProvisioningProfile';
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from '../AscApiKeyUtils';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { SetUpProvisioningProfile } from '../SetUpProvisioningProfile';
@@ -30,11 +31,15 @@ jest.mock('../SetUpDistributionCertificate');
jest.mock('../ConfigureProvisioningProfile');
jest.mock('../CreateProvisioningProfile');
jest.mock('../../validators/validateProvisioningProfile');
jest.mock('../AscApiKeyUtils');
jest.mock('../../../../graphql/queries/AppQuery');
describe('SetUpProvisioningProfile', () => {
beforeEach(() => {
delete process.env.EXPO_APPLE_TEAM_TYPE;
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockReset();
jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockResolvedValue(false);
});
const testCases = ['NON_INTERACTIVE', 'INTERACTIVE'];
@@ -262,4 +267,270 @@ describe('SetUpProvisioningProfile', () => {
// expect provisioning profile not to be deleted on expo servers
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('auto-authenticates before validation in nonInteractive mode when credentials are already valid', async () => {
let authAttemptedBeforeValidation = false;
jest
.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync)
.mockImplementation(async authedCtx => {
authAttemptedBeforeValidation = true;
(authedCtx.appStore as any).authCtx = testAuthCtx;
return true;
});
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
expect(authAttemptedBeforeValidation).toBe(true);
return true;
});
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...getAppstoreMock(),
authCtx: undefined,
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await setupProvisioningProfileAction.runAsync(ctx);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
[ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION],
]);
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
0
);
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('uses best-effort validation when Apple validation fails in nonInteractive mode', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
throw new Error('Apple API request failed');
});
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).resolves.toEqual(
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0]
);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(0);
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
0
);
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('rethrows Apple validation failures in interactive mode', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
throw new Error('Apple API request failed');
});
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrow(
'Apple API request failed'
);
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
0
);
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('repairs the Provisioning Profile in nonInteractive mode after auto-authenticating with the EAS-stored ASC API key', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
const appstoreMock = getAppstoreMock();
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...appstoreMock,
defaultAuthenticationMode: AuthenticationMode.USER,
authCtx: undefined,
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
listProvisioningProfilesAsync: jest.fn(() => [
{
provisioningProfileId: nullthrows(
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0].provisioningProfile
).developerPortalIdentifier,
},
]),
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment),
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(
() => testIosAppBuildCredentialsFragment
),
},
});
jest
.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync)
.mockImplementation(async authedCtx => {
(authedCtx.appStore as any).authCtx = testAuthCtx;
return true;
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await setupProvisioningProfileAction.runAsync(ctx);
// expect we attempted to auto-authenticate with the EAS-stored ASC API key
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
[ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION],
]);
// expect build credentials to be created or updated on expo servers
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
1
);
// expect provisioning profile not to be deleted on expo servers (it still exists on Apple)
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('passes IN_HOUSE team type for enterprise profiles when auto-authenticating in nonInteractive mode', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
const appstoreMock = getAppstoreMock();
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...appstoreMock,
defaultAuthenticationMode: AuthenticationMode.USER,
authCtx: undefined,
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.Enterprise
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
InsufficientAuthenticationNonInteractiveError
);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
[ctx, appLookupParams, AppleTeamType.IN_HOUSE],
]);
});
it('uses EXPO_APPLE_TEAM_TYPE over derived team type when auto-authenticating in nonInteractive mode', async () => {
process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.IN_HOUSE;
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
const appstoreMock = getAppstoreMock();
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...appstoreMock,
defaultAuthenticationMode: AuthenticationMode.USER,
authCtx: undefined,
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
InsufficientAuthenticationNonInteractiveError
);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
[ctx, appLookupParams, AppleTeamType.IN_HOUSE],
]);
});
it('errors in nonInteractive mode when the profile needs regeneration but no Apple authentication can be established', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
// tryAuthenticate fails to establish auth (default mock returns false and leaves authCtx unset).
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
InsufficientAuthenticationNonInteractiveError
);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(1);
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
0
);
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
});
@@ -6,6 +6,7 @@ import { promptAsync } from '../../../../prompts';
import { AppleTeamType } from '../authenticateTypes';
import * as Keychain from '../keychain';
import {
resolveAppleTeamTypeFromEnvironment,
resolveAppleTeamAsync,
resolveAscApiKeyAsync,
resolveUserCredentialsAsync,
@@ -73,6 +74,17 @@ const testTeam = {
teamName: 'test-name',
teamType: AppleTeamType.IN_HOUSE,
};
describe(resolveAppleTeamTypeFromEnvironment, () => {
it('returns undefined when EXPO_APPLE_TEAM_TYPE is not set', () => {
expect(resolveAppleTeamTypeFromEnvironment()).toBeUndefined();
});
it('returns parsed team type when EXPO_APPLE_TEAM_TYPE is set', () => {
process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.COMPANY_OR_ORGANIZATION;
expect(resolveAppleTeamTypeFromEnvironment()).toBe(AppleTeamType.COMPANY_OR_ORGANIZATION);
});
});
describe(resolveAppleTeamAsync, () => {
it(`uses option overrides over environment variables`, async () => {
process.env.EXPO_APPLE_TEAM_ID = 'not supposed to be here';
@@ -118,7 +118,7 @@ function assertAppleTeamType(maybeTeamType: any): AppleTeamType {
return maybeTeamType;
}
function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
export function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
if (!process.env.EXPO_APPLE_TEAM_TYPE) {
return undefined;
}
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,8 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeleteEmbeddedUpdateMutation,
DeleteEmbeddedUpdateMutationVariables,
UploadEmbeddedUpdateInput,
UploadEmbeddedUpdateMutation,
UploadEmbeddedUpdateMutationVariables,
@@ -61,4 +63,27 @@ export const EmbeddedUpdateMutation = {
);
return data.embeddedUpdate.uploadEmbeddedUpdate;
},
async deleteEmbeddedUpdateAsync(
graphqlClient: ExpoGraphqlClient,
{ id }: { id: string }
): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteEmbeddedUpdateMutation, DeleteEmbeddedUpdateMutationVariables>(
gql`
mutation DeleteEmbeddedUpdate($id: ID!) {
embeddedUpdate {
deleteEmbeddedUpdate(id: $id) {
id
}
}
}
`,
{ id }
)
.toPromise()
);
return data.embeddedUpdate.deleteEmbeddedUpdate;
},
};
@@ -0,0 +1,117 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { CreatePostHogAccountRequestInput, SetupPostHogProjectInput } from '../generated';
import {
PostHogOrganizationConnectionData,
PostHogOrganizationConnectionFragmentNode,
PostHogProjectData,
PostHogProjectFragmentNode,
} from '../types/PostHogConnection';
type CreatePostHogAccountRequestMutation = {
posthogOrganizationConnection: {
createPostHogAccountRequest: PostHogOrganizationConnectionData;
};
};
type CreatePostHogAccountRequestMutationVariables = {
input: CreatePostHogAccountRequestInput;
};
type SetupPostHogProjectMutation = {
posthogProject: {
setupPostHogProject: PostHogProjectData;
};
};
type SetupPostHogProjectMutationVariables = {
input: SetupPostHogProjectInput;
};
type DeletePostHogProjectMutation = {
posthogProject: {
deletePostHogProject: string;
};
};
type DeletePostHogProjectMutationVariables = {
id: string;
};
export const PostHogMutation = {
async createPostHogAccountRequestAsync(
graphqlClient: ExpoGraphqlClient,
input: CreatePostHogAccountRequestInput
): Promise<PostHogOrganizationConnectionData> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
CreatePostHogAccountRequestMutation,
CreatePostHogAccountRequestMutationVariables
>(
gql`
mutation CreatePostHogAccountRequest($input: CreatePostHogAccountRequestInput!) {
posthogOrganizationConnection {
createPostHogAccountRequest(input: $input) {
id
...PostHogOrganizationConnectionFragment
}
}
}
${print(PostHogOrganizationConnectionFragmentNode)}
`,
{ input }
)
.toPromise()
);
return data.posthogOrganizationConnection.createPostHogAccountRequest;
},
async setupPostHogProjectAsync(
graphqlClient: ExpoGraphqlClient,
input: SetupPostHogProjectInput
): Promise<PostHogProjectData> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<SetupPostHogProjectMutation, SetupPostHogProjectMutationVariables>(
gql`
mutation SetupPostHogProject($input: SetupPostHogProjectInput!) {
posthogProject {
setupPostHogProject(input: $input) {
id
...PostHogProjectFragment
}
}
}
${print(PostHogOrganizationConnectionFragmentNode)}
${print(PostHogProjectFragmentNode)}
`,
{ input }
)
.toPromise()
);
return data.posthogProject.setupPostHogProject;
},
async deletePostHogProjectAsync(graphqlClient: ExpoGraphqlClient, id: string): Promise<string> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeletePostHogProjectMutation, DeletePostHogProjectMutationVariables>(
gql`
mutation DeletePostHogProject($id: ID!) {
posthogProject {
deletePostHogProject(id: $id)
}
}
`,
{ id },
{ additionalTypenames: ['App', 'PostHogProject'] }
)
.toPromise()
);
return data.posthogProject.deletePostHogProject;
},
};
@@ -97,3 +97,27 @@ describe('EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync', () => {
expect(result).toEqual(expected);
});
});
describe('EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync', () => {
it('returns the deleted id from the GraphQL response', async () => {
const client = makeGraphqlClient({
embeddedUpdate: { deleteEmbeddedUpdate: { id: 'update-1' } },
});
const result = await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(client, {
id: 'update-1',
});
expect(result).toEqual({ id: 'update-1' });
});
it('passes the id through to the mutation variables', async () => {
const client = makeGraphqlClient({
embeddedUpdate: { deleteEmbeddedUpdate: { id: 'update-2' } },
});
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(client, { id: 'update-2' });
expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { id: 'update-2' });
});
});
@@ -0,0 +1,73 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Connection, QueryParams } from '../../utils/relay';
import { withErrorHandlingAsync } from '../client';
import {
AuditLogFragment,
AuditLogsByAccountQuery,
AuditLogsByAccountQueryVariables,
} from '../generated';
import { AuditLogFragmentNode } from '../types/AuditLog';
export const AuditLogQuery = {
async getAllForAccountAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
queryParams: QueryParams
): Promise<Connection<AuditLogFragment>> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AuditLogsByAccountQuery, AuditLogsByAccountQueryVariables>(
gql`
query AuditLogsByAccount(
$accountId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
account {
byId(accountId: $accountId) {
id
auditLogsPaginated(
first: $first
after: $after
last: $last
before: $before
) {
edges {
cursor
node {
id
...AuditLogFragment
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
${print(AuditLogFragmentNode)}
`,
{
accountId,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
},
{ additionalTypenames: ['AuditLog'] }
)
.toPromise()
);
return data.account.byId.auditLogsPaginated;
},
};
@@ -42,6 +42,7 @@ export const DeviceRunSessionQuery = {
}
... on ArgentRunSessionRemoteConfig {
toolsUrl
toolsAuthToken
webPreviewUrl
}
... on ServeSimRunSessionRemoteConfig {
@@ -0,0 +1,118 @@
import { CombinedError } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Connection } from '../../utils/relay';
import {
EmbeddedUpdateFilterInput,
ViewEmbeddedUpdateByIdQuery,
ViewEmbeddedUpdateByIdQueryVariables,
ViewEmbeddedUpdatesPaginatedQuery,
ViewEmbeddedUpdatesPaginatedQueryVariables,
} from '../generated';
import { withErrorHandlingAsync } from '../client';
export function isEmbeddedUpdateNotFoundError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_NOT_FOUND')
);
}
export type EmbeddedUpdateFragment = ViewEmbeddedUpdateByIdQuery['embeddedUpdates']['byId'];
export type EmbeddedUpdateFilter = EmbeddedUpdateFilterInput;
export const EmbeddedUpdateQuery = {
async viewByIdAsync(
graphqlClient: ExpoGraphqlClient,
{ embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string }
): Promise<EmbeddedUpdateFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ViewEmbeddedUpdateByIdQuery, ViewEmbeddedUpdateByIdQueryVariables>(
gql`
query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) {
embeddedUpdates {
byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) {
id
platform
runtimeVersion
channel
createdAt
launchAsset {
id
fileSize
finalFileSize
fileSHA256
}
}
}
}
`,
{ embeddedUpdateId, appId },
{ additionalTypenames: ['EmbeddedUpdate'] }
)
.toPromise()
);
return data.embeddedUpdates.byId;
},
async viewPaginatedAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
filter,
first,
after,
}: { appId: string; filter?: EmbeddedUpdateFilter; first: number; after?: string }
): Promise<Connection<EmbeddedUpdateFragment>> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ViewEmbeddedUpdatesPaginatedQuery, ViewEmbeddedUpdatesPaginatedQueryVariables>(
gql`
query ViewEmbeddedUpdatesPaginated(
$appId: String!
$first: Int!
$after: String
$filter: EmbeddedUpdateFilterInput
) {
app {
byId(appId: $appId) {
id
embeddedUpdatesPaginated(first: $first, after: $after, filter: $filter) {
edges {
cursor
node {
id
platform
runtimeVersion
channel
createdAt
launchAsset {
id
fileSize
finalFileSize
fileSHA256
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
`,
{ appId, first, after, filter },
{ additionalTypenames: ['EmbeddedUpdate'] }
)
.toPromise()
);
return data.app.byId.embeddedUpdatesPaginated;
},
};
@@ -0,0 +1,103 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
PostHogOrganizationConnectionData,
PostHogOrganizationConnectionFragmentNode,
PostHogProjectData,
PostHogProjectFragmentNode,
} from '../types/PostHogConnection';
type PostHogOrganizationConnectionByAccountIdQuery = {
account: {
byId: {
id: string;
posthogOrganizationConnection?: PostHogOrganizationConnectionData | null;
};
};
};
type PostHogOrganizationConnectionByAccountIdQueryVariables = {
accountId: string;
};
type PostHogProjectByAppIdQuery = {
app: {
byId: {
id: string;
posthogProject?: PostHogProjectData | null;
};
};
};
type PostHogProjectByAppIdQueryVariables = {
appId: string;
};
export const PostHogQuery = {
async getPostHogOrganizationConnectionByAccountIdAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string
): Promise<PostHogOrganizationConnectionData | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<
PostHogOrganizationConnectionByAccountIdQuery,
PostHogOrganizationConnectionByAccountIdQueryVariables
>(
gql`
query PostHogOrganizationConnectionByAccountId($accountId: String!) {
account {
byId(accountId: $accountId) {
id
posthogOrganizationConnection {
id
...PostHogOrganizationConnectionFragment
}
}
}
}
${print(PostHogOrganizationConnectionFragmentNode)}
`,
{ accountId },
{ additionalTypenames: ['PostHogOrganizationConnection'] }
)
.toPromise()
);
return data.account.byId.posthogOrganizationConnection ?? null;
},
async getPostHogProjectByAppIdAsync(
graphqlClient: ExpoGraphqlClient,
appId: string
): Promise<PostHogProjectData | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<PostHogProjectByAppIdQuery, PostHogProjectByAppIdQueryVariables>(
gql`
query PostHogProjectByAppId($appId: String!) {
app {
byId(appId: $appId) {
id
posthogProject {
id
...PostHogProjectFragment
}
}
}
}
${print(PostHogOrganizationConnectionFragmentNode)}
${print(PostHogProjectFragmentNode)}
`,
{ appId },
{ additionalTypenames: ['App', 'PostHogProject'] }
)
.toPromise()
);
return data.app.byId.posthogProject ?? null;
},
};
@@ -11,6 +11,8 @@ import {
ViewUpdateGroupsOnAppQueryVariables,
ViewUpdateGroupsOnBranchQuery,
ViewUpdateGroupsOnBranchQueryVariables,
ViewUpdateGroupsPaginatedOnBranchQuery,
ViewUpdateGroupsPaginatedOnBranchQueryVariables,
ViewUpdatesByGroupQuery,
ViewUpdatesByGroupQueryVariables,
} from '../generated';
@@ -96,6 +98,80 @@ export const UpdateQuery = {
return branch.updateGroups;
},
async viewUpdateGroupsPaginatedOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
first,
last,
after,
before,
filter,
}: ViewUpdateGroupsPaginatedOnBranchQueryVariables
): Promise<UpdateFragment[][]> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<
ViewUpdateGroupsPaginatedOnBranchQuery,
ViewUpdateGroupsPaginatedOnBranchQueryVariables
>(
gql`
query ViewUpdateGroupsPaginatedOnBranch(
$appId: String!
$branchName: String!
$first: Int
$last: Int
$after: String
$before: String
$filter: UpdatesFilterV2
) {
app {
byId(appId: $appId) {
id
updateBranchByName(name: $branchName) {
id
updateGroupsPaginated(
first: $first
last: $last
after: $after
before: $before
filter: $filter
) {
edges {
node {
id
...UpdateFragment
}
}
}
}
}
}
}
${print(UpdateFragmentNode)}
`,
{
appId,
branchName,
first,
last,
after,
before,
filter,
},
{ additionalTypenames: ['Update'] }
)
.toPromise()
);
const branch = response.app.byId.updateBranchByName;
if (!branch) {
throw new Error(`Could not find branch "${branchName}"`);
}
return branch.updateGroupsPaginated.edges.map(edge => edge.node);
},
async viewUpdateGroupsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ limit, offset, appId, filter }: ViewUpdateGroupsOnAppQueryVariables
@@ -0,0 +1,47 @@
import { anything, capture, instance, mock, when } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AuditLogQuery } from '../AuditLogQuery';
describe(AuditLogQuery.getAllForAccountAsync.name, () => {
it('returns the audit log connection for an account and forwards pagination params', async () => {
const connection = {
edges: [
{
cursor: 'cursor-1',
node: {
id: 'audit-log-id',
createdAt: '2026-05-21T17:43:32.433Z',
websiteMessage: 'Created a new Project',
targetEntityTypePublicName: 'Project',
targetEntityMutationType: 'CREATE',
actor: { id: 'actor-id', displayName: 'tester' },
},
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor-1',
endCursor: 'cursor-1',
},
};
const graphqlClientMock = mock<ExpoGraphqlClient>();
when(graphqlClientMock.query(anything(), anything(), anything())).thenReturn({
toPromise: () =>
Promise.resolve({ data: { account: { byId: { auditLogsPaginated: connection } } } }),
} as any);
const graphqlClient = instance(graphqlClientMock);
const result = await AuditLogQuery.getAllForAccountAsync(graphqlClient, 'account-id', {
first: 10,
after: 'cursor-0',
});
expect(result).toBe(connection);
const [, variables] = capture<any, any, any>(graphqlClientMock.query as any).first();
expect(variables).toMatchObject({ accountId: 'account-id', first: 10, after: 'cursor-0' });
});
});
@@ -0,0 +1,178 @@
import { CombinedError } from '@urql/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../generated';
import { EmbeddedUpdateQuery, isEmbeddedUpdateNotFoundError } from '../EmbeddedUpdateQuery';
function makeGraphqlClient(data: unknown): ExpoGraphqlClient {
return {
query: jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
}),
} as unknown as ExpoGraphqlClient;
}
function makeCombinedError(errorCode: string): CombinedError {
return new CombinedError({
graphQLErrors: [{ message: 'error', extensions: { errorCode } }],
});
}
describe('isEmbeddedUpdateNotFoundError', () => {
it('returns true for CombinedError with EMBEDDED_UPDATE_NOT_FOUND errorCode', () => {
expect(isEmbeddedUpdateNotFoundError(makeCombinedError('EMBEDDED_UPDATE_NOT_FOUND'))).toBe(
true
);
});
it('returns false for CombinedError with a different errorCode', () => {
expect(isEmbeddedUpdateNotFoundError(makeCombinedError('SOME_OTHER_ERROR'))).toBe(false);
});
it('returns false for CombinedError with no extensions', () => {
expect(
isEmbeddedUpdateNotFoundError(new CombinedError({ graphQLErrors: [{ message: 'oops' }] }))
).toBe(false);
});
it('returns false for a plain Error', () => {
expect(isEmbeddedUpdateNotFoundError(new Error('plain error'))).toBe(false);
});
it('returns false for null and non-error values', () => {
expect(isEmbeddedUpdateNotFoundError(null)).toBe(false);
expect(isEmbeddedUpdateNotFoundError('string')).toBe(false);
expect(isEmbeddedUpdateNotFoundError(42)).toBe(false);
});
});
describe('EmbeddedUpdateQuery.viewByIdAsync', () => {
it('returns the embedded update from the GraphQL response', async () => {
const expected = {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: 768,
fileSHA256: 'abc123',
},
};
const client = makeGraphqlClient({
embeddedUpdates: { byId: expected },
});
const result = await EmbeddedUpdateQuery.viewByIdAsync(client, {
embeddedUpdateId: 'update-1',
appId: 'app-1',
});
expect(result).toEqual(expected);
});
it('passes the embeddedUpdateId and appId to the query', async () => {
const client = makeGraphqlClient({
embeddedUpdates: {
byId: {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: null,
fileSHA256: 'abc',
},
},
},
});
await EmbeddedUpdateQuery.viewByIdAsync(client, {
embeddedUpdateId: 'update-1',
appId: 'app-1',
});
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{ embeddedUpdateId: 'update-1', appId: 'app-1' },
expect.objectContaining({ additionalTypenames: ['EmbeddedUpdate'] })
);
});
});
describe('EmbeddedUpdateQuery.viewPaginatedAsync', () => {
function makeConnection(): {
edges: { cursor: string; node: any }[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
} {
return {
edges: [
{
cursor: 'c1',
node: {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: null,
fileSHA256: 'abc',
},
},
},
],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
};
}
it('returns the connection from the GraphQL response', async () => {
const connection = makeConnection();
const client = makeGraphqlClient({
app: { byId: { id: 'app-1', embeddedUpdatesPaginated: connection } },
});
const result = await EmbeddedUpdateQuery.viewPaginatedAsync(client, {
appId: 'app-1',
first: 25,
});
expect(result).toEqual(connection);
});
it('forwards filter / first / after to the query variables', async () => {
const client = makeGraphqlClient({
app: { byId: { id: 'app-1', embeddedUpdatesPaginated: makeConnection() } },
});
await EmbeddedUpdateQuery.viewPaginatedAsync(client, {
appId: 'app-1',
first: 10,
after: 'cursor-99',
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.0.0', channel: 'production' },
});
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{
appId: 'app-1',
first: 10,
after: 'cursor-99',
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.0.0', channel: 'production' },
},
expect.objectContaining({ additionalTypenames: ['EmbeddedUpdate'] })
);
});
});
@@ -0,0 +1,15 @@
import gql from 'graphql-tag';
export const AuditLogFragmentNode = gql`
fragment AuditLogFragment on AuditLog {
id
createdAt
websiteMessage
targetEntityTypePublicName
targetEntityMutationType
actor {
id
displayName
}
}
`;
@@ -0,0 +1,53 @@
import gql from 'graphql-tag';
import { PostHogOrganizationConnection, PostHogProject } from '../generated';
export type PostHogOrganizationConnectionData = Pick<
PostHogOrganizationConnection,
| 'id'
| 'posthogOrganizationIdentifier'
| 'posthogOrganizationName'
| 'posthogRegion'
| 'createdAt'
| 'updatedAt'
>;
export type PostHogProjectData = Pick<
PostHogProject,
| 'id'
| 'posthogProjectIdentifier'
| 'posthogProjectName'
| 'posthogProjectToken'
| 'posthogHost'
| 'createdAt'
| 'updatedAt'
> & {
posthogOrganizationConnection: PostHogOrganizationConnectionData;
};
export const PostHogOrganizationConnectionFragmentNode = gql`
fragment PostHogOrganizationConnectionFragment on PostHogOrganizationConnection {
id
posthogOrganizationIdentifier
posthogOrganizationName
posthogRegion
createdAt
updatedAt
}
`;
export const PostHogProjectFragmentNode = gql`
fragment PostHogProjectFragment on PostHogProject {
id
posthogProjectIdentifier
posthogProjectName
posthogProjectToken
posthogHost
createdAt
updatedAt
posthogOrganizationConnection {
id
...PostHogOrganizationConnectionFragment
}
}
`;
+31 -5
View File
@@ -31,6 +31,10 @@ export function getRemoteSessionEnvironmentVariables(
AGENT_DEVICE_DAEMON_AUTH_TOKEN: remoteConfig.agentDeviceRemoteSessionToken,
};
case 'ArgentRunSessionRemoteConfig':
return {
ARGENT_TOOLS_URL: remoteConfig.toolsUrl,
...(remoteConfig.toolsAuthToken ? { ARGENT_AUTH_TOKEN: remoteConfig.toolsAuthToken } : {}),
};
case 'ServeSimRunSessionRemoteConfig':
return {};
}
@@ -70,11 +74,33 @@ export function formatRemoteSessionInstructions(
return lines.join('\n');
}
case 'ArgentRunSessionRemoteConfig': {
const lines = [
'🔑 Open the following URL to access the Argent tools for this session:',
'',
remoteConfig.toolsUrl,
];
const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig);
const lines =
configType === 'dotenv'
? [
'🔑 Run the following to link your local Argent client to this simulator session:',
'',
[
'argent',
'link',
`'${remoteConfig.toolsUrl}'`,
remoteConfig.toolsAuthToken
? `--token '${remoteConfig.toolsAuthToken}'`
: undefined,
'--yes',
]
.filter(Boolean)
.join(' '),
'',
'Restart your editor after linking so its Argent MCP process uses the remote session.',
]
: [
'🔑 Run the following in your shell to attach Argent to this simulator session:',
'',
...Object.entries(environmentVariables).map(
([key, value]) => `export ${key}='${value}'`
),
];
if (remoteConfig.webPreviewUrl) {
lines.push(
'',
+8 -136
View File
@@ -1,6 +1,5 @@
import JsonFile from '@expo/json-file';
import { Errors } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
@@ -13,7 +12,7 @@ import { createGraphqlClient } from '../commandUtils/context/contextUtils/create
import { CurrentUserQuery } from '../graphql/generated';
import { UserQuery } from '../graphql/queries/UserQuery';
import Log, { learnMore } from '../log';
import { promptAsync, selectAsync } from '../prompts';
import { promptAsync } from '../prompts';
import { getStateJsonPath } from '../utils/paths';
type UserSettingsData = {
@@ -29,18 +28,6 @@ type SessionData = {
currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
};
export enum UserSecondFactorDeviceMethod {
AUTHENTICATOR = 'authenticator',
SMS = 'sms',
}
type SecondFactorDevice = {
id: string;
method: UserSecondFactorDeviceMethod;
sms_phone_number: string | null;
is_primary: boolean;
};
export type LoggedInAuthenticationInfo =
| {
accessToken: string;
@@ -205,11 +192,7 @@ export default class SessionManager {
});
} catch (e) {
if (e instanceof ApiV2Error && e.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') {
await this.retryUsernamePasswordAuthWithOTPAsync(
username,
password,
e.expoApiV2ErrorMetadata as any
);
await this.retryUsernamePasswordAuthWithOTPAsync(username, password);
} else {
throw e;
}
@@ -245,11 +228,8 @@ export default class SessionManager {
/**
* Prompt for an OTP with the option to cancel the question by answering empty (pressing return key).
*/
private async promptForOTPAsync(cancelBehavior: 'cancel' | 'menu'): Promise<string | null> {
const enterMessage =
cancelBehavior === 'cancel'
? `press ${chalk.bold('Enter')} to cancel`
: `press ${chalk.bold('Enter')} for more options`;
private async promptForOTPAsync(): Promise<string | null> {
const enterMessage = `press ${chalk.bold('Enter')} to cancel`;
const { otp } = await promptAsync({
type: 'text',
name: 'otp',
@@ -263,122 +243,14 @@ export default class SessionManager {
}
/**
* Prompt for user to choose a backup OTP method. If selected method is SMS, a request
* for a new OTP will be sent to that method. Then, prompt for the OTP, and retry the user login.
*/
private async promptForBackupOTPAsync(
username: string,
password: string,
secondFactorDevices: SecondFactorDevice[]
): Promise<string | null> {
const nonPrimarySecondFactorDevices = secondFactorDevices.filter(device => !device.is_primary);
if (nonPrimarySecondFactorDevices.length === 0) {
throw new Error(
'No other second-factor devices set up. Ensure you have set up and certified a backup device.'
);
}
const hasAuthenticatorSecondFactorDevice = nonPrimarySecondFactorDevices.find(
device => device.method === UserSecondFactorDeviceMethod.AUTHENTICATOR
);
const smsNonPrimarySecondFactorDevices = nonPrimarySecondFactorDevices.filter(
device => device.method === UserSecondFactorDeviceMethod.SMS
);
const authenticatorChoiceSentinel = -1;
const cancelChoiceSentinel = -2;
const deviceChoices = smsNonPrimarySecondFactorDevices.map((device, idx) => ({
title: device.sms_phone_number!,
value: idx,
}));
if (hasAuthenticatorSecondFactorDevice) {
deviceChoices.push({
title: 'Authenticator',
value: authenticatorChoiceSentinel,
});
}
deviceChoices.push({
title: 'Cancel',
value: cancelChoiceSentinel,
});
const selectedValue = await selectAsync('Select a second-factor device:', deviceChoices);
if (selectedValue === cancelChoiceSentinel) {
return null;
} else if (selectedValue === authenticatorChoiceSentinel) {
return await this.promptForOTPAsync('cancel');
}
const device = smsNonPrimarySecondFactorDevices[selectedValue];
// this is a logged-out endpoint
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret: null });
await apiV2Client.postAsync('auth/send-sms-otp', {
body: {
username,
password,
secondFactorDeviceID: device.id,
},
});
return await this.promptForOTPAsync('cancel');
}
/**
* Handle the special case error indicating that a second-factor is required for
* authentication.
*
* There are three cases we need to handle:
* 1. User's primary second-factor device was SMS, OTP was automatically sent by the server to that
* device already. In this case we should just prompt for the SMS OTP (or backup code), which the
* user should be receiving shortly. We should give the user a way to cancel and the prompt and move
* to case 3 below.
* 2. User's primary second-factor device is authenticator. In this case we should prompt for authenticator
* OTP (or backup code) and also give the user a way to cancel and move to case 3 below.
* 3. User doesn't have a primary device or doesn't have access to their primary device. In this case
* we should show a picker of the SMS devices that they can have an OTP code sent to, and when
* the user picks one we show a prompt() for the sent OTP.
* Handle the special case error indicating that a second-factor is required for authentication.
*/
private async retryUsernamePasswordAuthWithOTPAsync(
username: string,
password: string,
metadata: {
secondFactorDevices?: SecondFactorDevice[];
smsAutomaticallySent?: boolean;
}
password: string
): Promise<void> {
const { secondFactorDevices, smsAutomaticallySent } = metadata;
assert(
secondFactorDevices !== undefined && smsAutomaticallySent !== undefined,
`Malformed OTP error metadata: ${metadata}`
);
const primaryDevice = secondFactorDevices.find(device => device.is_primary);
let otp: string | null = null;
if (smsAutomaticallySent) {
assert(primaryDevice, 'OTP should only automatically be sent when there is a primary device');
Log.log(
`One-time password was sent to the phone number ending in ${primaryDevice.sms_phone_number}.`
);
otp = await this.promptForOTPAsync('menu');
}
if (primaryDevice?.method === UserSecondFactorDeviceMethod.AUTHENTICATOR) {
Log.log('One-time password from authenticator required.');
otp = await this.promptForOTPAsync('menu');
}
// user bailed on case 1 or 2, wants to move to case 3
if (!otp) {
otp = await this.promptForBackupOTPAsync(username, password, secondFactorDevices);
}
Log.log('One-time password from authenticator required.');
const otp = await this.promptForOTPAsync();
if (!otp) {
throw new Error('Cancelled login');
}
@@ -6,9 +6,9 @@ import { ApiV2Error } from '../../ApiV2Error';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
import { ApiV2Client } from '../../api';
import Log from '../../log';
import { promptAsync, selectAsync } from '../../prompts';
import { promptAsync } from '../../prompts';
import { getStateJsonPath } from '../../utils/paths';
import SessionManager, { UserSecondFactorDeviceMethod } from '../SessionManager';
import SessionManager from '../SessionManager';
import { fetchSessionSecretAndUserAsync } from '../fetchSessionSecretAndUser';
import { fetchSessionSecretAndUserFromBrowserAuthFlowAsync } from '../fetchSessionSecretAndUserFromBrowserAuthFlow';
@@ -275,17 +275,6 @@ describe(SessionManager, () => {
throw new ApiV2Error({
message: 'An OTP is required',
code: 'ONE_TIME_PASSWORD_REQUIRED',
metadata: {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.SMS,
sms_phone_number: 'testphone',
},
],
smsAutomaticallySent: true,
},
});
})
.mockResolvedValueOnce({
@@ -298,18 +287,7 @@ describe(SessionManager, () => {
expect(sessionManagerRetryUsernamePasswordAuthWithOTPAsyncSpy).toHaveBeenCalledWith(
'hello',
'world',
{
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.SMS,
sms_phone_number: 'testphone',
},
],
smsAutomaticallySent: true,
}
'world'
);
});
@@ -383,7 +361,7 @@ describe(SessionManager, () => {
});
describe('retryUsernamePasswordAuthWithOTPAsync', () => {
it('shows SMS OTP prompt when SMS is primary and code was automatically sent', async () => {
it('shows authenticator OTP prompt', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ otp: 'hello' }))
@@ -393,246 +371,24 @@ describe(SessionManager, () => {
const sessionManager = new SessionManager(analytics);
const sessionManagerLoginAsyncSpy = jest.spyOn(sessionManager as any, 'loginAsync');
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.SMS,
sms_phone_number: 'testphone',
},
],
smsAutomaticallySent: true,
});
expect(Log.log).toHaveBeenCalledWith(
'One-time password was sent to the phone number ending in testphone.'
);
expect(sessionManagerLoginAsyncSpy).toHaveBeenCalledTimes(1);
});
it('shows authenticator OTP prompt when authenticator is primary', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ otp: 'hello' }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
const sessionManager = new SessionManager(analytics);
const sessionManagerLoginAsyncSpy = jest.spyOn(sessionManager as any, 'loginAsync');
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
],
smsAutomaticallySent: false,
});
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah');
expect(Log.log).toHaveBeenCalledWith('One-time password from authenticator required.');
expect(sessionManagerLoginAsyncSpy).toHaveBeenCalledTimes(1);
});
it('shows menu when user bails on primary', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ otp: null }))
.mockImplementationOnce(async () => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
jest
.mocked(selectAsync)
.mockImplementationOnce(async () => -1)
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
const sessionManager = new SessionManager(analytics);
const sessionManagerLoginAsyncSpy = jest.spyOn(sessionManager as any, 'loginAsync');
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
{
id: 'p2',
is_primary: false,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
],
smsAutomaticallySent: false,
});
expect(jest.mocked(selectAsync).mock.calls.length).toEqual(1);
expect(sessionManagerLoginAsyncSpy).toHaveBeenCalledTimes(1);
});
it('shows a warning when when user bails on primary and does not have any secondary set up', async () => {
it('exits when user bails', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ otp: null }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
const sessionManager = new SessionManager(analytics);
await expect(
sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
],
smsAutomaticallySent: false,
})
).rejects.toThrowError(
'No other second-factor devices set up. Ensure you have set up and certified a backup device.'
);
});
it('prompts for authenticator OTP when user selects authenticator secondary', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ otp: null }))
.mockImplementationOnce(async () => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
jest
.mocked(selectAsync)
.mockImplementationOnce(async () => -1)
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
const sessionManager = new SessionManager(analytics);
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
{
id: 'p2',
is_primary: false,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
],
smsAutomaticallySent: false,
});
expect(jest.mocked(promptAsync).mock.calls.length).toBe(2); // first OTP, second OTP
});
it('requests SMS OTP and prompts for SMS OTP when user selects SMS secondary', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ otp: null }))
.mockImplementationOnce(async () => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
jest
.mocked(selectAsync)
.mockImplementationOnce(async () => 0)
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValue({
sessionSecret: 'SESSION_SECRET',
id: 'USER_ID',
username: 'USERNAME',
});
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
const sessionManager = new SessionManager(analytics);
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
{
id: 'p2',
is_primary: false,
method: UserSecondFactorDeviceMethod.SMS,
sms_phone_number: 'wat',
},
],
smsAutomaticallySent: false,
});
expect(jest.mocked(promptAsync).mock.calls.length).toBe(2); // first OTP, second OTP
expect(apiV2PostSpy.mock.calls[0]).toEqual([
'auth/send-sms-otp',
{
body: {
username: 'blah',
password: 'blah',
secondFactorDeviceID: 'p2',
},
},
]);
});
it('exits when user bails on primary and backup', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({ otp: null }))
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
jest
.mocked(selectAsync)
.mockImplementationOnce(async () => -2)
.mockImplementation(() => {
throw new Error("shouldn't happen");
});
const sessionManager = new SessionManager(analytics);
await expect(
sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
secondFactorDevices: [
{
id: 'p0',
is_primary: true,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
{
id: 'p2',
is_primary: false,
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
sms_phone_number: null,
},
],
smsAutomaticallySent: false,
})
sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah')
).rejects.toThrowError('Cancelled login');
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "20.0.0",
"version": "20.1.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+2 -2
View File
@@ -131,7 +131,7 @@ PLATFORMS
ruby
DEPENDENCIES
bundler (~> 1.17)
bundler (>= 2.4)
cocoapods (~> 1.12)
expo-cocoapods-proxy!
rake (~> 10.0)
@@ -139,4 +139,4 @@ DEPENDENCIES
rubocop (~> 1.48)
BUNDLED WITH
2.1.4
4.0.13
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']
spec.add_development_dependency 'bundler', '~> 1.17'
spec.add_development_dependency 'bundler', '>= 2.4'
spec.add_development_dependency 'rake', '~> 10.0'
spec.add_development_dependency 'rspec', '~> 3.0'
end
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "20.0.0",
"version": "20.3.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,8 +26,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/build-tools": "20.3.0",
"@expo/eas-build-job": "20.1.0",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "20.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "20.0.0",
"version": "20.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "20.0.0",
"version": "20.3.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "20.0.0",
"@expo/build-tools": "20.3.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/logger": "20.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.0.0",
"@expo/steps": "20.1.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@hapi/boom": "10.0.1",
@@ -0,0 +1,87 @@
import { RuntimeSettings } from '@expo/build-tools';
import { Platform, Workflow } from '@expo/eas-build-job';
import config from '../config';
import { createBuildContext } from '../context';
import { getBuildEnv } from '../env';
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
jest.mock('../env', () => ({
getBuildEnv: jest.fn(() => ({
__API_SERVER_URL: 'http://api.expo.test',
PATH: '/usr/bin',
})),
}));
jest.mock('../runtimeEnvironment', () => ({
prepareRuntimeEnvironmentConfigFiles: jest.fn(async () => {}),
}));
describe(createBuildContext.name, () => {
const childLogger = {
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
} as any;
const buildLogger = {
child: jest.fn(() => childLogger),
} as any;
const baseOptions = {
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
analytics: {} as any,
metadata: {},
projectId: 'project-id',
buildId: 'build-id',
buildLogger,
};
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(RuntimeSettings, 'loadAsync').mockResolvedValue();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('loads runtime settings with the job environment before creating build env', async () => {
const env = { EAS_USE_NPM_CACHE: '1' };
await createBuildContext({
...baseOptions,
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
secrets: { environmentSecrets: [] },
builderEnvironment: { env },
environment: 'preview',
} as any,
});
expect(RuntimeSettings.loadAsync).toHaveBeenCalledWith({
environment: config.env,
env,
});
expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalled();
expect(jest.mocked(RuntimeSettings.loadAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(getBuildEnv).mock.invocationCallOrder[0]
);
});
it('loads runtime settings without job env when the job has no builder environment', async () => {
await createBuildContext({
...baseOptions,
job: {
platform: Platform.ANDROID,
type: Workflow.GENERIC,
secrets: { environmentSecrets: [] },
} as any,
});
expect(RuntimeSettings.loadAsync).toHaveBeenCalledWith(
expect.objectContaining({
environment: config.env,
env: undefined,
})
);
});
});
+231
View File
@@ -1,3 +1,4 @@
import { RuntimeSettings } from '@expo/build-tools';
import { Platform, Workflow } from '@expo/eas-build-job';
import os from 'os';
@@ -6,9 +7,47 @@ import { getBuildEnv, getGradleMemoryOptions } from '../env';
describe(getBuildEnv.name, () => {
const originalSentryDsn = config.sentry.dsn;
const originalPlatform = process.platform;
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
EAS_BUILD_NPM_CACHE_URL: process.env.EAS_BUILD_NPM_CACHE_URL,
NPM_CACHE_URL: process.env.NPM_CACHE_URL,
NVM_NODEJS_ORG_MIRROR: process.env.NVM_NODEJS_ORG_MIRROR,
EAS_BUILD_MAVEN_CACHE_URL: process.env.EAS_BUILD_MAVEN_CACHE_URL,
EAS_BUILD_COCOAPODS_CACHE_URL: process.env.EAS_BUILD_COCOAPODS_CACHE_URL,
NPM_CONFIG_REGISTRY: process.env.NPM_CONFIG_REGISTRY,
};
beforeEach(() => {
delete process.env.EAS_BUILD_NPM_CACHE_URL;
delete process.env.NPM_CACHE_URL;
delete process.env.NVM_NODEJS_ORG_MIRROR;
delete process.env.EAS_BUILD_MAVEN_CACHE_URL;
delete process.env.EAS_BUILD_COCOAPODS_CACHE_URL;
delete process.env.NPM_CONFIG_REGISTRY;
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'getNodeJsCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'getCocoapodsCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'isUsingIosPrecompiledModulesEnabled').mockReturnValue(false);
});
afterEach(() => {
config.sentry.dsn = originalSentryDsn;
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
restoreEnv('EAS_BUILD_NPM_CACHE_URL', originalCacheUrls.EAS_BUILD_NPM_CACHE_URL);
restoreEnv('NPM_CACHE_URL', originalCacheUrls.NPM_CACHE_URL);
restoreEnv('NVM_NODEJS_ORG_MIRROR', originalCacheUrls.NVM_NODEJS_ORG_MIRROR);
restoreEnv('EAS_BUILD_MAVEN_CACHE_URL', originalCacheUrls.EAS_BUILD_MAVEN_CACHE_URL);
restoreEnv('EAS_BUILD_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_BUILD_COCOAPODS_CACHE_URL);
restoreEnv('NPM_CONFIG_REGISTRY', originalCacheUrls.NPM_CONFIG_REGISTRY);
mockProcessPlatform(originalPlatform);
jest.restoreAllMocks();
});
@@ -62,6 +101,183 @@ describe(getBuildEnv.name, () => {
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
it('adds precompiled modules env vars for iOS jobs when enabled', () => {
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
const env = getBuildEnv({
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {},
},
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EAS_USE_PRECOMPILED_MODULES).toBe('1');
});
it('does not add precompiled modules env vars for Android jobs when enabled', () => {
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: {
env: {},
},
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
});
it('leaves job-provided precompiled modules env vars in override position', () => {
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
const job = {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {
EAS_USE_PRECOMPILED_MODULES: '0',
},
},
} as any;
const baseEnv = getBuildEnv({
job,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect({ ...baseEnv, ...job.builderEnvironment.env }.EAS_USE_PRECOMPILED_MODULES).toBe('0');
});
it('does not expose disabled Linux cache URLs in build env', () => {
mockProcessPlatform('linux');
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: {
env: {},
},
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.NPM_CACHE_URL).toBeUndefined();
expect(env.NPM_CONFIG_REGISTRY).toBeUndefined();
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBeUndefined();
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
expect(env.EAS_MAVEN_CACHE_URL).toBeUndefined();
});
it('does not expose disabled Darwin cache URLs in build env', () => {
mockProcessPlatform('darwin');
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
const env = getBuildEnv({
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {},
},
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.NPM_CACHE_URL).toBeUndefined();
expect(env.NPM_CONFIG_REGISTRY).toBeUndefined();
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_BUILD_COCOAPODS_CACHE_URL).toBeUndefined();
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
expect(env.EAS_COCOAPODS_CACHE_URL).toBeUndefined();
});
it('exposes enabled cache URLs inferred from private worker environment variables', () => {
mockProcessPlatform('linux');
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
jest.mocked(RuntimeSettings.getNpmCacheUrl).mockReturnValue('https://npm.example');
jest.mocked(RuntimeSettings.getNodeJsCacheUrl).mockReturnValue('https://node.example');
jest.mocked(RuntimeSettings.getMavenCacheUrl).mockReturnValue('https://maven.example');
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: {
env: {},
},
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.NPM_CACHE_URL).toBe('https://npm.example');
expect(env.NPM_CONFIG_REGISTRY).toBe('https://npm.example');
expect(env.EAS_BUILD_NPM_CACHE_URL).toBe('https://npm.example');
expect(env.NVM_NODEJS_ORG_MIRROR).toBe('https://node.example');
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBe('https://maven.example');
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
expect(env.EAS_MAVEN_CACHE_URL).toBeUndefined();
});
it('sizes Gradle memory options from total memory', () => {
const totalMemory = jest.spyOn(os, 'totalmem');
@@ -84,3 +300,18 @@ describe(getBuildEnv.name, () => {
});
});
});
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
@@ -1,10 +1,18 @@
// @ts-nocheck
import { RuntimeSettings } from '@expo/build-tools';
import { Android, Ios, Job } from '@expo/eas-build-job';
import templateFile from '@expo/template-file';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import { pathExists } from 'fs-extra';
import { prepareRuntimeEnvironment } from '../runtimeEnvironment';
import { mkdirp, pathExists } from 'fs-extra';
import config from '../config';
import {
prepareRuntimeEnvironment,
prepareRuntimeEnvironmentConfigFiles,
} from '../runtimeEnvironment';
jest.mock('fs-extra');
jest.mock('@expo/template-file');
jest.mock('@expo/turtle-spawn');
const spawnResult: SpawnResult = {
@@ -31,10 +39,75 @@ const ctx = {
const builderConfig: Ios.BuilderEnvironment | Android.BuilderEnvironment = {};
describe('prepareRuntimeEnvironment', () => {
const originalEnvironment = config.env;
const originalPlatform = process.platform;
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
};
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
config.env = originalEnvironment;
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
mockProcessPlatform(originalPlatform);
jest.restoreAllMocks();
});
describe(prepareRuntimeEnvironmentConfigFiles.name, () => {
beforeEach(() => {
config.env = 'production';
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
});
it('does not prepare disabled Linux cache config files', async () => {
mockProcessPlatform('linux');
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
await prepareRuntimeEnvironmentConfigFiles();
expect(spawn).not.toHaveBeenCalledWith('npm', [
'config',
'set',
'registry',
'https://npm.example',
]);
expect(templateFile).not.toHaveBeenCalled();
expect(mkdirp).not.toHaveBeenCalled();
});
it('prepares enabled Linux cache config files', async () => {
mockProcessPlatform('linux');
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue('https://npm.example');
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue('https://maven.example');
await prepareRuntimeEnvironmentConfigFiles();
expect(spawn).not.toHaveBeenCalledWith('npm', [
'config',
'set',
'registry',
'https://npm.example',
]);
expect(templateFile).toHaveBeenCalledWith(
expect.stringContaining('yarnrc.yml'),
{ URL: 'https://npm.example' },
expect.stringContaining('.yarnrc.yml')
);
expect(templateFile).toHaveBeenCalledWith(
expect.stringContaining('init.gradle'),
{ URL: 'https://maven.example' },
expect.stringContaining('init.gradle')
);
});
});
describe('installNode', () => {
describe('prepareRuntimeEnvironment', () => {
beforeEach(() => {
@@ -188,3 +261,18 @@ describe('prepareRuntimeEnvironment', () => {
});
});
});
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
+5 -5
View File
@@ -26,7 +26,7 @@ jest.mock('../build', () => ({
build: jest.fn(),
}));
jest.mock('../context', () => ({
createBuildContext: jest.fn(() => ({ job: {} })),
createBuildContext: jest.fn(async () => ({ job: {} })),
}));
jest.mock('../config', () => {
const actual = jest.requireActual('../config').default;
@@ -111,7 +111,7 @@ describe('BuildService Datadog setup', () => {
beforeEach(() => {
jest.clearAllMocks();
buildMock.mockResolvedValue({});
createBuildContextMock.mockReturnValue({ job: {} } as any);
createBuildContextMock.mockResolvedValue({ job: {} } as any);
createBuildLoggerWithSecretsFilterMock.mockResolvedValue({
logger: buildLogger,
cleanUp: jest.fn(async () => {}),
@@ -136,14 +136,14 @@ describe('BuildService Datadog setup', () => {
expect(datadogSetupMock).toHaveBeenCalledWith({
expoApiV2BaseUrl: expect.any(String),
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
expect(createBuildContextMock).toHaveBeenCalled();
expect(datadogFlushAsyncMock).toHaveBeenCalled();
});
it('configures Datadog for non-platform jobs with the turtle build id', async () => {
it('configures Datadog for jobRun jobs with the turtle job run id', async () => {
const service = new BuildService();
service.checkForHangingWorker = jest.fn(async () => {});
@@ -158,8 +158,8 @@ describe('BuildService Datadog setup', () => {
expect(datadogSetupMock).toHaveBeenCalledWith({
expoApiV2BaseUrl: expect.any(String),
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'build-id' },
});
expect(createBuildContextMock).toHaveBeenCalled();
});
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more