Compare commits

...
Author SHA1 Message Date
AbbanMustafa 0f2823fa18 [build-tools] Add npm cache fallback to install_node_modules step 2026-07-01 15:50:05 -04:00
AbbanMustafa 52e6fd0e81 use cache url as signal 2026-07-01 15:50:04 -04:00
AbbanMustafa 3957f058e9 inline env, remove deprecated env 2026-07-01 13:35:27 -04:00
AbbanMustafa 8597524f32 cache install test 2026-07-01 01:14:01 -04:00
AbbanMustafa 7c5190a4ab [build-tools] Apply npm cache registry only to the dependency install 2026-06-30 23:39:51 -04:00
Stanisław Chmiela 9247f3cf78 [eas-cli] Upgrade apple-utils to 2.1.22 (#3913)
## Summary

- [eas-cli] Upgrade `@expo/apple-utils` from `2.1.19` to `2.1.22`.
- Refresh the Yarn lockfile entry for the new package tarball.
- Add a changelog entry under the unreleased `main` section.

## Why

This picks up the latest published Apple API helpers, including recent App Store Connect pagination, SIWA, and review-related fixes from `@expo/apple-utils`.

## Validation

CI should pass.
2026-06-30 18:33:59 +02:00
Stanisław Chmiela bde331da63 [eas-cli] Fix managed tvOS env typecheck (#3920)
## Summary
- fix main typecheck after `Env` was widened to allow undefined values
- align `getManagedTvBuildSettings` with the shared `Env` type instead of requiring `Record<string, string>`

## Test plan

CI should pass.
2026-06-30 12:13:25 +00:00
Stanisław Chmiela fd055f08b3 [eas-build-job] Allow undefined env values (#3900)
## Summary

Widens the shared build `Env` type to allow `undefined` values, matching Node/process env semantics where undefined entries can represent absent values.

## Details

- Changes `Env` to `Record<string, string | undefined>` in `@expo/eas-build-job`.
- Allows `ctx.reportError` extras to contain undefined values for diagnostic metadata.
- Updates strict string boundaries to explicitly require values where needed, such as GraphQL IDs and static context API URLs.
- Filters undefined values out of `displayRuntimeInfo` string-only display maps.

## Validation

CI should pass.
2026-06-30 14:03:53 +02:00
Stanisław Chmiela 785d690e65 [build-tools] Enable simulator accessibility prefs before boot (#3914)
## Summary

- Add an opt-in `enable_accessibility_settings` boolean input to `eas/start_ios_simulator`.
- When enabled, call `IosSimulatorUtils.enableAccessibilitySettingsAsync` inline immediately before each `IosSimulatorUtils.startAsync` call, including cloned simulators.
- Reuse the existing `xcrun simctl list devices --json --no-escape-slashes available` wrapper so the `dataPath` from simctl JSON is used directly, then do an exact UDID/name equality check in JS. This avoids `simctl` search-term substring matches like `iPhone 17` matching `iPhone 17 Pro`.
- Treat invalid requested simulator state as `UserError`; wrap command/filesystem setup failures as `SystemError`.

## Why

These accessibility preferences help Argent and other AX-tree based tools inspect Simulator state more reliably. They can influence app/runtime accessibility behavior, so the setting is explicit and default-off rather than applied to every simulator startup.

## Maestro compatibility

`enable_accessibility_settings` defaults to `false`, so existing Maestro test runs and other `eas/start_ios_simulator` users keep the previous startup behavior unless they opt in. When enabled, the setup only writes preferences for an exact, available, shutdown simulator before `simctl bootstatus -b`. If the requested simulator is not available or not shutdown, the step fails with `UserError`; if the setup command/filesystem work fails, it fails with `SystemError`.

## Notes

- `plutil` is a standard macOS utility (`/usr/bin/plutil` on the local validation machine), so this does not add an external tool installation requirement.

## Validation

CI should pass.
2026-06-30 11:02:21 +02:00
Kudo Chien 47335f02f0 [build-tools] Fix repack AAB output path (#3892) 2026-06-30 14:38:06 +08:00
Douglas Lowder 75d9be813f [eas-cli] Resolve ApplePlatform.TV_OS for managed tvOS targets via EXPO_TV (#3907)
# Why

Non interactive builds were attempting to incorrectly verify tvOS provisioning profiles with settings for the iOS platform.

# How

Recognize when EXPO_TV env variable is set, and in that case, set the target platform to tvOS.

Fixes #3905

# Test Plan

- Tested with a real SDK 56 TV project
- New unit tests added
2026-06-29 18:32:47 -07:00
Expo CI cdaa011ce3 update CHANGELOG.md 2026-06-29 21:45:52 +00:00
Expo CI ee6e197587 v20.5.0 2026-06-29 21:43:22 +00:00
Gabe Debes 63baff4daf [eas-cli] Connect existing PostHog accounts via browser handoff (#3895) 2026-06-29 14:33:53 -07:00
Stanisław Chmiela 85a4054b17 [eas-cli] Use simulator session dashboard URLs (#3904)
## Why

The Expo dashboard route for device run sessions moved to the simulator sessions surface, so EAS CLI should send users to that page and use the simulator-session terminology in user-facing output.

## How

- Add a dashboard URL helper that points device run sessions at `/simulator-sessions/{id}`.
- Update `eas simulator:{start,get,list}` output and JSON to expose `deviceRunSessionUrl` using the simulator sessions route.
- Update user-facing simulator command copy from "device run session" to "simulator session".

## Test Plan

CI should pass.
2026-06-29 16:03:04 +02:00
Stanisław Chmiela 15cd0c5ae2 [worker] Disable Homebrew auto-update in EAS (#3912)
# Why

I don't think auto-updating Homebrew in CI makes sense. It creates a harder-to-reproduce / inconsistent environment.

# How

Added `HOMEBREW_NO_AUTO_UPDATE=1` to worker env.

# Test Plan

Next time https://expo.dev/accounts/gwdp/projects/expo-go-maestro/workflows/019f12db-23cd-78af-866b-3af410ccc10f runs I'll confirm `xcodes` does not update other formulas.
2026-06-29 13:39:00 +02:00
Stanisław Chmiela efc78f43d0 [ci] Update codemention action pin (#3903)
## Summary

Updates the codemention GitHub Action pin from `v1.4.0` to a reviewed post-`v1.5.2` upstream commit:

- `tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b`
- keeps the action pinned to an immutable commit SHA because this workflow runs on `pull_request_target` with `pull-requests: write`
- avoids pinning to moving `main` or a mutable tag

## Security review

I reviewed upstream `tobyhs/codemention` changes from `v1.4.0` through `v1.5.2` and found that `v1.5.2` introduced `[email protected]`, which currently has critical/high production dependency advisories. I therefore did not pin to `v1.5.2`.

The selected post-release commit includes follow-up dependency fixes on upstream `main`, including `[email protected]`, `[email protected]`, and `[email protected].0`. `npm audit --omit=dev` on that upstream commit reported 0 vulnerabilities at review time.

## Validation

- `yarn fmt:check .github/workflows/codemention.yml`
- YAML parse check
- `git diff --check`
2026-06-26 14:21:30 +02:00
a905a50f65 [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) (#3882)
* [eas-cli] Add tiered build-credit usage warnings (approaching/at/over)

Lowers the warning threshold from 85% to 80% to match email
notifications. Adds two new tiers beyond the existing "approaching":
one when the user has reached their included credits, and one when
they've exceeded them on a paid plan. Free plans collapse the "over"
case into the "at" message since Free is blocked at the limit.

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

* [eas-cli] Add CHANGELOG entry for tiered usage warnings (#3882)

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

---------

Co-authored-by: Sarah Lane <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-25 15:29:20 -06:00
Expo CI b36b5b36d7 update CHANGELOG.md 2026-06-25 21:14:47 +00:00
Expo CI f867b7b9f2 v20.4.0 2026-06-25 21:12:10 +00:00
David Mokos 99fba8c814 [eas-cli] share agent templates from llm-configs (#3865)
Syncs the generated app `AGENTS.md` and `CLAUDE.md` templates from `expo/llm-configs` during EAS CLI `prepack`, before the package is rebuilt for publishing. Keeps the synced templates bundled in EAS CLI so `eas project:new` still works offline and does not add a runtime `@expo/llm-configs` dependency.

Verified with `yarn workspace eas-cli sync-agent-templates`, byte-for-byte comparison against the upstream raw files, `yarn workspace eas-cli test src/commandUtils/new/__tests__/projectFiles.test.ts --runInBand`, `yarn workspace eas-cli typecheck-for-build`, and `yarn workspace eas-cli build`. Previously also ran a local `eas project:new` smoke test and confirmed generated agent files matched the shared templates.
2026-06-25 13:34:53 +02:00
Gabe Debes 49101c06c1 [eas-cli] Auto-create branch when channel has no branches mapped (#3891)
`eas update --channel <name>` was failing with `Channel has no branches associated with it` when the channel existed but had no branch mapped.

This trips up fresh projects: `eas build` auto-creates the channel without a branch, so by the time you run `eas update` the channel already exists. The CLI only ever auto-created a branch when the channel was *missing* (`ChannelNotFoundError`), so the existing-but-empty channel fell through to the error instead.

Now the empty-channel case does the same thing as the missing-channel case: create a branch with the channel's name and map it.

## Test plan

- Added a unit test for the empty-channel case
- Ran `eas update --channel <empty channel>` against a real project: the old CLI fails with the error above, this build creates the branch and publishes the update
2026-06-24 09:18:11 -07:00
Ash Wu 4071f4834c [build-tools] add metadata to eas/upload_artifact (#3878) 2026-06-22 17:59:44 +08:00
Ash Wu 55fe28cf63 [build-tools] upload maestro screenshots (#3872)
Signed-off-by: Ash Wu <[email protected]>
2026-06-22 17:13:29 +08:00
Stanisław Chmiela 88a404e45f [build-tools] Skip Xcode selection in development (#3884)
This was needed for running in development where `sudo` is NOT passwordless and waits for password input.

## Summary
- skip `sudo xcode-select` for remote session startup when `ENVIRONMENT=development`
- centralize Xcode developer directory selection for agent-device, Argent, and serve-sim remote sessions
- run `sudo xcode-select` with stdin ignored and stdout/stderr piped to the step logger outside development

## Test plan
- yarn workspace @expo/build-tools test src/steps/utils/__tests__/remoteDeviceRunSession.test.ts
- yarn workspace @expo/build-tools typecheck
2026-06-19 14:15:18 +02:00
Stanisław Chmiela cb2daddbfc [build-tools] Fix Argent artifact upload size (#3883)
Since https://github.com/software-mansion/argent/pull/347 Argent exposes a `GET /artifacts/` endpoint which returns a `size` for every artifact. This `size` is then being used to create a `www` upload session (which validates and includes the `size` as `Content-Length` of the signed upload URL).

The `size` is being calculated in Argent as `fs.stat()` which works great for single files, but for directories it returns 96 bytes. The actual archiving of a given directory (producing an artifact) is only happening when downloading the artifact with `GET /artifacts/:id`.

This pull request changes the artifacts behavior around Argent from:
- list artifacts
- trust `size`
- pipe `GET /artifacts/:id` directly to signed upload URL

to:
- list artifacts
- download from `GET /artifacts/:id` to temporary directory
- check the `size` of the actually downloaded file
- pipe it to signed upload URL.

## Test

You can see which traces have been created _with_ the fix and which _without_ it.

<img width="445" height="354" alt="Zrzut ekranu 2026-06-19 o 13 03 01" src="https://github.com/user-attachments/assets/fddecddf-419c-46a1-8876-599bc00ea8ce" />
2026-06-19 12:02:16 +00:00
Stanisław Chmiela b2388fdfa0 [build-tools] Add Argent remote session artifact uploads (#3880)
## Summary
- bump the minimum supported Argent remote-session version to the latest published `@swmansion/argent` version (`0.12.0`)
- enable Argent's artifact inventory endpoint before starting the tool-server
- poll the local Argent tool-server every 5 seconds for new artifacts and stream each new artifact to the EAS device-run-session artifact upload backend
- add focused tests for version validation, artifact inventory listing, and signed upload streaming

## Testing

CI should pass.
2026-06-19 09:40:58 +02:00
Douglas Lowder 0986682159 [eas-cli] Add observe:session for inspecting events by session ID (#3868)
# Why

New command for retrieving and analyzing events in a session.

```
USAGE
  $ eas observe:session SESSIONID [--project-id <value>] [--json] [--non-interactive]

ARGUMENTS
  SESSIONID  Session ID to inspect

FLAGS
  --json                Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --non-interactive     Run the command in non-interactive mode.
  --project-id=<value>  EAS project ID (defaults to the project ID of the current directory)

DESCRIPTION
  display the timeline of metric and log events for a specific session

```

# How

Query both metrics and logs, filtered by the session ID passed in. 

Example results:

```
# Results for an app startup session (main session)
easd observe:session 2bb44b16-7650-4efd-8ec8-a1bd75ee81dc

App version: 1.1.0 (31)
Device:      Pixel 7 · Android 13
First seen:  May 11, 2026, 04:12:07.662 AM
Last seen:   May 11, 2026, 04:13:04.886 AM

Offset  Type    Name          Value  Properties         Severity
------  ------  ------------  -----  -----------------  --------
0.00s   metric  Bundle Load   0.77s  -                  -       
0.05s   log     app.open      -      cold_start=false   INFO    
0.15s   metric  Startup TTR   1.38s  -                  -       
0.45s   metric  Startup TTI   2.16s  -                  -       
0.55s   metric  Warm Launch   1.29s  -                  -       
0.95s   log     screen.view   -      screen=profile     INFO    
17.16s  log     habit.logged  -      habit_id=meditate  INFO    
                                     streak_days=12             
56.59s  log     screen.view   -      screen=streak      INFO    
57.22s  log     screen.view   -      screen=paywall     INFO

# Results for a navigation session with routes
$ eas observe:session 7e57f6cd-f8e9-4fe0-b6a5-934856d61663

App version: 1.1.0 (31)
Device:      iPhone16,1 · iOS 17.5
First seen:  May 6, 2026, 06:54:35.820 AM
Last seen:   May 6, 2026, 06:56:11.753 AM

Offset  Type    Name                           Value / Severity
------  ------  -----------------------------  ----------------
0.00s   metric  Nav Cold TTR · /task/[taskId]  0.51s           
0.08s   metric  Nav TTI · /task/[taskId]       0.53s           
4.78s   metric  Nav Warm TTR · /stats          0.55s           
4.84s   metric  Nav TTI · /stats               0.57s           
33.66s  metric  Nav Warm TTR · /stats          0.53s           
33.72s  metric  Nav TTI · /stats               0.47s           
53.96s  metric  Nav Warm TTR · /stats          0.24s           
54.02s  metric  Nav TTI · /stats               0.82s           
78.61s  metric  Nav Warm TTR · /new            0.21s           
78.67s  metric  Nav TTI · /new                 0.27s           
95.87s  metric  Nav Warm TTR · /new            0.08s           
95.93s  metric  Nav TTI · /new                 0.20s

```

# Test Plan

- Tested against a project in staging.
- New unit tests added.
2026-06-18 13:19:55 -07:00
Expo CI 24354688b6 update CHANGELOG.md 2026-06-18 17:47:10 +00:00
94 changed files with 7054 additions and 1069 deletions

No files matched your search

+5 -5
View File
@@ -12,9 +12,9 @@ jobs:
contents: read
pull-requests: write
steps:
# Pinned to a commit SHA (not the v1.4.0 tag) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag from
# a compromised upstream account would give the attacker repo write access.
- uses: tobyhs/codemention@bb6bfb2c3ff1e6fee7ee37006bbee6d114057225 # v1.4.0
# Pinned to a commit SHA (not a tag or branch) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag or
# moving branch would allow unreviewed upstream code to run here.
- uses: tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b # post-v1.5.2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
githubToken: ${{ secrets.GITHUB_TOKEN }}
+30
View File
@@ -8,6 +8,35 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Bump `@expo/apple-utils` to `2.1.22`. ([#3913](https://github.com/expo/eas-cli/pull/3913) by [@sjchmiela](https://github.com/sjchmiela))
## [20.5.0](https://github.com/expo/eas-cli/releases/tag/v20.5.0) - 2026-06-29
### 🎉 New features
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
## [20.4.0](https://github.com/expo/eas-cli/releases/tag/v20.4.0) - 2026-06-25
### 🎉 New features
- [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) before each build, lowering the warning threshold to 80% to match email notifications. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
### 🐛 Bug fixes
- [eas-cli] Fix `eas update --channel` failing with "Channel has no branches associated with it" when the channel was auto-created by a build. A branch with the channel's name is now created and linked, matching the behavior for a channel that doesn't exist yet. ([#3891](https://github.com/expo/eas-cli/pull/3891) by [@gwdp](https://github.com/gwdp))
## [20.3.0](https://github.com/expo/eas-cli/releases/tag/v20.3.0) - 2026-06-18
### 🎉 New features
- [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))
@@ -20,6 +49,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🧹 Chores
- [eas-cli] Sync new app agent templates from `expo/llm-configs` before publishing. ([#3865](https://github.com/expo/eas-cli/pull/3865) by [@davidmokos](https://github.com/davidmokos))
- [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
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "20.3.0",
"version": "20.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "20.3.0",
"version": "20.4.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -40,7 +40,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
NPM_CONFIG_REGISTRY: npmCacheUrl,
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
@@ -56,6 +56,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
@@ -66,6 +67,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
});
expect(logger.warn).toHaveBeenCalledWith(
@@ -110,7 +112,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
NPM_CONFIG_REGISTRY: npmCacheUrl,
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
@@ -158,7 +160,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
NPM_CONFIG_REGISTRY: 'http://npm.staging.caches.eas-build.internal',
EAS_BUILD_NPM_CACHE_URL: 'http://npm.staging.caches.eas-build.internal',
},
logger,
cwd: '/tmp/build',
@@ -170,7 +172,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('does not retry when EAS_USE_NPM_CACHE is not enabled', async () => {
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
@@ -188,9 +190,7 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
env: {},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
@@ -200,6 +200,40 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('installs through the npm cache registry without retrying when the cache install succeeds', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--include=dev'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
@@ -93,7 +93,24 @@ export async function installDependenciesWithNpmCacheFallbackAsync({
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
useFrozenLockfile: boolean;
}): Promise<void> {
const npmCacheUrl = env.NPM_CONFIG_REGISTRY;
const npmCacheUrl = env.EAS_BUILD_NPM_CACHE_URL;
if (!npmCacheUrl) {
await (
await installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
})
).spawnPromise;
return;
}
logger.info(`Installing dependencies using the npm cache registry (${npmCacheUrl}).`);
let firstErrorLine: string | undefined;
let errorLineCount = 0;
@@ -101,11 +118,11 @@ export async function installDependenciesWithNpmCacheFallbackAsync({
await (
await installDependenciesAsync({
packageManager,
env,
env: { ...env, NPM_CONFIG_REGISTRY: npmCacheUrl },
logger,
infoCallbackFn,
lineTransformer: (line: string) => {
if (isNpmCacheRegistryErrorLine(line, { env, npmCacheUrl })) {
if (isNpmCacheRegistryErrorLine(line, npmCacheUrl)) {
firstErrorLine ??= line;
errorLineCount += 1;
}
@@ -124,7 +141,7 @@ export async function installDependenciesWithNpmCacheFallbackAsync({
});
}
} catch (err: unknown) {
if (!isNpmCacheInstallFailure(err, { env, npmCacheUrl })) {
if (!isNpmCacheInstallFailure(err, npmCacheUrl)) {
throw err;
}
@@ -144,11 +161,10 @@ export async function installDependenciesWithNpmCacheFallbackAsync({
},
});
const { NPM_CONFIG_REGISTRY: _NPM_CONFIG_REGISTRY, ...fallbackEnv } = env;
await (
await installDependenciesAsync({
packageManager,
env: fallbackEnv,
env,
logger,
infoCallbackFn,
cwd,
@@ -172,35 +188,17 @@ 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 isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(
line: string,
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
): boolean {
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): 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 '';
@@ -233,7 +233,7 @@ async function uploadProjectMetadataAsync(
}
`),
{
buildId: ctx.env.EAS_BUILD_ID,
buildId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
projectMetadataFile: {
type: 'GCS',
bucketKey: uploadSession.bucketKey,
+3 -2
View File
@@ -45,6 +45,7 @@ export type ArtifactToUpload =
type: GenericArtifactType;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
};
export interface BuildContextOptions {
@@ -62,7 +63,7 @@ export interface BuildContextOptions {
reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
@@ -79,7 +80,7 @@ export class BuildContext<TJob extends Job = Job> {
public readonly reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
+1 -1
View File
@@ -93,7 +93,7 @@ export async function runFastlane(
cwd,
}: {
logger?: bunyan;
env?: Record<string, string>;
env?: Env;
cwd?: string;
} = {}
): Promise<SpawnResult> {
@@ -98,7 +98,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(),
createMaestroTestsBuildFunction(ctx),
];
if (ctx.hasBuildJob()) {
@@ -0,0 +1,132 @@
import { BuildStepContext, BuildStepEnv } from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
PackageManager,
findPackagerRootDir,
resolvePackageManager,
shouldUseFrozenLockfile,
} from '../../../utils/packageManager';
import { readPackageJson } from '../../../utils/project';
import { installNodeModules } from '../installNodeModules';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
findPackagerRootDir: jest.fn(),
shouldUseFrozenLockfile: jest.fn(),
}));
jest.mock('../../../utils/project', () => ({
...jest.requireActual('../../../utils/project'),
readPackageJson: jest.fn(),
}));
describe(installNodeModules, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(findPackagerRootDir).mockReturnValue('/tmp/build');
jest.mocked(shouldUseFrozenLockfile).mockReturnValue(false);
jest.mocked(readPackageJson).mockReturnValue({});
});
function createStepCtx(logger: ReturnType<typeof createMockLogger>): BuildStepContext {
return {
logger,
workingDirectory: '/tmp/build',
global: {
projectTargetDirectory: '/tmp/build',
staticContext: { metadata: {} },
},
} as unknown as BuildStepContext;
}
it('retries the install without the npm cache registry when the cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.caches.eas-build.internal';
const env: BuildStepEnv = {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
};
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND`,
});
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installNodeModules(createStepCtx(logger), env);
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--include=dev'],
expect.objectContaining({
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
})
);
expect(spawn).toHaveBeenNthCalledWith(
2,
'npm',
['install', '--include=dev'],
expect.objectContaining({
env: { EAS_USE_NPM_CACHE: '1', EAS_BUILD_NPM_CACHE_URL: npmCacheUrl },
})
);
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
});
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
status: 1,
signal: null,
stdout: '',
stderr: 'npm error some unrelated failure',
});
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(installNodeModules(createStepCtx(logger), {})).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
});
});
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,287 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
parseFailureScreenshotFilename,
selectFailureScreenshots,
} from '../maestroScreenshots';
describe(parseFailureScreenshotFilename, () => {
it('parses a plain failure screenshot', () => {
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
flowName: 'Login Flow',
capturedAtMs: 1781186692250,
});
});
it('parses a sharded failure screenshot', () => {
expect(
parseFailureScreenshotFilename('screenshot-shard-2-❌-1781186692250-(Login Flow).png')
).toEqual({
flowName: 'Login Flow',
capturedAtMs: 1781186692250,
});
});
it('handles parentheses inside the flow name (right-to-left parse)', () => {
expect(
parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login (staging) Flow).png')
).toEqual({
flowName: 'Login (staging) Flow',
capturedAtMs: 1781186692250,
});
});
it('rejects non-failure screenshots (✅, ⚠️) and unrelated files', () => {
expect(
parseFailureScreenshotFilename('screenshot-✅-1781186692250-(Login Flow).png')
).toBeNull();
expect(parseFailureScreenshotFilename('commands-(Login Flow).json')).toBeNull();
expect(parseFailureScreenshotFilename('android-maestro-junit-attempt-0.xml')).toBeNull();
});
});
describe(harvestFailureScreenshotsAsync, () => {
const logger = createMockLogger();
let testsDirectory: string;
beforeEach(async () => {
testsDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-harvest-test-'));
});
afterEach(async () => {
await fs.rm(testsDirectory, { recursive: true, force: true });
});
async function makeDebugDir(name: string, files: string[], mtimeMs: number): Promise<void> {
const dir = path.join(testsDirectory, name);
await fs.mkdir(dir);
for (const file of files) {
await fs.writeFile(path.join(dir, file), '');
}
const when = new Date(mtimeMs);
await fs.utimes(dir, when, when);
}
it('returns shots from dirs modified at/after sinceMtimeMs, attributed to the attempt', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'attempt-dir',
['screenshot-❌-1781186692250-(Login Flow).png', 'commands.json'],
sinceMtimeMs + 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([
{
fileAbsPath: path.join(
testsDirectory,
'attempt-dir',
'screenshot-❌-1781186692250-(Login Flow).png'
),
displayName: 'Failure Screenshot: Login Flow (attempt 2)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login Flow',
attemptIndex: 1,
capturedAtMs: 1781186692250,
},
},
]);
});
it('ignores dirs older than sinceMtimeMs (previous attempts)', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'old-dir',
['screenshot-❌-1781186692250-(Old Flow).png'],
sinceMtimeMs - 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots).toEqual([]);
});
it('scans multiple new dirs (maestro <2.5.0 split-dir bug)', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'dir-a',
[`screenshot-❌-${sinceMtimeMs + 100}-(Flow A).png`],
sinceMtimeMs + 1_000
);
await makeDebugDir(
'dir-b',
[`screenshot-❌-${sinceMtimeMs + 200}-(Flow B).png`],
sinceMtimeMs + 2_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual(['Flow A', 'Flow B']);
});
it('keeps the disk flow name (slashes already substituted) and strips the shard prefix', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'sharded-dir',
['screenshot-shard-3-❌-1781186692250-(Login_Sub Flow).png'],
sinceMtimeMs + 1_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].metadata.flowName).toBe('Login_Sub Flow');
});
it('ignores a stale screenshot whose capturedAtMs predates sinceMtimeMs (dir touched later)', async () => {
const sinceMtimeMs = 1_000_000;
// The dir mtime is AFTER this attempt started (so it passes the dir gate), but the
// screenshot inside was captured BEFORE it — a prior attempt's shot in a dir touched later.
await makeDebugDir(
'touched-dir',
['screenshot-❌-999000-(Stale Flow).png'],
sinceMtimeMs + 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([]);
});
it('logs and returns [] when the tests dir cannot be read', async () => {
const shots = await harvestFailureScreenshotsAsync({
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
capturedSinceMs: 0,
attemptIndex: 0,
logger,
});
expect(shots).toEqual([]);
});
});
describe(computePureFailureFlowNames, () => {
it('classifies a flow that failed every attempt as pure', () => {
const pure = computePureFailureFlowNames([
{ name: 'Login Flow', status: 'failed' },
{ name: 'Login Flow', status: 'failed' },
]);
expect([...pure]).toEqual(['Login Flow']);
});
it('excludes a flaky flow (failed then passed)', () => {
const pure = computePureFailureFlowNames([
{ name: 'Flaky Flow', status: 'failed' },
{ name: 'Flaky Flow', status: 'passed' },
]);
expect(pure.size).toBe(0);
});
it('excludes a fail-pass-fail flow (it passed at some point)', () => {
const pure = computePureFailureFlowNames([
{ name: 'FPF', status: 'failed' },
{ name: 'FPF', status: 'passed' },
{ name: 'FPF', status: 'failed' },
]);
expect(pure.size).toBe(0);
});
it('excludes a flow that only passed', () => {
expect(computePureFailureFlowNames([{ name: 'Happy', status: 'passed' }]).size).toBe(0);
});
it('normalizes the JUnit name to the screenshot form (/ -> _)', () => {
const pure = computePureFailureFlowNames([{ name: 'sub/Login', status: 'failed' }]);
expect([...pure]).toEqual(['sub_Login']);
});
it('excludes flows that collide only after / -> _ normalization (one failed, one passed)', () => {
const pure = computePureFailureFlowNames([
{ name: 'sub/Login', status: 'failed' },
{ name: 'sub_Login', status: 'passed' },
]);
expect(pure.size).toBe(0);
});
it('returns an empty set for no testcases', () => {
expect(computePureFailureFlowNames([]).size).toBe(0);
});
});
describe(selectFailureScreenshots, () => {
function makeShot(flowName: string, attemptIndex: number): HarvestedScreenshot {
return {
fileAbsPath: `/tmp/${flowName}-${attemptIndex}.png`,
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex,
capturedAtMs: 1_000 + attemptIndex,
},
};
}
it('keeps only the final attempt for a pure-failure flow', () => {
const shots = [makeShot('Pure', 0), makeShot('Pure', 1), makeShot('Pure', 2)];
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([2]);
});
it('keeps every attempt for a flow not classified pure (flaky)', () => {
const shots = [makeShot('Flaky', 0), makeShot('Flaky', 1)];
const selected = selectFailureScreenshots(shots, new Set());
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([0, 1]);
});
it('reduces pure flows while preserving flaky flows and input order', () => {
const shots = [
makeShot('Pure', 0),
makeShot('Flaky', 0),
makeShot('Pure', 1),
makeShot('Flaky', 1),
];
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
expect(selected.map(shot => `${shot.metadata.flowName}#${shot.metadata.attemptIndex}`)).toEqual(
['Flaky#0', 'Pure#1', 'Flaky#1']
);
});
it('keeps all when the pure set is empty', () => {
const shots = [makeShot('A', 0), makeShot('A', 1)];
expect(selectFailureScreenshots(shots, new Set())).toHaveLength(2);
});
});
@@ -1,10 +1,15 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import * as discovery from '../maestroFlowDiscovery';
import * as parser from '../maestroResultParser';
import * as maestroScreenshots from '../maestroScreenshots';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
@@ -17,7 +22,29 @@ jest.mock('../../../utils/retry', () => ({
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
// Partial mock: stub the harvest (filesystem) but keep the real reduction helpers
// (computePureFailureFlowNames / selectFailureScreenshots) the step now calls post-loop.
jest.mock('../maestroScreenshots', () => ({
...jest.requireActual('../maestroScreenshots'),
harvestFailureScreenshotsAsync: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
const mockUploadArtifact = jest.fn();
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
return {
fileAbsPath: path.join(os.tmpdir(), `src-screenshot-${index}.png`),
displayName: 'Failure Screenshot: Login (attempt 1)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login',
attemptIndex: 0,
capturedAtMs: 1781186692250,
},
};
}
const SPAWN_SUCCESS = {
status: 0,
@@ -41,7 +68,10 @@ function createStep(
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const fn = createMaestroTestsBuildFunction();
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext;
const fn = createMaestroTestsBuildFunction(ctx);
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
@@ -54,10 +84,18 @@ describe('createMaestroTestsBuildFunction', () => {
// hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
mockedHarvest.mockReset();
mockedHarvest.mockResolvedValue([]);
mockUploadArtifact.mockReset();
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(createMaestroTestsBuildFunction()).toBeDefined();
expect(
createMaestroTestsBuildFunction({
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext)
).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
@@ -704,4 +742,149 @@ describe('createMaestroTestsBuildFunction', () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
it('uploads harvested screenshots after the retry loop with metadata', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
})
);
});
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('swallows upload errors without affecting the test verdict', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
mockUploadArtifact.mockRejectedValue(new Error('upload boom'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('skips screenshot upload but preserves the verdict when the JUnit re-parse throws', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
mockedHarvest.mockResolvedValue([makeShot(0)]);
jest.spyOn(parser, 'parseJUnitTestCases').mockRejectedValue(new Error('malformed junit'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
// The classify throw is swallowed; the maestro failure verdict still surfaces.
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('skips screenshot upload but preserves the verdict when the staging dir cannot be created', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
mockedHarvest.mockResolvedValue([makeShot(0)]);
jest.spyOn(parser, 'parseJUnitTestCases').mockResolvedValue([]);
jest.spyOn(fs, 'mkdtemp').mockRejectedValue(new Error('no tmp space'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
// mkdtemp failure is swallowed; the maestro failure verdict still surfaces.
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('caps uploads at 30 screenshots (excess dropped)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shots = Array.from({ length: 35 }, (_, index) => makeShot(index));
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
mockedHarvest.mockResolvedValue(shots);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots when output_format is not junit', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
await step.executeAsync();
expect(mockedHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const tc = (name: string, status: 'passed' | 'failed'): parser.JUnitTestCaseResult => ({
name,
file: undefined,
status,
duration: 0,
errorMessage: null,
tags: [],
properties: {},
});
jest
.spyOn(parser, 'parseJUnitTestCases')
.mockResolvedValue([
tc('PureFlow', 'failed'),
tc('FlakyFlow', 'failed'),
tc('FlakyFlow', 'passed'),
]);
const makeShotFor = (
flowName: string,
attemptIndex: number
): maestroScreenshots.HarvestedScreenshot => ({
fileAbsPath: path.join(os.tmpdir(), `wiring-${flowName}-${attemptIndex}.png`),
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex,
capturedAtMs: 1781186692250 + attemptIndex,
},
});
const shots = [
makeShotFor('PureFlow', 0),
makeShotFor('PureFlow', 1),
makeShotFor('FlakyFlow', 0),
makeShotFor('FlakyFlow', 1),
];
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
mockedHarvest.mockResolvedValue(shots);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
// PureFlow keeps only its final attempt (1); FlakyFlow keeps every failed attempt.
// Uploads run concurrently, so compare the set, not the order.
const uploaded = mockUploadArtifact.mock.calls
.map(([arg]) => `${arg.artifact.metadata.flowName}#${arg.artifact.metadata.attemptIndex}`)
.sort();
expect(uploaded).toEqual(['FlakyFlow#0', 'FlakyFlow#1', 'PureFlow#1']);
});
});
@@ -27,7 +27,7 @@ describe(createRepackBuildFunction, () => {
vol.reset();
});
it('should set the output path for successful repack', async () => {
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
createGlobalContextMock({
@@ -38,14 +38,33 @@ describe(createRepackBuildFunction, () => {
{
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app',
output_path: '/path/to/output_app',
source_app_path: '/path/to/source_app.ipa',
},
}
);
await repackStep.executeAsync();
expect(repackStep.outputById['output_path'].value).toBe('/path/to/output_app');
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.ipa$/);
});
it('should rename generated aab output path to apk', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
createGlobalContextMock({
staticContextContent: {
job: createTestAndroidJob(),
},
}),
{
callInputs: {
platform: 'android',
source_app_path: '/path/to/source_app.aab',
},
}
);
await repackStep.executeAsync();
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.apk$/);
});
it('should throw for unsupported platforms', async () => {
@@ -1,11 +1,16 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { warnIfArgentPackageVersionCannotBeVerified } from '../startArgentRemoteSession';
import {
MIN_ARGENT_REMOTE_SESSION_VERSION,
warnIfArgentPackageVersionCannotBeVerified,
} from '../startArgentRemoteSession';
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
const supportedVersion = '999.0.0';
const oldVersion = '0.0.0';
beforeEach(() => {
warn.mockClear();
@@ -23,24 +28,30 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
it('allows exact supported semver versions', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '0.11.0', logger })
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
logger,
})
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '0.11.1', logger })
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'v0.11.1', logger })
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `v${supportedVersion}`,
logger,
})
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '0.10.9', logger })
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '0.10.9', logger })
).toThrow(/Use "latest" or pass an exact version >= 0\.11\.0/);
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
@@ -48,7 +59,10 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: '^0.11.0', logger })
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
logger,
})
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
@@ -15,6 +15,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
cloneAsync: jest.fn(),
enableAccessibilitySettingsAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableApsdAsync: jest.fn(),
@@ -34,15 +35,59 @@ function createStep(callInputs?: Record<string, unknown>) {
describe(createStartIosSimulatorBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
});
it('does not enable accessibility settings by default', async () => {
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
});
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({
device_identifier: 'iPhone 15',
count: 2,
enable_accessibility_settings: true,
}).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'eas-simulator-1',
env: expect.any(Object),
});
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'eas-simulator-2',
env: expect.any(Object),
});
for (const [callIndex] of mockedUtils.startAsync.mock.calls.entries()) {
const enableCallOrder =
mockedUtils.enableAccessibilitySettingsAsync.mock.invocationCallOrder[callIndex];
const startCallOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
expect(enableCallOrder).toBeLessThan(startCallOrder);
}
});
it('disables apsd on the main device and every clone', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
@@ -120,4 +120,44 @@ describe(createUploadArtifactBuildFunction, () => {
const typeInput = buildStep.inputs?.find(input => input.id === 'type')!;
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
type: 'build-artifact',
path: '/',
metadata: { kind: 'screen-recording' },
},
});
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.not.objectContaining({ metadata: expect.anything() }),
})
);
});
});
@@ -1,6 +1,7 @@
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { Sentry } from '../../sentry';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
export function createSubmissionEntityFunction(): BuildFunction {
@@ -54,18 +55,42 @@ export function createSubmissionEntityFunction(): BuildFunction {
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
if (!robotAccessToken) {
stepsCtx.logger.error('Failed to create submission entity: no robot access token found');
Sentry.capture('Failed to create submission entity: missing robot access token');
return;
}
const buildId = inputs.build_id.value;
if (!buildId) {
stepsCtx.logger.error('Failed to create submission entity: no build ID provided');
Sentry.capture('Failed to create submission entity: missing build ID', {
extras: {
buildId,
},
});
return;
}
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
if (!workflowJobId) {
stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found');
Sentry.capture('Failed to create submission entity: missing workflow job ID', {
extras: {
buildId,
workflowJobId,
},
});
return;
}
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
if (!expoApiServerURL) {
stepsCtx.logger.error('Failed to create submission entity: no Expo API server URL found');
Sentry.capture('Failed to create submission entity: missing Expo API server URL', {
extras: {
buildId,
workflowJobId,
},
});
return;
}
@@ -83,7 +108,7 @@ export function createSubmissionEntityFunction(): BuildFunction {
try {
const response = await retryOnDNSFailure(fetch)(
new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL),
new URL('/v2/app-store-submissions/', expoApiServerURL),
{
method: 'POST',
headers: {
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -56,18 +56,23 @@ export function createDownloadArtifactFunction(): BuildFunction {
const interpolationContext = stepsCtx.global.getInterpolationContext();
if (!('workflow' in interpolationContext)) {
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
'No workflow found in the interpolation context.'
);
throw new SystemError('No workflow found in the interpolation context.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
});
}
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
if (!robotAccessToken) {
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
'No robot access token found in the job secrets.'
);
throw new SystemError('No robot access token found in the job secrets.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
});
}
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
if (!expoApiServerURL) {
throw new SystemError('Missing Expo API server URL.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_EXPO_API_SERVER_URL',
});
}
const workflowRunId = interpolationContext.workflow.id;
@@ -82,7 +87,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
const { artifactPath } = await downloadArtifactAsync({
logger,
workflowRunId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
params,
});
@@ -1,7 +1,7 @@
import { BuildFunction, BuildStepContext, BuildStepEnv } from '@expo/steps';
import path from 'path';
import { installDependenciesAsync } from '../../common/installDependencies';
import { installDependenciesWithNpmCacheFallbackAsync } from '../../common/installDependencies';
import {
findPackagerRootDir,
getPackageVersionFromPackageJson,
@@ -63,7 +63,7 @@ export async function installNodeModules(
packageName: 'react-native',
});
const { spawnPromise } = await installDependenciesAsync({
await installDependenciesWithNpmCacheFallbackAsync({
packageManager,
env,
logger: stepCtx.logger,
@@ -74,5 +74,4 @@ export async function installNodeModules(
reactNativeVersion,
}),
});
await spawnPromise;
}
@@ -0,0 +1,159 @@
import { bunyan } from '@expo/logger';
import { type Dirent } from 'fs';
import fs from 'fs/promises';
import path from 'path';
export interface ParsedFailureScreenshot {
flowName: string;
capturedAtMs: number;
}
// Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
// `).png` suffix rather than scanning for parens.
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
export function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null {
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
if (!match) {
return null;
}
const capturedAtMs = Number(match[1]);
if (!Number.isFinite(capturedAtMs)) {
return null;
}
return {
flowName: match[2],
capturedAtMs,
};
}
export interface HarvestedScreenshot {
fileAbsPath: string;
displayName: string; // `Failure Screenshot: <flowName> (attempt <n>)`
metadata: {
kind: 'maestro-test-screenshot';
// The Maestro flow name straight from the filename (already '/'->'_' substituted by
// Maestro). The website matches this against `attempt.name` (normalized the same way).
flowName: string;
attemptIndex: number;
capturedAtMs: number;
};
}
// Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
// the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
// whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
// and skipped so a screenshot harvest can't affect the maestro step outcome.
export async function harvestFailureScreenshotsAsync(args: {
testsDirectory: string;
capturedSinceMs: number;
attemptIndex: number;
logger: bunyan;
}): Promise<HarvestedScreenshot[]> {
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
// prevents mis-attributing an earlier attempt's screenshot.
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
let entries: Dirent[];
try {
entries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
} catch (err: any) {
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
return [];
}
const results: HarvestedScreenshot[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const dirPath = path.join(args.testsDirectory, entry.name);
let filenames: string[];
try {
if ((await fs.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
continue;
}
filenames = await fs.readdir(dirPath);
} catch (err: any) {
args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
continue;
}
for (const filename of filenames) {
const parsed = parseFailureScreenshotFilename(filename);
// Re-check capture time per file: a dir's mtime can advance after this attempt began (or
// fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
// earlier attempt's screenshot to this one.
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
continue;
}
results.push({
fileAbsPath: path.join(dirPath, filename),
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName: parsed.flowName,
attemptIndex: args.attemptIndex,
capturedAtMs: parsed.capturedAtMs,
},
});
}
}
return results;
}
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
// is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
// name the same way before comparing — matching the website's own normalization.
function normalizeFlowName(name: string): string {
return name.replace(/\//g, '_');
}
// A flow is a "pure failure" when it failed and never passed across all attempts. The website
// surfaces only the final screenshot for such a flow, so keeping its earlier attempts would
// spend the per-job artifact budget on screenshots that are never shown — we keep only the final
// one (selectFailureScreenshots). A flow that passed at least once (flaky / fail-pass-fail) is
// NOT pure: each of its failed attempts is a distinct failure and is kept.
export function computePureFailureFlowNames(
testCases: readonly { name: string; status: 'passed' | 'failed' }[]
): Set<string> {
const passed = new Set<string>();
const failed = new Set<string>();
for (const testCase of testCases) {
const flowName = normalizeFlowName(testCase.name);
if (testCase.status === 'passed') {
passed.add(flowName);
} else {
failed.add(flowName);
}
}
const pure = new Set<string>();
for (const flowName of failed) {
if (!passed.has(flowName)) {
pure.add(flowName);
}
}
return pure;
}
// Keep every harvested screenshot for flaky/mixed flows; for pure-failure flows keep only
// the final (highest attemptIndex) screenshot.
export function selectFailureScreenshots(
harvested: readonly HarvestedScreenshot[],
pureFailureFlowNames: ReadonlySet<string>
): HarvestedScreenshot[] {
const finalAttemptByPureFlow = new Map<string, number>();
for (const shot of harvested) {
if (!pureFailureFlowNames.has(shot.metadata.flowName)) {
continue;
}
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
if (finalAttempt === undefined || shot.metadata.attemptIndex > finalAttempt) {
finalAttemptByPureFlow.set(shot.metadata.flowName, shot.metadata.attemptIndex);
}
}
return harvested.filter(shot => {
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
return finalAttempt === undefined || shot.metadata.attemptIndex === finalAttempt;
});
}
@@ -1,4 +1,5 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -8,6 +9,7 @@ import {
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { z } from 'zod';
@@ -18,7 +20,15 @@ import {
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
} from './maestroResultParser';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
selectFailureScreenshots,
} from './maestroScreenshots';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -81,7 +91,7 @@ function buildMaestroArgs(args: {
return out;
}
export function createMaestroTestsBuildFunction(): BuildFunction {
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'maestro_tests',
@@ -217,6 +227,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// trusted; we then fall through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
const harvested: HarvestedScreenshot[] = [];
const totalAttempts = retries + 1;
for (let attempt = 0; attempt <= retries; attempt++) {
@@ -239,6 +250,8 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
);
const attemptStartedAtMs = Date.now();
try {
await spawn('maestro', maestroArgs, {
cwd: stepCtx.workingDirectory,
@@ -258,6 +271,20 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
}
}
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
// runs, so harvesting other formats would just create orphan artifacts the website hides.
if (outputFormat === 'junit') {
harvested.push(
...(await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: attemptStartedAtMs,
attemptIndex: attempt,
logger,
}))
);
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
@@ -330,6 +357,12 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
}
}
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
if (outputFormat === 'junit') {
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
}
// The retry loop exits via success (0), numeric status (retryable),
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
@@ -342,3 +375,82 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
},
});
}
// Reduce harvested failure screenshots to what's worth uploading, then upload them as workflow
// artifacts. Best-effort and verdict-neutral: never throws, so a screenshot problem can't mask
// the maestro test result. Caller guards on junit (harvest only runs for junit).
async function uploadFailureScreenshotsAsync({
harvested,
junitReportDirectory,
ctx,
logger,
}: {
harvested: HarvestedScreenshot[];
junitReportDirectory: string;
ctx: CustomBuildContext;
logger: bunyan;
}): Promise<void> {
// Reduce to the attempts worth uploading — every failed attempt for flaky flows, only the final
// attempt for all-failed flows. See computePureFailureFlowNames / selectFailureScreenshots.
// Guard the JUnit re-parse so a malformed/missing report can't throw past here and mask the
// test verdict (the whole step is verdict-neutral for screenshots).
let selected: HarvestedScreenshot[];
try {
const pureFailureFlowNames = computePureFailureFlowNames(
await parseJUnitTestCases(junitReportDirectory)
);
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
} catch (err: any) {
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
return;
}
if (selected.length === 0) {
return;
}
// Cap well under www's 50-artifact-per-job limit.
const MAX_SCREENSHOT_UPLOADS = 30;
const toUpload = selected.slice(0, MAX_SCREENSHOT_UPLOADS);
if (selected.length > toUpload.length) {
logger.warn(
`Found ${selected.length} failure screenshots; uploading only the first ${toUpload.length}.`
);
}
// Copy each shot to an ASCII-safe name outside testsDirectory (the originals contain a
// non-ASCII marker and testsDirectory is uploaded wholesale as the tarball).
let safeScreenshotDir: string;
try {
safeScreenshotDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-maestro-screenshots-'));
} catch (err: any) {
logger.warn(
{ err },
'Failed to create the failure-screenshot staging dir; skipping screenshot upload.'
);
return;
}
await Promise.all(
toUpload.map(async (shot, index) => {
try {
// `index` disambiguates two flows that fail within the same millisecond of an attempt.
const safePath = path.join(
safeScreenshotDir,
`failure-attempt-${shot.metadata.attemptIndex}-${index}-${shot.metadata.capturedAtMs}.png`
);
await fs.copyFile(shot.fileAbsPath, safePath);
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
name: shot.displayName,
paths: [safePath],
metadata: shot.metadata,
},
logger,
});
} catch (err: any) {
logger.warn({ err }, 'Failed to upload failure screenshot.');
}
})
);
}
@@ -42,11 +42,6 @@ export function createRepackBuildFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'output_path',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'embed_bundle_assets',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
@@ -109,9 +104,10 @@ export function createRepackBuildFunction(): BuildFunction {
stepsCtx.logger.info(`Created temporary working directory: ${workingDirectory}`);
const sourceAppPath = inputs.source_app_path.value as string;
const outputPath =
(inputs.output_path.value as string) ??
path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
const outputPath = createOutputPath({
sourceAppPath,
tmpDir,
});
const exportEmbedOptions = inputs.embed_bundle_assets.value
? {
sourcemapOutput: undefined,
@@ -202,6 +198,21 @@ export function createRepackBuildFunction(): BuildFunction {
});
}
function createOutputPath({
sourceAppPath,
tmpDir,
}: {
sourceAppPath: string;
tmpDir: string;
}): string {
const outputPath = path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
const extension = path.extname(outputPath);
if (extension.toLowerCase() !== '.aab') {
return outputPath;
}
return `${outputPath.slice(0, -extension.length)}.apk`;
}
/**
* Install `@expo/repack-app` in a sandbox directory and import it.
*/
@@ -69,6 +69,10 @@ export function createRestoreCacheFunction(): BuildFunction {
.filter(key => key !== '');
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(
stepsCtx.global.staticContext.expoApiServerURL,
'expoApiServerURL is not set'
);
const robotAccessToken = nullthrows(
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
'robotAccessToken is not set'
@@ -77,7 +81,7 @@ export function createRestoreCacheFunction(): BuildFunction {
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
paths,
key,
@@ -43,6 +43,10 @@ export function createSaveCacheFunction(): BuildFunction {
.filter(path => path.length > 0);
const key = z.string().parse(inputs.key.value);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(
stepsCtx.global.staticContext.expoApiServerURL,
'expoApiServerURL is not set'
);
const robotAccessToken = nullthrows(
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
'robotAccessToken is not set'
@@ -61,7 +65,7 @@ export function createSaveCacheFunction(): BuildFunction {
await uploadPublicCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
archivePath,
key,
@@ -73,7 +77,7 @@ export function createSaveCacheFunction(): BuildFunction {
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
archivePath,
key,
@@ -19,6 +19,7 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
@@ -30,7 +31,6 @@ 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');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(
@@ -64,8 +64,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
logger.info('Launching agent-device daemon.');
@@ -14,10 +14,12 @@ import semver from 'semver';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
@@ -26,9 +28,9 @@ import {
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.11.0';
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
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({
@@ -69,12 +71,17 @@ export function createStartArgentRemoteSessionBuildFunction(
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Stale state from a previous run would mask the new server's port.
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bunx',
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
{ env, logger }
);
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
const argentServer = spawnDetached({
@@ -113,6 +120,12 @@ export function createStartArgentRemoteSessionBuildFunction(
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
void pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
});
const publicToolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
@@ -31,6 +31,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
defaultValue: 1,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'enable_accessibility_settings',
required: false,
defaultValue: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async ({ logger }, { inputs, env }) => {
try {
@@ -55,11 +61,18 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
| undefined;
const originalDeviceIdentifier =
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
if (!originalDeviceIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: originalDeviceIdentifier,
env,
});
}
const { udid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: originalDeviceIdentifier,
env,
@@ -97,6 +110,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: cloneDeviceName,
env,
});
}
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: cloneDeviceName,
env,
@@ -1,15 +1,14 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
@@ -27,8 +26,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
logger.info('Starting serve-sim remote session.');
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
await selectXcodeDeveloperDirectoryAsync({ env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
@@ -1,4 +1,9 @@
import { GenericArtifactType, Job, ManagedArtifactType } from '@expo/eas-build-job';
import {
GenericArtifactType,
Job,
ManagedArtifactType,
isGenericArtifact,
} from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -65,6 +70,11 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
BuildStepInput.createProvider({
id: 'metadata',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -108,7 +118,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
throw result.reason;
});
const artifact = {
const artifactSpec = {
type: parseArtifactTypeInput({
platform: ctx.job.platform,
inputValue: `${inputs.type.value ?? ''}`,
@@ -116,12 +126,15 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
paths: artifactPaths,
name: (inputs.name.value || inputs.key.value) as string,
};
const artifact = isGenericArtifact(artifactSpec)
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
artifact,
logger,
});
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
if (artifactId) {
outputs.artifact_id.set(artifactId);
@@ -0,0 +1,111 @@
import { bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { listArgentArtifactsAsync, uploadArgentArtifactAsync } from '../argentArtifacts';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
for await (const chunk of stream as Readable) {
void chunk;
}
}
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
}
describe(listArgentArtifactsAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
});
it('lists Argent artifacts with bearer auth', async () => {
jest.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
artifacts: [
{
id: 'artifact-id',
filename: 'report.json',
mimeType: 'application/json',
isDirectory: false,
},
],
})
)
);
const artifacts = await listArgentArtifactsAsync({
toolsUrl: 'http://127.0.0.1:1234',
toolsAuthToken: 'tools-token',
});
expect(artifacts).toEqual([
{
id: 'artifact-id',
filename: 'report.json',
mimeType: 'application/json',
isDirectory: false,
},
]);
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
headers: { Authorization: 'Bearer tools-token' },
});
});
});
describe(uploadArgentArtifactAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
});
it('downloads an Argent artifact and uploads it as a device run session artifact', async () => {
const data = Buffer.from('artifact-data');
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadArgentArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
toolsUrl: 'http://127.0.0.1:1234',
toolsAuthToken: 'tools-token',
logger,
artifact: {
id: 'artifact-id',
filename: 'report.json',
mimeType: 'application/json',
},
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts/artifact-id', {
headers: { Authorization: 'Bearer tools-token' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'report.json (artifact-id)',
filename: 'report.json',
size: data.length,
stream: expect.anything(),
});
});
});
@@ -0,0 +1,72 @@
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
describe(uploadDeviceRunSessionArtifactAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
});
it('streams an artifact through a signed upload URL', async () => {
const stream = Readable.from(Buffer.from('artifact-data'));
const reportedSize = 1024;
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
},
},
},
},
},
}),
});
const ctx = {
graphqlClient: {
mutation,
},
} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
size: reportedSize,
stream,
});
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: {
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
size: reportedSize,
},
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
expect.objectContaining({
method: 'PUT',
body: stream,
})
);
});
});
@@ -0,0 +1,174 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import fetch from 'node-fetch';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { sleepAsync } from '../../utils/retry';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const ArgentArtifactSchema = z.object({
id: z.string(),
filename: z.string(),
mimeType: z.string(),
isDirectory: z.boolean().optional(),
});
const ArgentArtifactsListResponseSchema = z.object({
artifacts: z.array(ArgentArtifactSchema),
});
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
export async function pollArgentArtifactsForUploadAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
logger,
}: {
deviceRunSessionId: string;
toolsUrl: string;
toolsAuthToken?: string;
logger: bunyan;
}
): Promise<never> {
logger.info('Started polling Argent tool-server for artifacts.');
const seenArtifactIds = new Set<string>();
let listArtifactsErrorCount = 0;
for (;;) {
try {
const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken });
listArtifactsErrorCount = 0;
for (const artifact of artifacts) {
if (seenArtifactIds.has(artifact.id)) {
continue;
}
seenArtifactIds.add(artifact.id);
void uploadArgentArtifactAsync(ctx, {
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
artifact,
logger,
}).catch(err => {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload Argent remote session artifact', error);
logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
listArtifactsErrorCount += 1;
if (listArtifactsErrorCount === 1 || listArtifactsErrorCount % 5 === 0) {
Sentry.capture('Could not list Argent remote session artifacts', error);
logger.warn(
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
'Could not list Argent remote session artifacts.'
);
}
}
await sleepAsync(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
}
}
export async function listArgentArtifactsAsync({
toolsUrl,
toolsAuthToken,
}: {
toolsUrl: string;
toolsAuthToken?: string;
}): Promise<ArgentArtifact[]> {
const response = await fetch(new URL('/artifacts', toolsUrl).toString(), {
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
});
if (!response.ok) {
throw new SystemError(
`Failed to list Argent artifacts: ${response.status} ${response.statusText}`
);
}
const result = ArgentArtifactsListResponseSchema.safeParse(await response.json());
if (!result.success) {
throw new SystemError(`Invalid Argent artifacts response: ${result.error.message}`);
}
return result.data.artifacts;
}
export async function uploadArgentArtifactAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
artifact,
logger,
}: {
deviceRunSessionId: string;
toolsUrl: string;
toolsAuthToken?: string;
artifact: ArgentArtifact;
logger: bunyan;
}
): Promise<void> {
const filename = artifact.isDirectory ? `${artifact.filename}.tar.gz` : artifact.filename;
logger.info(`Downloading artifact ${filename}.`);
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'argent-artifact-'));
try {
const temporaryArtifactPath = path.join(temporaryDirectory, path.basename(filename));
await downloadArgentArtifactToFileAsync({
artifact,
toolsUrl,
toolsAuthToken,
destinationPath: temporaryArtifactPath,
});
const { size } = await stat(temporaryArtifactPath);
logger.info(`Uploading artifact ${filename} (${formatBytes(size)}).`);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: artifact.id,
name: `${filename} (${artifact.id})`,
filename,
size,
stream: createReadStream(temporaryArtifactPath),
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function downloadArgentArtifactToFileAsync({
artifact,
toolsUrl,
toolsAuthToken,
destinationPath,
}: {
artifact: ArgentArtifact;
toolsUrl: string;
toolsAuthToken?: string;
destinationPath: string;
}): Promise<void> {
const response = await fetch(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
});
if (!response.ok) {
throw new SystemError(
`Failed to download Argent artifact ${artifact.id}: ${response.status} ${response.statusText}`
);
}
if (!response.body) {
throw new SystemError(
`Argent artifact ${artifact.id} response did not include a readable body.`
);
}
await pipeline(response.body, createWriteStream(destinationPath));
}
@@ -0,0 +1,94 @@
import { SystemError } from '@expo/eas-build-job';
import { graphql } from 'gql.tada';
import fetch, { Headers } from 'node-fetch';
import { CustomBuildContext } from '../../customBuildContext';
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionArtifactUploadSession(
$deviceRunSessionId: ID!
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
) {
deviceRunSession {
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
uploadSession {
url
headers
}
}
}
}
`);
export async function uploadDeviceRunSessionArtifactAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
artifactId,
name,
filename,
size,
stream,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
size: number;
stream: NodeJS.ReadableStream;
}
): Promise<void> {
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
deviceRunSessionId,
artifactId,
name,
filename,
size,
});
const response = await fetch(uploadSession.url, {
method: 'PUT',
headers: new Headers(uploadSession.headers as Record<string, string>),
body: stream,
});
if (!response.ok) {
throw new SystemError(
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
{ cause: response }
);
}
}
async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
artifactId,
name,
filename,
size,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
size: number;
}
) {
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
input: {
name,
filename,
size,
},
})
.toPromise();
if (result.error) {
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
{ cause: result.error }
);
}
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
}
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import { BuildStepEnv, spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
@@ -14,6 +14,8 @@ import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
@@ -74,6 +76,26 @@ const TurnIceServersSchema = z.array(
export type TurnIceServers = z.infer<typeof TurnIceServersSchema>;
export async function selectXcodeDeveloperDirectoryAsync({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if (process.env.ENVIRONMENT === 'development') {
logger.info('Job running outside of EAS, not selecting Xcode developer directory.');
return;
}
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawnAsync('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], {
env,
logger,
stdio: ['ignore', 'pipe', 'pipe'],
});
}
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
@@ -1,3 +1,4 @@
import { ExpoError, SystemError, UserError } from '@expo/eas-build-job';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
@@ -92,6 +93,67 @@ export namespace IosSimulatorUtils {
});
}
export async function enableAccessibilitySettingsAsync({
deviceIdentifier,
env,
}: {
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
try {
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
const device = devices.find(
device =>
device.isAvailable &&
(device.udid === deviceIdentifier || device.name === deviceIdentifier)
);
if (!device) {
throw new UserError(
'EAS_IOS_SIMULATOR_NOT_FOUND',
`Failed to find available iOS Simulator "${deviceIdentifier}" to update accessibility settings.`
);
}
if (device.state !== 'Shutdown') {
throw new UserError(
'EAS_IOS_SIMULATOR_NOT_SHUTDOWN',
`Expected iOS Simulator "${deviceIdentifier}" to be shutdown before updating accessibility settings, but it is ${device.state}.`
);
}
const plistPath = path.join(
device.dataPath,
'Library',
'Preferences',
'com.apple.Accessibility.plist'
);
await fs.promises.mkdir(path.dirname(plistPath), { recursive: true });
const plistExists = await fs.promises
.access(plistPath)
.then(() => true)
.catch(() => false);
if (!plistExists) {
await spawn('plutil', ['-create', 'binary1', plistPath], { env });
}
for (const key of [
'AutomationEnabled',
'IgnoreAXServerEntitlements',
'AccessibilityEnabled',
'ApplicationAccessibilityEnabled',
]) {
await spawn('plutil', ['-replace', key, '-bool', 'true', plistPath], { env });
}
} catch (err) {
if (err instanceof ExpoError) {
throw err;
}
throw new SystemError('Failed to update iOS Simulator accessibility settings.', {
cause: err,
});
}
}
export async function startAsync({
deviceIdentifier,
env,
@@ -1,3 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import os from 'node:os';
import path from 'node:path';
@@ -18,9 +19,143 @@ const mockedSpawn = jest.mocked(spawn);
describe('IosSimulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
describe(IosSimulatorUtils.startAsync, () => {
it('waits for boot completion without writing accessibility prefs', async () => {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'xcrun' && args.join(' ') === 'simctl bootstatus test-udid -b') {
return {
stdout: 'Monitoring boot status for iPhone 15 (test-udid).\n',
stderr: '',
} as any;
}
return { stdout: '', stderr: '' } as any;
}) as any);
await IosSimulatorUtils.startAsync({
deviceIdentifier: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).not.toHaveBeenCalledWith(
'xcrun',
['simctl', 'list', 'devices', '--json', '--no-escape-slashes', 'test-udid'],
expect.anything()
);
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
});
});
describe(IosSimulatorUtils.enableAccessibilitySettingsAsync, () => {
function device(overrides: Record<string, unknown> = {}) {
return {
dataPath: '/tmp/eas-test-simulator-data',
dataPathSize: 1,
logPath: '/tmp/eas-test-simulator-logs',
udid: 'test-udid',
isAvailable: true,
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
state: 'Shutdown',
name: 'iPhone 15',
...overrides,
};
}
function mockAvailableDevices(devices: Record<string, unknown>[]) {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (
command === 'xcrun' &&
args.join(' ') === 'simctl list devices --json --no-escape-slashes available'
) {
return {
stdout: JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices,
},
}),
stderr: '',
} as any;
}
return { stdout: '', stderr: '' } as any;
}) as any);
}
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
mockAvailableDevices([
device({
udid: 'test-udid-pro',
name: 'iPhone 17 Pro',
dataPath: '/tmp/eas-test-simulator-data-pro',
}),
device({ name: 'iPhone 17' }),
]);
await expect(
IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: 'iPhone 17' as any,
env: process.env,
})
).resolves.toBeUndefined();
for (const key of [
'AutomationEnabled',
'IgnoreAXServerEntitlements',
'AccessibilityEnabled',
'ApplicationAccessibilityEnabled',
]) {
expect(mockedSpawn).toHaveBeenCalledWith(
'plutil',
[
'-replace',
key,
'-bool',
'true',
'/tmp/eas-test-simulator-data/Library/Preferences/com.apple.Accessibility.plist',
],
{ env: process.env }
);
}
});
it('throws UserError when no available simulator exactly matches the identifier', async () => {
mockAvailableDevices([device({ name: 'iPhone 17 Pro' })]);
await expect(
IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: 'iPhone 17' as any,
env: process.env,
})
).rejects.toThrow(UserError);
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
});
it('throws UserError when the matched simulator is already booted', async () => {
mockAvailableDevices([device({ state: 'Booted' })]);
await expect(
IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: 'test-udid' as any,
env: process.env,
})
).rejects.toThrow(UserError);
});
it('throws SystemError when pre-boot accessibility setup fails', async () => {
mockedSpawn.mockRejectedValue(new Error('Failed to list simulators'));
await expect(
IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: 'test-udid' as any,
env: process.env,
})
).rejects.toThrow(SystemError);
});
});
describe(IosSimulatorUtils.waitForReadyAsync, () => {
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
await IosSimulatorUtils.waitForReadyAsync({
@@ -104,4 +104,33 @@ describe(expoUpdates.configureExpoUpdatesIfInstalledAsync, () => {
expect(iosSetChannelNativelyAsync).toBeCalledTimes(1);
expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1);
});
it('does not fail on missing EAS_BUILD_ID when logging fingerprint diffs', async () => {
jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0');
const warn = jest.fn();
const managedCtx: BuildContext<BuildJob> = {
appConfig: {},
env: {},
job: {
platform: Platform.IOS,
},
logger: { warn },
metadata: {
runtimeVersion: 'runtime-version-from-local-machine',
},
getReactNativeProjectDirectory: () => '/app',
} as any;
await expect(
expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, {
resolvedRuntimeVersion: 'runtime-version-from-eas-build',
resolvedFingerprintSources: [],
})
).rejects.toThrow(
'Runtime version calculated on local machine not equal to runtime version calculated during build.'
);
expect(warn).toHaveBeenCalledWith('Skipping fingerprint diff because EAS_BUILD_ID is not set');
});
});
@@ -243,6 +243,12 @@ async function logDiffFingerprints({
ctx: BuildContext<BuildJob>;
}): Promise<void> {
const { resolvedRuntimeVersion, resolvedFingerprintSources } = resolvedRuntime;
const buildId = ctx.env.EAS_BUILD_ID;
if (!buildId) {
ctx.logger.warn('Skipping fingerprint diff because EAS_BUILD_ID is not set');
return;
}
const fingerprintInfo = await ctx.graphqlClient
.query(
@@ -257,7 +263,7 @@ async function logDiffFingerprints({
}
}
`),
{ id: ctx.env.EAS_BUILD_ID }
{ id: buildId }
)
.toPromise();
@@ -1,4 +1,18 @@
import { StaticWorkflowInterpolationContextZ } from '../common';
import { EnvSchema, StaticWorkflowInterpolationContextZ } from '../common';
describe('EnvSchema', () => {
it('accepts explicit undefined values', () => {
const env = {
DEFINED_ENV: 'value',
UNDEFINED_ENV: undefined,
};
const { value, error } = EnvSchema.validate(env);
expect(error).toBeUndefined();
expect(value).toEqual(env);
});
});
describe('StaticWorkflowInterpolationContextZ', () => {
it('accepts app and account context', () => {
+2 -2
View File
@@ -115,8 +115,8 @@ export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
}),
]);
export type Env = Record<string, string>;
export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string());
export type Env = Record<string, string | undefined>;
export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string().optional());
export type EnvironmentSecret = {
name: string;
+1 -1
View File
@@ -11,7 +11,7 @@ type StaticJobOnlyInterpolationContext = {
outputs: Record<string, string | undefined>;
}
>;
expoApiServerURL: string;
expoApiServerURL: string | undefined;
};
export type StaticJobInterpolationContext =
+136 -113
View File
@@ -141,6 +141,7 @@ eas --help COMMAND
* [`eas observe:metrics [METRIC]`](#eas-observemetrics-metric)
* [`eas observe:metrics-summary`](#eas-observemetrics-summary)
* [`eas observe:routes`](#eas-observeroutes)
* [`eas observe:session SESSIONID`](#eas-observesession-sessionid)
* [`eas observe:versions`](#eas-observeversions)
* [`eas onboarding [TARGET_PROJECT_DIRECTORY]`](#eas-onboarding-target_project_directory)
* [`eas open`](#eas-open)
@@ -206,7 +207,7 @@ DESCRIPTION
view the audit logs for an account
```
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/account/audit.ts)_
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/audit.ts)_
## `eas account:login`
@@ -227,7 +228,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -244,7 +245,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -266,7 +267,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -283,7 +284,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -297,7 +298,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -349,7 +350,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -370,7 +371,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -390,7 +391,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -410,7 +411,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -433,7 +434,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -474,7 +475,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -494,7 +495,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -512,7 +513,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -532,7 +533,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -557,7 +558,7 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -584,7 +585,7 @@ DESCRIPTION
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -622,7 +623,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -662,7 +663,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -692,7 +693,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -720,7 +721,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -772,7 +773,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -790,7 +791,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -808,7 +809,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -825,7 +826,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -846,7 +847,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -867,7 +868,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -889,7 +890,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -913,7 +914,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -933,7 +934,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -955,7 +956,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -977,7 +978,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -1012,7 +1013,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -1035,7 +1036,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1056,7 +1057,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1073,7 +1074,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1091,7 +1092,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1120,7 +1121,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1145,7 +1146,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1166,7 +1167,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1187,7 +1188,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1224,7 +1225,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1244,7 +1245,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1265,7 +1266,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1286,7 +1287,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1300,7 +1301,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1314,7 +1315,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1346,7 +1347,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1372,7 +1373,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1394,7 +1395,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1422,7 +1423,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1450,7 +1451,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1473,7 +1474,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1495,7 +1496,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1529,7 +1530,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1573,7 +1574,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1604,7 +1605,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1683,7 +1684,7 @@ DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1702,7 +1703,7 @@ DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1720,7 +1721,7 @@ DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas integrations:convex:connect`
@@ -1742,7 +1743,7 @@ DESCRIPTION
connect Convex to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
## `eas integrations:convex:dashboard`
@@ -1756,7 +1757,7 @@ DESCRIPTION
open the Convex dashboard for the linked Convex project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
## `eas integrations:convex:project`
@@ -1770,7 +1771,7 @@ DESCRIPTION
display the Convex project linked to the current Expo app
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
## `eas integrations:convex:project:delete`
@@ -1788,7 +1789,7 @@ DESCRIPTION
remove the Convex project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
## `eas integrations:convex:team`
@@ -1802,7 +1803,7 @@ DESCRIPTION
display Convex teams linked to the current Expo app's owner account
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
@@ -1823,7 +1824,7 @@ DESCRIPTION
remove a Convex team link from the current Expo app owner account's EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
@@ -1843,7 +1844,7 @@ DESCRIPTION
send a Convex team invitation to your verified email address
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
## `eas integrations:posthog:connect`
@@ -1870,7 +1871,7 @@ DESCRIPTION
connect PostHog to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/connect.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/posthog/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/connect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/posthog/connect.ts)_
## `eas integrations:posthog:dashboard`
@@ -1888,7 +1889,7 @@ DESCRIPTION
open the PostHog dashboard for the linked PostHog project
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts)_
## `eas integrations:posthog:disconnect`
@@ -1907,7 +1908,7 @@ DESCRIPTION
remove the PostHog project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/posthog/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/integrations/posthog/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/posthog/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/integrations/posthog/disconnect.ts)_
## `eas login`
@@ -1959,7 +1960,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1977,7 +1978,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1995,7 +1996,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -2054,7 +2055,7 @@ DESCRIPTION
arguments, a list of the available event names and associated event counts is returned.
```
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/observe/events.ts)_
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/events.ts)_
## `eas observe:metrics [METRIC]`
@@ -2089,7 +2090,7 @@ DESCRIPTION
display individual performance metric samples ordered by value
```
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/observe/metrics.ts)_
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/metrics.ts)_
## `eas observe:metrics-summary`
@@ -2120,7 +2121,7 @@ DESCRIPTION
display aggregated performance metric statistics grouped by app version
```
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
## `eas observe:routes`
@@ -2157,7 +2158,29 @@ DESCRIPTION
display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name
```
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/observe/routes.ts)_
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/routes.ts)_
## `eas observe:session SESSIONID`
display the timeline of metric and log events for a specific session
```
USAGE
$ eas observe:session SESSIONID [--project-id <value>] [--json] [--non-interactive]
ARGUMENTS
SESSIONID Session ID to inspect
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
--project-id=<value> EAS project ID (defaults to the project ID of the current directory)
DESCRIPTION
display the timeline of metric and log events for a specific session
```
_See code: [packages/eas-cli/src/commands/observe/session.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/session.ts)_
## `eas observe:versions`
@@ -2182,7 +2205,7 @@ DESCRIPTION
display app versions with build and update details
```
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/observe/versions.ts)_
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/observe/versions.ts)_
## `eas onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2212,7 +2235,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -2226,7 +2249,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -2249,7 +2272,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -2273,7 +2296,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2291,7 +2314,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -2324,7 +2347,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -2367,7 +2390,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -2388,7 +2411,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2409,7 +2432,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2434,7 +2457,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:embedded:delete ID`
@@ -2455,7 +2478,7 @@ DESCRIPTION
delete an embedded update registered with EAS Update
```
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
## `eas update:embedded:list`
@@ -2481,7 +2504,7 @@ DESCRIPTION
list embedded updates registered with EAS Update for this project
```
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
## `eas update:embedded:upload`
@@ -2511,7 +2534,7 @@ EXAMPLES
$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>
```
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
## `eas update:embedded:view ID`
@@ -2531,7 +2554,7 @@ DESCRIPTION
view details of an embedded update registered with EAS Update
```
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
## `eas update:insights GROUPID`
@@ -2558,7 +2581,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2585,7 +2608,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2623,7 +2646,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2651,7 +2674,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2681,7 +2704,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback [GROUPID]`
@@ -2713,7 +2736,7 @@ DESCRIPTION
roll back to an embedded update or an existing update
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2737,7 +2760,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2758,7 +2781,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2780,7 +2803,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2800,7 +2823,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2819,7 +2842,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2842,7 +2865,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2859,7 +2882,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2980,7 +3003,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -3000,7 +3023,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -3024,7 +3047,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -3064,7 +3087,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -3086,7 +3109,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -3114,7 +3137,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -3134,7 +3157,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -3156,5 +3179,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.3.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+2619 -316
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "20.3.0",
"version": "20.5.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -27,7 +27,7 @@
],
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"prepack": "yarn sync-agent-templates && yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"build": "yarn typecheck-for-build && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
@@ -36,6 +36,7 @@
"typecheck": "tsc",
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"sync-agent-templates": "node scripts/sync-agent-templates.js",
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.ts",
"verify-graphql-code": "./scripts/verify-graphql.sh",
@@ -43,7 +44,7 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.19",
"@expo/apple-utils": "2.1.22",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
@@ -0,0 +1,31 @@
const fs = require('fs/promises');
const path = require('path');
const LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL =
'https://raw.githubusercontent.com/expo/llm-configs/main/expo-app';
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md', 'CLAUDE.md'];
async function fetchTemplateAsync(fileName) {
const url = `${LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL}/${fileName}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
return await response.text();
}
(async () => {
const templatesDir = path.join(__dirname, '..', 'src', 'commandUtils', 'new', 'templates');
await Promise.all(
AGENT_TEMPLATE_FILE_NAMES.map(async fileName => {
const content = await fetchTemplateAsync(fileName);
await fs.writeFile(path.join(templatesDir, fileName), content);
})
);
// eslint-disable-next-line no-console
console.log('Synced new app agent templates from expo/llm-configs');
})();
+2 -2
View File
@@ -1,5 +1,5 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { Env, Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, EasJson } from '@expo/eas-json';
import { LoggerLevel } from '@expo/logger';
import { NodePackageManager } from '@expo/package-manager';
@@ -65,5 +65,5 @@ export interface BuildContext<T extends Platform> {
loggerLevel?: LoggerLevel;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Record<string, string>;
env: Env;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Env, Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json';
import JsonFile from '@expo/json-file';
import { LoggerLevel } from '@expo/logger';
@@ -69,7 +69,7 @@ export async function createBuildContextAsync<T extends Platform>({
refreshAdHocProvisioningProfile?: boolean;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Record<string, string>;
env: Env;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
env,
+2 -2
View File
@@ -1,4 +1,4 @@
import { Job, Metadata, version } from '@expo/eas-build-job';
import { Env, Job, Metadata, version } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { ChildProcess } from 'child_process';
import semver from 'semver';
@@ -42,7 +42,7 @@ export async function runLocalBuildAsync(
job: Job,
metadata: Metadata,
options: LocalBuildOptions,
env: Record<string, string>
env: Env
): Promise<void> {
const { command, args } = await getCommandAndArgsAsync(job, metadata);
let spinner;
+13
View File
@@ -64,6 +64,19 @@ export function getWorkflowRunUrl(
).toString();
}
export function getDeviceRunSessionUrl(
accountName: string,
projectName: string,
deviceRunSessionId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/simulator-sessions/${encodeURIComponent(deviceRunSessionId)}`,
getExpoWebsiteBaseUrl()
).toString();
}
/**
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
* or the feature-specific dashboard) that gives users more context. Use this only for internal
@@ -1,162 +1,41 @@
# AGENTS.md
## Project Overview
This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
## Documentation Resources
## Expo has changed — do not trust your training data
When working on this project, **always consult the official Expo documentation** available at:
Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:
- **https://docs.expo.dev/llms.txt** - Index of all available documentation files
- **https://docs.expo.dev/llms-full.txt** - Complete Expo documentation including Expo Router, Expo Modules API, development process
- **https://docs.expo.dev/llms-eas.txt** - Complete EAS (Expo Application Services) documentation
- **https://docs.expo.dev/llms-sdk.txt** - Complete Expo SDK documentation
- **https://reactnative.dev/docs/getting-started** - Complete React Native documentation
1. Read the major version of the `expo` package in `package.json`.
2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v<major>.0.0/`
3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.
These documentation files are specifically formatted for AI agents and should be your **primary reference** for:
## Commands
- Expo APIs and best practices
- Expo Router navigation patterns
- EAS Build, Submit, and Update workflows
- Expo SDK modules and their usage
- Development and deployment processes
## Project Structure
```
/
├── app/ # Expo Router file-based routing
│ ├── (tabs)/ # Tab-based navigation screens
│ │ ├── index.tsx # Home screen
│ │ ├── explore.tsx # Explore screen
│ │ └── _layout.tsx # Tabs layout
│ ├── _layout.tsx # Root layout with theme provider
│ └── modal.tsx # Modal screen example
├── components/ # Reusable React components
│ ├── ui/ # UI primitives (IconSymbol, Collapsible)
│ └── ... # Feature components (themed, haptic, parallax)
├── constants/ # App-wide constants (theme, colors)
├── hooks/ # Custom React hooks (color scheme, theme)
├── assets/ # Static assets (images, fonts)
├── scripts/ # Utility scripts (reset-project)
├── .eas/workflows/ # EAS Workflows (CI/CD automation)
├── app.json # Expo configuration
├── eas.json # EAS Build/Submit configuration
└── package.json # Dependencies and scripts
```
## Essential Commands
### Development
Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present).
```bash
npx expo start # Start dev server
npx expo start --clear # Clear cache and start dev server
npx expo install <package> # Install packages with compatible versions
npx expo install --check # Check which installed packages need to be updated
npx expo install --fix # Automatically update any invalid package versions
npm run development-builds # Create development builds (workflow)
npm run reset-project # Reset to blank template
npx expo install <package> # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
npx expo start # start the dev server
npx expo lint # lint
npx tsc --noEmit # typecheck
npx expo-doctor # diagnose dependency and config issues
npx expo install --fix # fix incompatible package versions
```
### Building & Testing
Run lint and typecheck before declaring any task done.
```bash
npx expo doctor # Check project health and dependencies
npx expo lint # Run ESLint
npm run draft # Publish preview update and website (workflow)
```
## Navigation & Routing
### Production
- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
- Docs: https://docs.expo.dev/router/introduction.md
```bash
npx eas-cli@latest build --platform ios -s # Use EAS to build for iOS platform and submit to App Store
npx eas-cli@latest build --platform android -s # Use EAS to build for Android platform and submit to Google Play Store
npm run deploy # Deploy to production (workflow)
```
## Building with EAS
## Development Guidelines
Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli <command>` in Bun projects, or `npx eas-cli@latest <command>` otherwise; substitute that for bare `eas` in docs examples.
Docs: https://docs.expo.dev/eas/index.md
### Code Style & Standards
## Rules
- **TypeScript First**: Use TypeScript for all new code with strict type checking
- **Naming Conventions**: Use meaningful, descriptive names for variables, functions, and components
- **Self-Documenting Code**: Write clear, readable code that explains itself; only add comments for complex business logic or design decisions
- **React 19 Patterns**: Follow modern React patterns including:
- Function components with hooks
- Enable React Compiler
- Proper dependency arrays in useEffect
- Memoization when appropriate (useMemo, useCallback)
- Error boundaries for better error handling
### Navigation & Routing
- Use **Expo Router** for all navigation
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`
- Docs: https://docs.expo.dev/router/introduction/
### Recommended Libraries
- **Navigation**: `expo-router` for navigation
- **Images**: `expo-image` for optimized image handling and caching
- **Animations**: `react-native-reanimated` for performant animations on native thread
- **Gestures**: `react-native-gesture-handler` for native gesture recognition
- **Storage**: Use `expo-sqlite` for persistent storage, `expo-sqlite/kv-store` for simple key-value storage
## Debugging & Development Tools
### DevTools Integration
- **React Native DevTools**: Use MCP `open_devtools` command to launch debugging tools
- **Network Inspection**: Monitor API calls and network requests in DevTools
- **Element Inspector**: Debug component hierarchy and styles
- **Performance Profiler**: Identify performance bottlenecks
- **Logging**: Use `console.log` for debugging (remove before production), `console.warn` for deprecation notices, `console.error` for actual errors, and implement error boundaries for production error handling
### Testing & Quality Assurance
#### Automated Testing with MCP Tools
Developers can configure the Expo MCP server with the following doc: https://docs.expo.dev/eas/ai/mcp/
- **Component Testing**: Add `testID` props to components for automation
- **Visual Testing**: Use MCP `automation_take_screenshot` to verify UI appearance
- **Interaction Testing**: Use MCP `automation_tap_by_testid` to simulate user interactions
- **View Verification**: Use MCP `automation_find_view_by_testid` to validate component rendering
## EAS Workflows CI/CD
This project is pre-configured with **EAS Workflows** for automating development and release processes. Workflows are defined in `.eas/workflows/` directory.
When working with EAS Workflows, **always refer to**:
- https://docs.expo.dev/eas/workflows/ for workflow examples
- The `.eas/workflows/` directory for existing workflow configurations
- You can check that a workflow YAML is valid using the workflows schema: https://exp.host/--/api/v2/workflows/schema
### Build Profiles (eas.json)
- **development**: Development builds with dev client
- **development-simulator**: Development builds for iOS simulator
- **preview**: Internal distribution preview builds
- **production**: Production builds with auto-increment
## Troubleshooting
### Expo Go Errors & Development Builds
If there are errors in **Expo Go** or the project is not running, create a **development build**. **Expo Go** is a sandbox environment with a limited set of native modules. To create development builds, run `eas build:dev`. Additionally, after installing new packages or adding config plugins, new development builds are often required.
## AI Agent Instructions
When working on this project:
1. **Always start by consulting the appropriate documentation**:
- For general Expo questions: https://docs.expo.dev/llms-full.txt
- For EAS/deployment questions: https://docs.expo.dev/llms-eas.txt
- For SDK/API questions: https://docs.expo.dev/llms-sdk.txt
2. **Understand before implementing**: Read the relevant docs section before writing code
3. **Follow existing patterns**: Look at existing components and screens for patterns to follow
- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins.
- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md
@@ -1,9 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Note**: This project uses `AGENTS.md` files for detailed guidance.
## Primary Reference
Please see `AGENTS.md` in this same directory for the main project documentation and guidance.
@AGENTS.md
@@ -1,4 +1,5 @@
import spawnAsync from '@expo/spawn-async';
import openBrowserAsync from 'better-opn';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
@@ -17,6 +18,7 @@ import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
import {
PostHogOrganizationConnectionData,
PostHogProjectData,
StartPostHogConnectionResult,
} from '../../../../graphql/types/PostHogConnection';
import Log from '../../../../log';
import { createOrModifyExpoConfigAsync } from '../../../../project/expoConfig';
@@ -27,6 +29,7 @@ import { printJsonOnlyOutput } from '../../../../utils/json';
import IntegrationsPostHogConnect from '../connect';
jest.mock('@expo/spawn-async');
jest.mock('better-opn');
jest.mock('fs-extra');
jest.mock('../../../../project/expoConfig');
jest.mock('../../../../graphql/queries/PostHogQuery');
@@ -42,6 +45,7 @@ jest.mock('../../../../ora', () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
}),
}));
@@ -79,6 +83,16 @@ describe(IntegrationsPostHogConnect, () => {
updatedAt: '2024-01-01T00:00:00.000Z',
};
const mockConnectionResult: StartPostHogConnectionResult = {
__typename: 'PostHogOrganizationConnection',
...mockConnection,
};
const mockPendingResult: StartPostHogConnectionResult = {
__typename: 'PostHogPendingConnection',
url: 'https://us.posthog.com/login?next=/oauth/authorize',
};
const mockProject: PostHogProjectData = {
id: 'project-1',
posthogProjectIdentifier: '467657',
@@ -137,8 +151,11 @@ describe(IntegrationsPostHogConnect, () => {
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(mockConnection);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mockResolvedValue(mockConnection);
jest
.mocked(PostHogMutation.startPostHogConnectionAsync)
.mockResolvedValue(mockConnectionResult);
jest.mocked(PostHogMutation.setupPostHogProjectAsync).mockResolvedValue(mockProject);
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
id: 'env-var-1',
@@ -187,7 +204,7 @@ describe(IntegrationsPostHogConnect, () => {
jest.mocked(promptAsync).mockImplementation((async (opts: any) => {
if (opts.name === 'features') {
provisionedBeforePrompt =
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mock.calls.length > 0 &&
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mock.calls.length > 0 &&
jest.mocked(PostHogMutation.setupPostHogProjectAsync).mock.calls.length > 0;
return { features: ['analytics'] };
}
@@ -261,7 +278,7 @@ describe(IntegrationsPostHogConnect, () => {
createCommand(['--non-interactive', '--error-tracking']).runAsync()
).rejects.toThrow(/personal API key in non-interactive/);
expect(PostHogMutation.createPostHogAccountRequestAsync).not.toHaveBeenCalled();
expect(PostHogMutation.startPostHogConnectionAsync).not.toHaveBeenCalled();
});
it('non-interactive --error-tracking with --posthog-cli-api-key sets up CLI vars without prompting', async () => {
@@ -321,31 +338,121 @@ describe(IntegrationsPostHogConnect, () => {
it('reuses an existing connection + project without provisioning', async () => {
await createCommand([]).runAsync();
expect(PostHogMutation.createPostHogAccountRequestAsync).not.toHaveBeenCalled();
expect(PostHogMutation.startPostHogConnectionAsync).not.toHaveBeenCalled();
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
});
it('surfaces the existing-account dead-end without provisioning a project', async () => {
it('completes an existing-account connection via the browser, then provisions the project', async () => {
// No connection at the first check; the start mutation hands off to the browser, and
// polling then finds the connection the website callback created.
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
mockFeatureSelection(['analytics']);
await createCommand(['--region', 'US']).runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(mockPendingResult.url);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Opened'));
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
posthogOrganizationConnectionId: mockConnection.id,
});
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
});
it('still prints the approval URL when a browser cannot be opened', async () => {
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValueOnce(null)
.mockResolvedValue(mockConnection);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
jest.mocked(openBrowserAsync).mockResolvedValue(false as never);
mockFeatureSelection(['analytics']);
await createCommand(['--region', 'US']).runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Open this URL to approve the connection')
);
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalled();
});
it('retries polling after a transient failure, then completes', async () => {
jest.useFakeTimers();
const debugSpy = jest.spyOn(Log, 'debug').mockImplementation(() => {});
try {
// No connection initially; the first poll throws (transient), the next finds it.
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValueOnce(null)
.mockRejectedValueOnce(new Error('network blip'))
.mockResolvedValue(mockConnection);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
mockFeatureSelection(['analytics']);
const promise = createCommand(['--region', 'US']).runAsync();
// Flush the poll interval so the retry runs after the transient failure.
await jest.advanceTimersByTimeAsync(3_000);
await promise;
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining('Polling for the PostHog connection failed')
);
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalled();
} finally {
debugSpy.mockRestore();
jest.useRealTimers();
}
});
it('refuses the browser handoff non-interactively and provisions nothing', 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' } }],
})
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
await expect(createCommand(['--non-interactive', '--region', 'US']).runAsync()).rejects.toThrow(
/already has a PostHog account.*interactively/s
);
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(openBrowserAsync).not.toHaveBeenCalled();
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('times out (and provisions nothing) if the connection is never approved', async () => {
jest.useFakeTimers();
try {
// Connection never appears: null at the first check and on every poll.
jest
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
mockFeatureSelection(['analytics']);
const promise = createCommand(['--region', 'US']).runAsync();
const assertion = expect(promise).rejects.toThrow(/Timed out waiting for the PostHog/);
// Advance past the 15-minute poll window, flushing each poll iteration's awaits.
await jest.advanceTimersByTimeAsync(16 * 60 * 1_000);
await assertion;
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('emits JSON output with --json', async () => {
await createCommand(['--json', '--posthog-cli-api-key', 'phx_x']).runAsync();
@@ -390,7 +497,7 @@ describe(IntegrationsPostHogConnect, () => {
await createCommand(['--region', 'US']).runAsync();
expect(PostHogMutation.createPostHogAccountRequestAsync).toHaveBeenCalledWith(graphqlClient, {
expect(PostHogMutation.startPostHogConnectionAsync).toHaveBeenCalledWith(graphqlClient, {
accountId: testAccountId,
region: PostHogRegion.Us,
});
@@ -405,7 +512,7 @@ describe(IntegrationsPostHogConnect, () => {
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
.mockResolvedValue(null);
jest
.mocked(PostHogMutation.createPostHogAccountRequestAsync)
.mocked(PostHogMutation.startPostHogConnectionAsync)
.mockRejectedValue(new Error('network down'));
await expect(createCommand(['--region', 'US']).runAsync()).rejects.toThrow('network down');
@@ -532,7 +639,7 @@ describe(IntegrationsPostHogConnect, () => {
await createCommand([]).runAsync();
expect(selectAsync).toHaveBeenCalledWith('Select a PostHog region', expect.any(Array));
expect(PostHogMutation.createPostHogAccountRequestAsync).toHaveBeenCalledWith(graphqlClient, {
expect(PostHogMutation.startPostHogConnectionAsync).toHaveBeenCalledWith(graphqlClient, {
accountId: testAccountId,
region: PostHogRegion.Us,
});
@@ -1,6 +1,7 @@
import { ExpoConfig } from '@expo/config';
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import openBrowserAsync from 'better-opn';
import chalk from 'chalk';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
@@ -24,7 +25,11 @@ import { EnvironmentVariableMutation } from '../../../graphql/mutations/Environm
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 {
PostHogOrganizationConnectionData,
PostHogProjectData,
StartPostHogConnectionResult,
} from '../../../graphql/types/PostHogConnection';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
@@ -59,6 +64,11 @@ 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';
// Match the server's 15-minute pending-row TTL — timing out sooner would strand an approval
// the user completes within the window.
const CONNECTION_POLL_INTERVAL_MS = 2_000;
const CONNECTION_POLL_TIMEOUT_MS = 15 * 60 * 1_000;
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.`;
@@ -141,19 +151,7 @@ export default class IntegrationsPostHogConnect extends EasCommand {
);
} 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;
}
connection = await this.startConnectionAsync(graphqlClient, account, region, nonInteractive);
}
let project = await PostHogQuery.getPostHogProjectByAppIdAsync(graphqlClient, projectId);
@@ -276,6 +274,97 @@ export default class IntegrationsPostHogConnect extends EasCommand {
this.printNextSteps(project, features, manualSteps);
}
private async startConnectionAsync(
graphqlClient: ExpoGraphqlClient,
account: { id: string; name: string },
region: PostHogRegion,
nonInteractive: boolean
): Promise<PostHogOrganizationConnectionData> {
const spinner = ora('Creating PostHog organization').start();
let result: StartPostHogConnectionResult;
try {
result = await PostHogMutation.startPostHogConnectionAsync(graphqlClient, {
accountId: account.id,
region,
});
} catch (error) {
spinner.fail('Failed to create PostHog organization');
throw error;
}
if (result.__typename === 'PostHogOrganizationConnection') {
spinner.succeed(`Created PostHog organization ${chalk.bold(result.posthogOrganizationName)}`);
return result;
}
spinner.stop();
if (nonInteractive) {
throw new Error(
`The email on the ${chalk.bold(account.name)} account already has a PostHog account, which must be connected by approving it in a browser. Re-run \`eas integrations:posthog:connect\` interactively to finish connecting.`
);
}
return await this.completePendingConnectionViaBrowserAsync(graphqlClient, account, result);
}
private async completePendingConnectionViaBrowserAsync(
graphqlClient: ExpoGraphqlClient,
account: { id: string; name: string },
pending: { url: string }
): Promise<PostHogOrganizationConnectionData> {
Log.addNewLineIfNone();
Log.log(
`The email on the ${chalk.bold(account.name)} account already has a PostHog account. Approve connecting it to Expo in your browser.`
);
const opened = await openBrowserAsync(pending.url).catch(() => false);
Log.log(
opened
? `Opened ${link(pending.url)}`
: `Open this URL to approve the connection: ${link(pending.url)}`
);
const spinner = ora(
'Waiting for you to approve the connection in PostHog (up to 15 minutes; press Ctrl-C to cancel)'
).start();
try {
const connection = await this.pollForConnectionAsync(graphqlClient, account.id);
spinner.succeed(
`Connected PostHog organization ${chalk.bold(connection.posthogOrganizationName)}`
);
return connection;
} catch (error) {
spinner.fail("Couldn't confirm the PostHog connection");
throw error;
}
}
private async pollForConnectionAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string
): Promise<PostHogOrganizationConnectionData> {
const deadline = Date.now() + CONNECTION_POLL_TIMEOUT_MS;
while (true) {
let connection: PostHogOrganizationConnectionData | null = null;
try {
connection = await PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync(
graphqlClient,
accountId,
{ useCache: false }
);
} catch (error) {
Log.debug(`Polling for the PostHog connection failed, will retry: ${error}`);
}
if (connection) {
return connection;
}
if (Date.now() >= deadline) {
throw new Error(
'Timed out waiting for the PostHog connection. If you approved it in your browser, re-run `eas integrations:posthog:connect` — it will pick up the connection. If not, approve it and try again.'
);
}
await new Promise<void>(resolve => setTimeout(resolve, CONNECTION_POLL_INTERVAL_MS));
}
}
private async resolveFeaturesAsync(
flags: { 'session-replay'?: boolean; 'error-tracking'?: boolean },
cliApiKey: string | undefined,
@@ -0,0 +1,93 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { fetchObserveSessionEventsAsync } from '../../../observe/fetchSessions';
import {
buildObserveSessionEventsJson,
buildObserveSessionEventsTable,
} from '../../../observe/formatSessions';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveSession from '../session';
jest.mock('../../../observe/fetchSessions');
jest.mock('../../../observe/formatSessions', () => ({
buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'),
buildObserveSessionEventsJson: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveSessionEventsAsync = jest.mocked(fetchObserveSessionEventsAsync);
const mockBuildObserveSessionEventsTable = jest.mocked(buildObserveSessionEventsTable);
const mockBuildObserveSessionEventsJson = jest.mocked(buildObserveSessionEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveSession, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveSessionEventsAsync.mockResolvedValue({
entries: [],
metadata: null,
hasMoreMetricEvents: false,
hasMoreLogEvents: false,
});
});
function createCommand(argv: string[]): ObserveSession {
const command = new ObserveSession(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('requires a session ID argument', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
});
it('passes the session ID argument to the fetcher', async () => {
const command = createCommand(['session-abc']);
await command.runAsync();
expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledTimes(1);
expect(mockFetchObserveSessionEventsAsync.mock.calls[0][2].sessionId).toBe('session-abc');
});
it('hardcodes the per-source page size at 100', async () => {
const command = createCommand(['session-abc']);
await command.runAsync();
expect(mockFetchObserveSessionEventsAsync.mock.calls[0][2].limit).toBe(100);
});
it('renders the timeline table by default', async () => {
const command = createCommand(['session-abc']);
await command.runAsync();
expect(mockBuildObserveSessionEventsTable).toHaveBeenCalled();
expect(mockBuildObserveSessionEventsJson).not.toHaveBeenCalled();
});
it('emits JSON output when --json is set', async () => {
const command = createCommand(['session-abc', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveSessionEventsJson).toHaveBeenCalled();
expect(mockBuildObserveSessionEventsTable).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('rejects unsupported filter flags like --platform', async () => {
const command = createCommand(['session-abc', '--platform', 'ios']);
await expect(command.runAsync()).rejects.toThrow();
});
});
@@ -0,0 +1,87 @@
import { Args } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { fetchObserveSessionEventsAsync } from '../../observe/fetchSessions';
import { ObserveProjectIdFlag } from '../../observe/flags';
import {
buildObserveSessionEventsJson,
buildObserveSessionEventsTable,
} from '../../observe/formatSessions';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
// Fixed at 100 — the maximum page size accepted by the underlying events and
// customEventList queries. Until there's a dedicated sessions query, this
// command pulls one page of each and merges client-side.
const SESSION_PAGE_SIZE = 100;
export default class ObserveSession extends EasCommand {
static override description =
'display the timeline of metric and log events for a specific session';
static override args = {
sessionId: Args.string({
description: 'Session ID to inspect',
required: true,
}),
};
static override flags = {
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveSession);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveSession,
loggedInOnlyContextDefinition: ObserveSession.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
}
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
await fetchObserveSessionEventsAsync(graphqlClient, projectId, {
sessionId: args.sessionId,
limit: SESSION_PAGE_SIZE,
});
if (flags.json) {
printJsonOnlyOutput(
buildObserveSessionEventsJson(
entries,
args.sessionId,
metadata,
hasMoreMetricEvents,
hasMoreLogEvents
)
);
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveSessionEventsTable(entries, {
metadata,
hasMoreMetricEvents,
hasMoreLogEvents,
})
);
}
}
}
@@ -118,7 +118,8 @@ describe(SimulatorGet, () => {
id: 'session-123',
type: 'agent-device',
status: DeviceRunSessionStatus.InProgress,
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
deviceRunSessionUrl:
'https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123',
remoteConfig: session.remoteConfig,
});
});
@@ -135,7 +135,8 @@ describe(SimulatorList, () => {
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: undefined,
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
deviceRunSessionUrl:
'https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123',
},
],
pageInfo: {
@@ -61,7 +61,8 @@ type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId
const graphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/test/project';
const simulatorDotenvPath = `${projectDir}/.env.eas-simulator`;
const jobRunUrl = 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123';
const deviceRunSessionUrl =
'https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123';
const mockCreateDeviceRunSessionAsync = jest.mocked(
DeviceRunSessionMutation.createDeviceRunSessionAsync
@@ -185,7 +186,7 @@ describe(SimulatorStart, () => {
});
expect(fs.writeFile).not.toHaveBeenCalled();
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
`Device run session created (id: session-123) ${jobRunUrl}`
`Simulator session created (id: session-123) ${deviceRunSessionUrl}`
);
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining("export AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'")
@@ -214,7 +215,7 @@ describe(SimulatorStart, () => {
mockByIdAsync.mock.invocationCallOrder[0]
);
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
`Device run session created (id: session-123, saved to ${SIMULATOR_DOTENV_FILE_NAME}) ${jobRunUrl}`
`Simulator session created (id: session-123, saved to ${SIMULATOR_DOTENV_FILE_NAME}) ${deviceRunSessionUrl}`
);
expect(Log.withTick).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(
@@ -298,7 +299,7 @@ describe(SimulatorStart, () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--no-force']);
await expect(command.runAsync()).rejects.toThrow(
'Existing simulator session in environment. Use --force to create a new device session.'
'Existing simulator session in environment. Use --force to create a new simulator session.'
);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
+13 -11
View File
@@ -1,6 +1,6 @@
import { Flags } from '@oclif/core';
import { getBareJobRunUrl } from '../../build/utils/url';
import { getDeviceRunSessionUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
@@ -24,11 +24,11 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorGet extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its device run session ID';
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID';
static override flags = {
id: Flags.string({
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -61,26 +61,28 @@ export default class SimulatorGet extends EasCommand {
);
}
const fetchSpinner = ora(`Fetching device run session ${flagId}`).start();
const fetchSpinner = ora(`Fetching simulator session ${flagId}`).start();
let session;
try {
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flagId);
fetchSpinner.succeed(`Fetched device run session ${session.id}`);
fetchSpinner.succeed(`Fetched simulator session ${session.id}`);
} catch (err) {
fetchSpinner.fail(`Failed to fetch device run session ${flagId}`);
fetchSpinner.fail(`Failed to fetch simulator session ${flagId}`);
throw err;
}
const jobRunUrl = session.turtleJobRun
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
: '';
const deviceRunSessionUrl = getDeviceRunSessionUrl(
session.app.ownerAccount.name,
session.app.slug,
session.id
);
if (jsonFlag) {
printJsonOnlyOutput({
id: session.id,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
jobRunUrl: jobRunUrl || undefined,
deviceRunSessionUrl,
remoteConfig: session.remoteConfig,
});
return;
@@ -90,7 +92,7 @@ export default class SimulatorGet extends EasCommand {
Log.log(`ID: ${session.id}`);
Log.log(`Type: ${session.type}`);
Log.log(`Status: ${session.status}`);
Log.log(`URL: ${jobRunUrl ? link(jobRunUrl) : ''}`);
Log.log(`URL: ${link(deviceRunSessionUrl)}`);
if (session.status === DeviceRunSessionStatus.InProgress) {
Log.newLine();
+16 -18
View File
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { getBareJobRunUrl } from '../../build/utils/url';
import { getDeviceRunSessionUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
@@ -110,7 +110,7 @@ export default class SimulatorList extends EasCommand {
const limit = flags.limit ?? DEFAULT_LIMIT;
const fetchSpinner = jsonFlag ? null : ora('Fetching device run sessions').start();
const fetchSpinner = jsonFlag ? null : ora('Fetching simulator sessions').start();
let connection;
try {
connection = await DeviceRunSessionQuery.listByAppIdAsync(graphqlClient, {
@@ -119,9 +119,9 @@ export default class SimulatorList extends EasCommand {
after: flags.after,
filter: Object.keys(filter).length > 0 ? filter : undefined,
});
fetchSpinner?.succeed(`Fetched ${connection.edges.length} device run session(s)`);
fetchSpinner?.succeed(`Fetched ${connection.edges.length} simulator session(s)`);
} catch (err) {
fetchSpinner?.fail('Failed to fetch device run sessions');
fetchSpinner?.fail('Failed to fetch simulator sessions');
throw err;
}
@@ -137,13 +137,11 @@ export default class SimulatorList extends EasCommand {
createdAt: session.createdAt,
startedAt: session.startedAt ?? undefined,
finishedAt: session.finishedAt ?? undefined,
jobRunUrl: session.turtleJobRun
? getBareJobRunUrl(
session.app.ownerAccount.name,
session.app.slug,
session.turtleJobRun.id
)
: undefined,
deviceRunSessionUrl: getDeviceRunSessionUrl(
session.app.ownerAccount.name,
session.app.slug,
session.id
),
})),
pageInfo: connection.pageInfo,
});
@@ -152,25 +150,25 @@ export default class SimulatorList extends EasCommand {
if (sessions.length === 0) {
Log.newLine();
Log.log('No device run sessions found.');
Log.log('No simulator sessions found.');
return;
}
Log.newLine();
const formattedEntries = sessions.map(session => {
const jobRunUrl = session.turtleJobRun
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
: null;
const deviceRunSessionUrl = getDeviceRunSessionUrl(
session.app.ownerAccount.name,
session.app.slug,
session.id
);
const lines = [
`ID: ${session.id}`,
`Type: ${session.type}`,
`Status: ${session.status}`,
`Platform: ${session.platform}`,
`Created: ${fromNow(new Date(session.createdAt))} ago`,
`URL: ${link(deviceRunSessionUrl)}`,
];
if (jobRunUrl) {
lines.push(`URL: ${link(jobRunUrl)}`);
}
return lines.join('\n');
});
Log.log(formattedEntries.join(`\n\n${chalk.dim('———')}\n\n`));
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core';
import nullthrows from 'nullthrows';
import { getBareJobRunUrl } from '../../build/utils/url';
import { getDeviceRunSessionUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
@@ -54,22 +54,22 @@ export default class SimulatorStart extends EasCommand {
required: true,
})(),
type: Flags.option({
description: 'Type of device run session to create',
description: 'Type of simulator session to create',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
})(),
'package-version': Flags.string({
description:
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
'Version of the package backing the simulator 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.',
'Maximum duration of the simulator 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.',
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
default: true,
allowNo: true,
}),
@@ -107,7 +107,7 @@ export default class SimulatorStart extends EasCommand {
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
if (existingDeviceRunSessionId && !flags.force) {
throw new Error(
`Existing simulator session in environment. Use --force to create a new device session.`
`Existing simulator session in environment. Use --force to create a new simulator session.`
);
}
if (existingDeviceRunSessionId) {
@@ -121,9 +121,9 @@ export default class SimulatorStart extends EasCommand {
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
const createSpinner = ora('🚀 Creating device run session').start();
const createSpinner = ora('🚀 Creating simulator session').start();
let deviceRunSessionId: string;
let jobRunUrl: string;
let deviceRunSessionUrl: string;
try {
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
appId: projectId,
@@ -133,8 +133,12 @@ export default class SimulatorStart extends EasCommand {
maxRunTimeMinutes: flags['max-duration-minutes'],
});
deviceRunSessionId = session.id;
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
nullthrows(session.turtleJobRun?.id, 'Expected simulator session to start');
deviceRunSessionUrl = getDeviceRunSessionUrl(
session.app.ownerAccount.name,
session.app.slug,
deviceRunSessionId
);
const simulatorEnvWritten =
!jsonFlag && flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
? await writeSimulatorEnvSafelyAsync(projectDir, {
@@ -142,12 +146,12 @@ export default class SimulatorStart extends EasCommand {
})
: false;
createSpinner.succeed(
`Device run session created (id: ${deviceRunSessionId}${
`Simulator session created (id: ${deviceRunSessionId}${
simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : ''
}) ${link(jobRunUrl)}`
}) ${link(deviceRunSessionUrl)}`
);
} catch (err) {
createSpinner.fail('Failed to create device run session');
createSpinner.fail('Failed to create simulator session');
throw err;
}
@@ -164,7 +168,7 @@ export default class SimulatorStart extends EasCommand {
session.status === DeviceRunSessionStatus.Stopped
) {
throw new Error(
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(jobRunUrl)}`
`Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}`
);
}
@@ -175,7 +179,7 @@ export default class SimulatorStart extends EasCommand {
jobRunStatus === JobRunStatus.Finished
) {
throw new Error(
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(jobRunUrl)}`
`Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}`
);
}
@@ -197,7 +201,7 @@ export default class SimulatorStart extends EasCommand {
pollSpinner.fail(`Timed out waiting for ${flags.type} session to be ready`);
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
throw new Error(
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(jobRunUrl)}`
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(deviceRunSessionUrl)}`
);
}
@@ -212,7 +216,7 @@ export default class SimulatorStart extends EasCommand {
printJsonOnlyOutput({
id: deviceRunSessionId,
type: flags.type,
jobRunUrl,
deviceRunSessionUrl,
remoteConfig,
});
return;
@@ -232,7 +236,7 @@ export default class SimulatorStart extends EasCommand {
await waitForSessionEndOrInterruptAsync({
graphqlClient,
deviceRunSessionId,
jobRunUrl,
deviceRunSessionUrl,
projectDir,
});
}
@@ -258,16 +262,16 @@ async function writeSimulatorEnvSafelyAsync(
async function waitForSessionEndOrInterruptAsync({
graphqlClient,
deviceRunSessionId,
jobRunUrl,
deviceRunSessionUrl,
projectDir,
}: {
graphqlClient: ExpoGraphqlClient;
deviceRunSessionId: string;
jobRunUrl: string;
deviceRunSessionUrl: string;
projectDir: string;
}): Promise<void> {
const spinner = ora(
`Device run session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
`Simulator session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
).start();
const abortController = new AbortController();
@@ -286,7 +290,7 @@ async function waitForSessionEndOrInterruptAsync({
// Force exit on a second Ctrl+C in case cleanup is hanging. The session may still be
// running on EAS, so tell the user how to make sure it gets terminated.
spinner.fail(
`Aborted before the device run session could be stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
`Aborted before the simulator session could be stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
);
process.exit(130);
}
@@ -301,7 +305,7 @@ async function waitForSessionEndOrInterruptAsync({
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
} catch (err) {
Log.debug(
`Failed to poll device run session: ${err instanceof Error ? err.message : String(err)}`
`Failed to poll simulator session: ${err instanceof Error ? err.message : String(err)}`
);
await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]);
continue;
@@ -312,15 +316,15 @@ async function waitForSessionEndOrInterruptAsync({
session.status === DeviceRunSessionStatus.Errored ||
jobRunStatus === JobRunStatus.Errored
) {
spinner.fail(`Device run session errored. ${link(jobRunUrl)}`);
throw new Error(`Device run session ${deviceRunSessionId} errored.`);
spinner.fail(`Simulator session errored. ${link(deviceRunSessionUrl)}`);
throw new Error(`Simulator session ${deviceRunSessionId} errored.`);
}
if (
session.status === DeviceRunSessionStatus.Stopped ||
jobRunStatus === JobRunStatus.Canceled ||
jobRunStatus === JobRunStatus.Finished
) {
spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`);
spinner.succeed(`Simulator session ended. ${link(deviceRunSessionUrl)}`);
await resetSimulatorEnvVerboseAsync(projectDir);
return;
}
@@ -328,17 +332,17 @@ async function waitForSessionEndOrInterruptAsync({
await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]);
}
spinner.text = 'Stopping device run session...';
spinner.text = 'Stopping simulator session...';
const stopped = await ensureDeviceRunSessionStoppedSafelyAsync(
graphqlClient,
deviceRunSessionId
);
if (stopped) {
spinner.succeed('Device run session stopped');
spinner.succeed('Simulator session stopped');
await resetSimulatorEnvVerboseAsync(projectDir);
} else {
spinner.fail(
`Could not confirm the device run session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
);
}
} finally {
@@ -368,7 +372,7 @@ async function ensureDeviceRunSessionStoppedSafelyAsync(
} catch (err) {
// Cleanup is best-effort; surface the failure but don't mask the original error.
Log.warn(
`Failed to stop device run session ${deviceRunSessionId}: ${
`Failed to stop simulator session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
}`
);
@@ -17,11 +17,11 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorStop extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
'[EXPERIMENTAL] stop a remote simulator session on EAS by its simulator session ID';
static override flags = {
id: Flags.string({
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -54,16 +54,16 @@ export default class SimulatorStop extends EasCommand {
);
}
const stopSpinner = ora(`🛑 Stopping device run session ${flagId}`).start();
const stopSpinner = ora(`🛑 Stopping simulator session ${flagId}`).start();
let session;
try {
session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
graphqlClient,
flagId
);
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
stopSpinner.succeed(`🎉 Simulator session ${session.id} is ${session.status.toLowerCase()}`);
} catch (err) {
stopSpinner.fail(`Failed to stop device run session ${flagId}`);
stopSpinner.fail(`Failed to stop simulator session ${flagId}`);
throw err;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { Env, Platform, Workflow } from '@expo/eas-build-job';
import { createFingerprintAsync } from './cli';
import { Fingerprint } from './types';
@@ -16,7 +16,7 @@ export async function getFingerprintInfoFromLocalProjectForPlatformsAsync(
projectId: string,
vcsClient: Client,
platforms: AppPlatform[],
{ env }: { env?: Record<string, string> } = {}
{ env }: { env?: Env } = {}
): Promise<Fingerprint> {
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
const optionsFromWorkflow = getFingerprintOptionsFromWorkflow(platforms, workflows);
+390 -79
View File
@@ -1194,7 +1194,6 @@ export type AndroidAppCredentialsMutationCreateAndroidAppCredentialsArgs = {
export type AndroidAppCredentialsMutationCreateFcmV1CredentialArgs = {
accountId?: InputMaybe<Scalars['ID']['input']>;
androidAppCredentialsId: Scalars['String']['input'];
credential: Scalars['String']['input'];
};
@@ -1350,8 +1349,6 @@ export type AndroidJobOverridesInput = {
export type AndroidJobSecretsInput = {
buildCredentials?: InputMaybe<AndroidJobBuildCredentialsInput>;
/** @deprecated Robot access token is no longer needed as it's generated server-side and injected into the builder environment automatically. */
robotAccessToken?: InputMaybe<Scalars['String']['input']>;
};
export type AndroidJobVersionInput = {
@@ -1379,8 +1376,6 @@ export type AndroidKeystoreInput = {
keyAlias: Scalars['String']['input'];
keyPassword?: InputMaybe<Scalars['String']['input']>;
keystorePassword: Scalars['String']['input'];
/** @deprecated Type will be automatically inferred from keystore content. */
type?: InputMaybe<AndroidKeystoreType>;
};
export type AndroidKeystoreMutation = {
@@ -1441,6 +1436,8 @@ export type App = Project & {
/** Android app credentials for the project */
androidAppCredentials: Array<AndroidAppCredentials>;
appStoreConnectApp?: Maybe<AppStoreConnectApp>;
/** Connection status for this project's App Store Connect-triggered workflows. */
appStoreConnectWorkflowConnectionStatus: AppStoreConnectWorkflowConnectionStatus;
/**
* ios.appStoreUrl field from most recent classic update manifest
* @deprecated Classic updates have been deprecated.
@@ -1490,7 +1487,7 @@ export type App = Project & {
* @deprecated Classic updates have been deprecated.
*/
icon?: Maybe<AppIcon>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
iconUrl?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** App query field for querying EAS Insights about this app */
@@ -1508,7 +1505,7 @@ export type App = Project & {
/** @deprecated 'likes' have been deprecated. */
isLikedByMe: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
lastPublishedTime: Scalars['DateTime']['output'];
/** Time of the last user activity (update, branch, submission). */
latestActivity: Scalars['DateTime']['output'];
@@ -1528,9 +1525,9 @@ export type App = Project & {
name: Scalars['String']['output'];
observe: AppObserve;
ownerAccount: Account;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
packageName: Scalars['String']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
packageUsername: Scalars['String']['output'];
/**
* android.playStoreUrl field from most recent classic update manifest
@@ -1540,7 +1537,7 @@ export type App = Project & {
posthogProject?: Maybe<PostHogProject>;
/** @deprecated No longer supported */
privacy: Scalars['String']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
privacySetting: AppPrivacy;
profileImageUrl?: Maybe<Scalars['String']['output']>;
/**
@@ -1596,11 +1593,12 @@ export type App = Project & {
/** EAS updates owned by an app */
updates: Array<Update>;
updatesPaginated: AppUpdatesConnection;
updatesTimeline: UpdatesTimelineConnection;
/** Project query object for querying EAS usage metrics */
usageMetrics: AppUsageMetrics;
/** @deprecated Use ownerAccount.name instead */
username: Scalars['String']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
users?: Maybe<Array<Maybe<User>>>;
vexoApp?: Maybe<VexoApp>;
/** Notification preferences of the viewer for this app */
@@ -1630,7 +1628,6 @@ export type AppActivityTimelineProjectActivitiesArgs = {
createdBefore?: InputMaybe<Scalars['DateTime']['input']>;
filterChannels?: InputMaybe<Array<Scalars['String']['input']>>;
filterPlatforms?: InputMaybe<Array<AppPlatform>>;
filterReleaseChannels?: InputMaybe<Array<Scalars['String']['input']>>;
filterTypes?: InputMaybe<Array<ActivityTimelineProjectActivityType>>;
limit: Scalars['Int']['input'];
};
@@ -1880,6 +1877,16 @@ export type AppUpdatesPaginatedArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppUpdatesTimelineArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<UpdatesTimelineFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWebhooksArgs = {
filter?: InputMaybe<WebhookFilter>;
@@ -2090,7 +2097,7 @@ export type AppFingerprintsConnection = {
export type AppIcon = {
__typename?: 'AppIcon';
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
colorPalette?: Maybe<Scalars['JSON']['output']>;
originalUrl: Scalars['String']['output'];
primaryColor?: Maybe<Scalars['String']['output']>;
@@ -2104,8 +2111,6 @@ export type AppInfoInput = {
export type AppInput = {
accountId: Scalars['ID']['input'];
appInfo?: InputMaybe<AppInfoInput>;
/** @deprecated Field no longer supported */
privacy?: InputMaybe<AppPrivacy>;
projectName: Scalars['String']['input'];
};
@@ -2141,7 +2146,7 @@ export type AppMutation = {
__typename?: 'AppMutation';
/** Create an app */
createApp: App;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
grantAccess?: Maybe<App>;
/** Remove profile image (icon) for the app. Do nothing if there's no profile image associated. */
removeProfileImage: App;
@@ -2217,6 +2222,14 @@ export type AppObserve = {
customEventList: AppObserveCustomEventListConnection;
customEventNames: AppObserveCustomEventNames;
environments: Array<Scalars['String']['output']>;
/** Breaks a single error group down by app version, OS, device, or country (detail page bars). */
errorGroupBreakdown: AppObserveErrorGroupBreakdown;
/** Distinct unhandled-JS-error groups (the issues list), grouped by fingerprint. */
errorGroups: AppObserveErrorGroups;
/** Headline error stats for the overview: crash-free rates and error/affected counts. */
errorStats: AppObserveErrorStats;
/** Time-bucketed exception counts split into fatal vs non-fatal, for the stacked-bar chart. */
errorTimeSeries: AppObserveErrorTimeSeries;
events: AppObserveEventsConnection;
navigationRoutes: AppObserveNavigationRoutesConnection;
timeSeries: AppObserveTimeSeries;
@@ -2274,6 +2287,26 @@ export type AppObserveEnvironmentsArgs = {
};
export type AppObserveErrorGroupBreakdownArgs = {
input: AppObserveErrorGroupBreakdownInput;
};
export type AppObserveErrorGroupsArgs = {
input: AppObserveErrorGroupsInput;
};
export type AppObserveErrorStatsArgs = {
input: AppObserveErrorStatsInput;
};
export type AppObserveErrorTimeSeriesArgs = {
input: AppObserveErrorTimeSeriesInput;
};
export type AppObserveEventsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
@@ -2414,10 +2447,17 @@ export type AppObserveCustomEvent = {
deviceOsVersion: Scalars['String']['output'];
easClientId: Scalars['String']['output'];
environment?: Maybe<Scalars['String']['output']>;
errorFingerprint?: Maybe<Scalars['String']['output']>;
errorSource?: Maybe<Scalars['String']['output']>;
eventName: Scalars['String']['output'];
exceptionMessage?: Maybe<Scalars['String']['output']>;
exceptionStacktrace?: Maybe<Scalars['String']['output']>;
/** Error fields below are populated only for exception events (eventName: exception). */
exceptionType?: Maybe<Scalars['String']['output']>;
expoSdkVersion?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
ingestedAt?: Maybe<Scalars['DateTime']['output']>;
isFatal?: Maybe<Scalars['Boolean']['output']>;
properties: Array<AppObserveEventProperty>;
reactNativeVersion?: Maybe<Scalars['String']['output']>;
sessionId?: Maybe<Scalars['String']['output']>;
@@ -2472,6 +2512,8 @@ export type AppObserveCustomEventListFilter = {
easClientId?: InputMaybe<Scalars['String']['input']>;
endTime?: InputMaybe<Scalars['DateTime']['input']>;
environment?: InputMaybe<Scalars['String']['input']>;
/** Restrict to one error group. Combine with the exception eventName to list a group's occurrences. */
errorFingerprint?: InputMaybe<Scalars['String']['input']>;
eventName?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
@@ -2512,6 +2554,167 @@ export type AppObserveCustomEventPropertyFilter = {
value: Scalars['String']['input'];
};
export type AppObserveErrorBreakdownBucket = {
__typename?: 'AppObserveErrorBreakdownBucket';
count: Scalars['Int']['output'];
/** Dimension value (app version, OS, device model, or country code). Empty string when unknown. */
key: Scalars['String']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
export enum AppObserveErrorBreakdownDimension {
AppVersion = 'APP_VERSION',
Country = 'COUNTRY',
Device = 'DEVICE',
Os = 'OS'
}
export type AppObserveErrorGroup = {
__typename?: 'AppObserveErrorGroup';
affectedSessionCount: Scalars['Int']['output'];
/** How the error was captured, from the most recent occurrence (e.g. global). */
errorSource?: Maybe<Scalars['String']['output']>;
eventCount: Scalars['Int']['output'];
/** Error message, from the most recent occurrence. */
exceptionMessage?: Maybe<Scalars['String']['output']>;
/** Error class/type, from the most recent occurrence (e.g. TypeError). */
exceptionType?: Maybe<Scalars['String']['output']>;
/** Stable grouping key (sha256 over error type + entropy-normalized message). */
fingerprint: Scalars['String']['output'];
firstSeenAt: Scalars['DateTime']['output'];
isFatal: Scalars['Boolean']['output'];
lastSeenAt: Scalars['DateTime']['output'];
/** Distinct device OS values seen for this group (e.g. Android, iOS). */
platforms: Array<Scalars['String']['output']>;
/** FATAL if any occurrence in the group was fatal, otherwise ERROR. */
severity: AppObserveErrorSeverity;
uniqueUserCount: Scalars['Int']['output'];
};
export type AppObserveErrorGroupBreakdown = {
__typename?: 'AppObserveErrorGroupBreakdown';
/** Buckets ordered by count descending. */
buckets: Array<AppObserveErrorBreakdownBucket>;
/** True when more buckets exist than were returned (top-N truncation). */
isTruncated: Scalars['Boolean']['output'];
};
/** Filters for the per-group breakdown. Same release/time/platform filters used across Observe. */
export type AppObserveErrorGroupBreakdownInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
dimension: AppObserveErrorBreakdownDimension;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
fingerprint: Scalars['String']['input'];
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveErrorGroups = {
__typename?: 'AppObserveErrorGroups';
groups: Array<AppObserveErrorGroup>;
/** True when more groups exist than were returned (top-N truncation). */
isTruncated: Scalars['Boolean']['output'];
};
export type AppObserveErrorGroupsInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
/** Restrict to a single group, e.g. to fetch one group's header on the detail page. */
fingerprint?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
orderBy?: InputMaybe<AppObserveErrorGroupsOrderBy>;
platform?: InputMaybe<AppObservePlatform>;
/** Restrict to fatal-only or non-fatal-only errors. */
severity?: InputMaybe<AppObserveErrorSeverity>;
/** Restrict to a capture source (e.g. global). */
source?: InputMaybe<Scalars['String']['input']>;
startTime: Scalars['DateTime']['input'];
};
export enum AppObserveErrorGroupsOrderBy {
FirstSeen = 'FIRST_SEEN',
LastSeen = 'LAST_SEEN',
MostFrequent = 'MOST_FREQUENT',
MostUsers = 'MOST_USERS'
}
export enum AppObserveErrorSeverity {
Error = 'ERROR',
Fatal = 'FATAL'
}
export type AppObserveErrorStats = {
__typename?: 'AppObserveErrorStats';
/** Distinct sessions that hit any exception. */
affectedSessions: Scalars['Int']['output'];
/** Distinct users that hit any exception. */
affectedUsers: Scalars['Int']['output'];
/** Fraction (0..1) of active sessions without a fatal error. Denominator spans app_metrics and app_events. */
crashFreeSessions: Scalars['Float']['output'];
/** Fraction (0..1) of active users without a fatal error. */
crashFreeUsers: Scalars['Float']['output'];
fatalCount: Scalars['Int']['output'];
nonFatalCount: Scalars['Int']['output'];
/** Total exception events in range. */
totalErrors: Scalars['Int']['output'];
};
/** Filters for the error-stats header. Same release/time/platform filters used across Observe. */
export type AppObserveErrorStatsInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveErrorTimeSeries = {
__typename?: 'AppObserveErrorTimeSeries';
buckets: Array<AppObserveErrorTimeSeriesBucket>;
/** Sum of fatalCount across all buckets. */
totalFatalCount: Scalars['Int']['output'];
/** Sum of nonFatalCount across all buckets. */
totalNonFatalCount: Scalars['Int']['output'];
};
export type AppObserveErrorTimeSeriesBucket = {
__typename?: 'AppObserveErrorTimeSeriesBucket';
/** Start of the bucket interval. */
bucket: Scalars['DateTime']['output'];
fatalCount: Scalars['Int']['output'];
nonFatalCount: Scalars['Int']['output'];
};
/** Filters for the error time series. Same release/time/platform filters used across Observe, plus the bucket size. */
export type AppObserveErrorTimeSeriesInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
/** Bucket width in minutes. Defaults to 60. */
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
/** Restrict to one error group for the detail page's occurrences-over-time chart. Omit for the overview chart. */
fingerprint?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveEvent = {
__typename?: 'AppObserveEvent';
appBuildNumber: Scalars['String']['output'];
@@ -2569,8 +2772,11 @@ export type AppObserveEventsConnection = {
export type AppObserveEventsFilter = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
/** Filter by the update the device was *running* at event time (the app_update_id column). */
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
/** Filter by the update that was *downloaded* (the expo.update_id tag), as surfaced by the EAS Update Recent updates download drill-down. Distinct from appUpdateId (running update); the two are not interchangeable. */
downloadedUpdateId?: InputMaybe<Scalars['String']['input']>;
easClientId?: InputMaybe<Scalars['String']['input']>;
endTime?: InputMaybe<Scalars['DateTime']['input']>;
environment?: InputMaybe<Scalars['String']['input']>;
@@ -3032,6 +3238,16 @@ export enum AppStoreConnectUserRole {
Unknown = 'UNKNOWN'
}
/** Whether a project has an App Store Connect connection for its ASC-triggered workflows. */
export enum AppStoreConnectWorkflowConnectionStatus {
/** An App Store Connect app connection is linked to this project. */
HasWorkflowsIsConnected = 'HAS_WORKFLOWS_IS_CONNECTED',
/** The project has App Store Connect-triggered workflows but no connection. */
HasWorkflowsMissingConnection = 'HAS_WORKFLOWS_MISSING_CONNECTION',
/** The project has no workflows with an on.app_store_connect trigger. */
NoAppStoreConnectWorkflows = 'NO_APP_STORE_CONNECT_WORKFLOWS'
}
export type AppSubmissionEdge = {
__typename?: 'AppSubmissionEdge';
cursor: Scalars['String']['output'];
@@ -3148,8 +3364,6 @@ export type AppWithGithubRepositoryInput = {
accountId: Scalars['ID']['input'];
appInfo?: InputMaybe<AppInfoInput>;
installationIdentifier?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Field no longer supported */
privacy?: InputMaybe<AppPrivacy>;
projectName: Scalars['String']['input'];
};
@@ -3951,6 +4165,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
deployment?: Maybe<Deployment>;
developmentClient?: Maybe<Scalars['Boolean']['output']>;
distribution?: Maybe<DistributionType>;
embeddedUpdate?: Maybe<EmbeddedUpdate>;
enqueuedAt?: Maybe<Scalars['DateTime']['output']>;
error?: Maybe<BuildError>;
estimatedWaitTimeLeftSeconds?: Maybe<Scalars['Int']['output']>;
@@ -4116,11 +4331,7 @@ export type BuildArtifacts = {
};
export type BuildCacheInput = {
/** @deprecated We don't cache anything by default anymore */
cacheDefaultPaths?: InputMaybe<Scalars['Boolean']['input']>;
clear?: InputMaybe<Scalars['Boolean']['input']>;
/** @deprecated We use paths now since there is no default caching anymore */
customPaths?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
disabled?: InputMaybe<Scalars['Boolean']['input']>;
key?: InputMaybe<Scalars['String']['input']>;
paths?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -4192,8 +4403,6 @@ export type BuildMetadataInput = {
appIdentifier?: InputMaybe<Scalars['String']['input']>;
appName?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Use job.mode instead. */
buildMode?: InputMaybe<BuildMode>;
buildProfile?: InputMaybe<Scalars['String']['input']>;
channel?: InputMaybe<Scalars['String']['input']>;
cliVersion?: InputMaybe<Scalars['String']['input']>;
@@ -4219,8 +4428,6 @@ export type BuildMetadataInput = {
runWithNoWaitFlag?: InputMaybe<Scalars['Boolean']['input']>;
runtimeVersion?: InputMaybe<Scalars['String']['input']>;
sdkVersion?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Unused. */
selectedImage?: InputMaybe<Scalars['String']['input']>;
simulator?: InputMaybe<Scalars['Boolean']['input']>;
trackingContext?: InputMaybe<Scalars['JSONObject']['input']>;
username?: InputMaybe<Scalars['String']['input']>;
@@ -4386,7 +4593,7 @@ export enum BuildPhase {
StartBuild = 'START_BUILD',
Unknown = 'UNKNOWN',
UploadApplicationArchive = 'UPLOAD_APPLICATION_ARCHIVE',
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
UploadArtifacts = 'UPLOAD_ARTIFACTS',
UploadBuildArtifacts = 'UPLOAD_BUILD_ARTIFACTS'
}
@@ -4581,6 +4788,11 @@ export type CodeSigningInfoInput = {
sig: Scalars['String']['input'];
};
export type CompletePostHogConnectionInput = {
code: Scalars['String']['input'];
state: Scalars['ID']['input'];
};
export type Concurrencies = {
__typename?: 'Concurrencies';
android: Scalars['Int']['output'];
@@ -4741,6 +4953,18 @@ export type CreateConvexTeamConnectionInput = {
deploymentRegion: Scalars['String']['input'];
};
export type CreateDeviceRunSessionArtifactUploadSessionInput = {
filename: Scalars['String']['input'];
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
name: Scalars['String']['input'];
size: Scalars['Int']['input'];
};
export type CreateDeviceRunSessionArtifactUploadSessionResult = {
__typename?: 'CreateDeviceRunSessionArtifactUploadSessionResult';
uploadSession: DeviceRunSessionArtifactUploadSession;
};
export type CreateDeviceRunSessionInput = {
appId: Scalars['ID']['input'];
/**
@@ -4929,8 +5153,6 @@ export type CreateSentryProjectInput = {
export type CreateSharedEnvironmentVariableInput = {
environments?: InputMaybe<Array<Scalars['EnvironmentVariableEnvironment']['input']>>;
fileName?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Whether the variable is global is now governed by its scope - account / app. */
isGlobal?: InputMaybe<Scalars['Boolean']['input']>;
name: Scalars['String']['input'];
overwrite?: InputMaybe<Scalars['Boolean']['input']>;
type?: InputMaybe<EnvironmentSecretType>;
@@ -5370,6 +5592,7 @@ export type DeploymentsMutationDeleteWorkerDeploymentByIdentifierArgs = {
export type DeviceRunSession = {
__typename?: 'DeviceRunSession';
app: App;
artifacts: Array<DeviceRunSessionArtifact>;
createdAt: Scalars['DateTime']['output'];
finishedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
@@ -5388,6 +5611,35 @@ export type DeviceRunSession = {
updatedAt: Scalars['DateTime']['output'];
};
export type DeviceRunSessionArtifact = {
__typename?: 'DeviceRunSessionArtifact';
createdAt: Scalars['DateTime']['output'];
deviceRunSession: DeviceRunSession;
downloadUrl: Scalars['String']['output'];
fileSizeBytes?: Maybe<Scalars['Float']['output']>;
filename: Scalars['String']['output'];
id: Scalars['ID']['output'];
metadata?: Maybe<Scalars['JSONObject']['output']>;
name: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type DeviceRunSessionArtifactQuery = {
__typename?: 'DeviceRunSessionArtifactQuery';
byId: DeviceRunSessionArtifact;
};
export type DeviceRunSessionArtifactQueryByIdArgs = {
deviceRunSessionArtifactId: Scalars['ID']['input'];
};
export type DeviceRunSessionArtifactUploadSession = {
__typename?: 'DeviceRunSessionArtifactUploadSession';
headers: Scalars['JSONObject']['output'];
url: Scalars['String']['output'];
};
export type DeviceRunSessionFilterInput = {
platforms?: InputMaybe<Array<AppPlatform>>;
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
@@ -5396,6 +5648,8 @@ export type DeviceRunSessionFilterInput = {
export type DeviceRunSessionMutation = {
__typename?: 'DeviceRunSessionMutation';
/** Create an upload session for an artifact produced by an active device run session */
createArtifactUploadSession: CreateDeviceRunSessionArtifactUploadSessionResult;
/** Create a device run session */
createDeviceRunSession: DeviceRunSession;
/**
@@ -5409,6 +5663,12 @@ export type DeviceRunSessionMutation = {
};
export type DeviceRunSessionMutationCreateArtifactUploadSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
input: CreateDeviceRunSessionArtifactUploadSessionInput;
};
export type DeviceRunSessionMutationCreateDeviceRunSessionArgs = {
deviceRunSessionInput: CreateDeviceRunSessionInput;
};
@@ -6207,13 +6467,20 @@ export type EditUpdateBranchInput = {
export type EmbeddedUpdate = {
__typename?: 'EmbeddedUpdate';
build?: Maybe<Build>;
channel: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
/** The manifest UUID baked into the binary by expo-updates at build time. */
id: Scalars['ID']['output'];
launchAsset: EmbeddedUpdateAsset;
platform: AppPlatform;
runtime?: Maybe<Runtime>;
runtimeVersion: Scalars['String']['output'];
/**
* A short-lived signed URL for downloading this bundle's launch asset. Minted on access,
* so only select it when the user is actually downloading.
*/
signedAssetUrl: Scalars['String']['output'];
};
export type EmbeddedUpdateAsset = {
@@ -7642,8 +7909,6 @@ export type IosJobOverridesInput = {
export type IosJobSecretsInput = {
buildCredentials?: InputMaybe<Array<InputMaybe<IosJobTargetCredentialsInput>>>;
/** @deprecated Robot access token is no longer needed as it's generated server-side and injected into the builder environment automatically. */
robotAccessToken?: InputMaybe<Scalars['String']['input']>;
};
export type IosJobTargetCredentialsInput = {
@@ -7690,7 +7955,7 @@ export type JobRun = {
__typename?: 'JobRun';
app: App;
artifacts: Array<WorkflowArtifact>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
childJobRun?: Maybe<JobRun>;
createdAt: Scalars['DateTime']['output'];
displayName?: Maybe<Scalars['String']['output']>;
@@ -8296,9 +8561,24 @@ export type PostHogOrganizationConnection = {
export type PostHogOrganizationConnectionMutation = {
__typename?: 'PostHogOrganizationConnectionMutation';
/**
* Completes an existing-user connection from the browser callback: exchanges the
* authorization code with the stored PKCE verifier and persists the connection.
*/
completePostHogConnection: PostHogOrganizationConnection;
createPostHogAccountRequest: PostHogOrganizationConnection;
/** Removes the Expo-side connection only; the PostHog organization is preserved. */
deletePostHogOrganizationConnection: Scalars['ID']['output'];
/**
* Starts a PostHog connection. Returns the connection directly for a new PostHog
* user, or a pending browser-auth handoff for an existing PostHog user.
*/
startPostHogConnection: StartPostHogConnectionResult;
};
export type PostHogOrganizationConnectionMutationCompletePostHogConnectionArgs = {
input: CompletePostHogConnectionInput;
};
@@ -8311,6 +8591,22 @@ export type PostHogOrganizationConnectionMutationDeletePostHogOrganizationConnec
id: Scalars['ID']['input'];
};
export type PostHogOrganizationConnectionMutationStartPostHogConnectionArgs = {
input: CreatePostHogAccountRequestInput;
};
/**
* Returned when the account's email already belongs to a PostHog account. The user
* must approve the connection in their browser at `url`; the CLI then polls for
* completion with `state`.
*/
export type PostHogPendingConnection = {
__typename?: 'PostHogPendingConnection';
state: Scalars['ID']['output'];
url: Scalars['String']['output'];
};
export type PostHogProject = {
__typename?: 'PostHogProject';
app: App;
@@ -8350,7 +8646,7 @@ export enum PostHogRegion {
export type Project = {
description: Scalars['String']['output'];
fullName: Scalars['String']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
iconUrl?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
name: Scalars['String']['output'];
@@ -8421,8 +8717,6 @@ export type PublishUpdateGroupInput = {
message?: InputMaybe<Scalars['String']['input']>;
rollBackToEmbeddedInfoGroup?: InputMaybe<UpdateRollBackToEmbeddedGroup>;
rolloutInfoGroup?: InputMaybe<UpdateRolloutInfoGroup>;
/** @deprecated Use fingerprintInfoGroup instead */
runtimeFingerprintSource?: InputMaybe<FingerprintSourceInput>;
runtimeVersion: Scalars['String']['input'];
turtleJobRunId?: InputMaybe<Scalars['String']['input']>;
updateInfoGroup?: InputMaybe<UpdateInfoGroup>;
@@ -8816,6 +9110,7 @@ export type RootQuery = {
convexIntegration: ConvexIntegrationQuery;
/** Top-level query object for querying Deployments. */
deployments: DeploymentQuery;
deviceRunSessionArtifacts: DeviceRunSessionArtifactQuery;
deviceRunSessions: DeviceRunSessionQuery;
/** Top-level query object for querying Echo chats. */
echoChat: EchoChatQuery;
@@ -9047,7 +9342,7 @@ export type SsoUser = Actor & UserActor & {
/** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/**
* Apps this user has published. If this user is the viewer, this field returns the apps the user has access to.
@@ -9070,16 +9365,16 @@ export type SsoUser = Actor & UserActor & {
fullName?: Maybe<Scalars['String']['output']>;
/** GitHub account linked to a user */
githubUser?: Maybe<GitHubUser>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
githubUsername?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
industry?: Maybe<Scalars['String']['output']>;
isExpoAdmin: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
pinnedApps: Array<App>;
pinnedDashboardViews: Array<PinnedDashboardView>;
@@ -9091,7 +9386,7 @@ export type SsoUser = Actor & UserActor & {
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this account */
snacks: Array<Snack>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']['output']>;
username: Scalars['String']['output'];
websiteNotificationsPaginated: WebsiteNotificationsConnection;
@@ -9137,15 +9432,7 @@ export type SsoUserWebsiteNotificationsPaginatedArgs = {
export type SsoUserDataInput = {
firstName?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Field no longer supported */
githubUsername?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Field no longer supported */
industry?: InputMaybe<Scalars['String']['input']>;
lastName?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Field no longer supported */
location?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Field no longer supported */
twitterUsername?: InputMaybe<Scalars['String']['input']>;
};
export type SecondFactorBooleanResult = {
@@ -9303,7 +9590,7 @@ export type Snack = Project & {
/** Has the Snack been run without errors */
hasBeenRunSuccessfully?: Maybe<Scalars['Boolean']['output']>;
hashId: Scalars['String']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
iconUrl?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Draft status, which is true when the Snack was not saved explicitly, but auto-saved */
@@ -9355,6 +9642,8 @@ export enum StandardOffer {
YearlySub = 'YEARLY_SUB'
}
export type StartPostHogConnectionResult = PostHogOrganizationConnection | PostHogPendingConnection;
/** Incident for a given component from Expo status page API. */
export type StatuspageIncident = {
__typename?: 'StatuspageIncident';
@@ -10090,8 +10379,6 @@ export type UpdateEnvironmentVariableInput = {
environments?: InputMaybe<Array<Scalars['EnvironmentVariableEnvironment']['input']>>;
fileName?: InputMaybe<Scalars['String']['input']>;
id: Scalars['ID']['input'];
/** @deprecated You cannot change whether the variable is global. */
isGlobal?: InputMaybe<Scalars['Boolean']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
type?: InputMaybe<EnvironmentSecretType>;
value?: InputMaybe<Scalars['String']['input']>;
@@ -10127,6 +10414,14 @@ export type UpdateGitHubRepositorySettingsInput = {
baseDirectory: Scalars['String']['input'];
};
export type UpdateGroup = {
__typename?: 'UpdateGroup';
createdAt: Scalars['DateTime']['output'];
/** The shared update_group UUID of the member updates. */
id: Scalars['ID']['output'];
updates: Array<Update>;
};
export type UpdateGroupEdge = {
__typename?: 'UpdateGroupEdge';
cursor: Scalars['String']['output'];
@@ -10241,8 +10536,6 @@ export type UpdateVexoAppInput = {
export type UpdatesFilter = {
platform?: InputMaybe<AppPlatform>;
runtimeVersions?: InputMaybe<Array<Scalars['String']['input']>>;
/** @deprecated Use runtimeVersions instead. */
sdkVersions?: InputMaybe<Array<Scalars['String']['input']>>;
};
export type UpdatesFilterV2 = {
@@ -10257,6 +10550,32 @@ export type UpdatesMetricsData = {
labels: Array<Scalars['String']['output']>;
};
export type UpdatesTimelineConnection = {
__typename?: 'UpdatesTimelineConnection';
edges: Array<UpdatesTimelineEdge>;
pageInfo: PageInfo;
};
export type UpdatesTimelineEdge = {
__typename?: 'UpdatesTimelineEdge';
cursor: Scalars['String']['output'];
node: UpdatesTimelineItem;
};
export type UpdatesTimelineFilter = {
channel?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppPlatform>;
runtimeVersions?: InputMaybe<Array<Scalars['String']['input']>>;
types?: InputMaybe<Array<UpdatesTimelineItemType>>;
};
export type UpdatesTimelineItem = EmbeddedUpdate | UpdateGroup;
export enum UpdatesTimelineItemType {
EmbeddedUpdate = 'EMBEDDED_UPDATE',
UpdateGroup = 'UPDATE_GROUP'
}
export type UploadEmbeddedUpdateInput = {
appId: Scalars['ID']['input'];
channel: Scalars['String']['input'];
@@ -10357,7 +10676,7 @@ export type User = Actor & UserActor & {
/** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/**
* Apps this user has published
@@ -10382,22 +10701,22 @@ export type User = Actor & UserActor & {
fullName?: Maybe<Scalars['String']['output']>;
/** GitHub account linked to a user */
githubUser?: Maybe<GitHubUser>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
githubUsername?: Maybe<Scalars['String']['output']>;
hasPassword: Scalars['Boolean']['output'];
/** Whether this user has any pending user invitations. Only resolves for the viewer. */
hasPendingUserInvitations: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
industry?: Maybe<Scalars['String']['output']>;
isExpoAdmin: Scalars['Boolean']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
isLegacy: Scalars['Boolean']['output'];
isSecondFactorAuthenticationEnabled: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
newEmailPendingVerification?: Maybe<Scalars['String']['output']>;
oAuthIdentities: Array<OAuthIdentity>;
@@ -10417,7 +10736,7 @@ export type User = Actor & UserActor & {
secondFactorDevices: Array<UserSecondFactorDevice>;
/** Snacks associated with this account */
snacks: Array<Snack>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']['output']>;
username: Scalars['String']['output'];
websiteNotificationsPaginated: WebsiteNotificationsConnection;
@@ -10472,7 +10791,7 @@ export type UserActor = {
*/
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
appCount: Scalars['Int']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/**
* Apps this user has published
@@ -10499,16 +10818,16 @@ export type UserActor = {
fullName?: Maybe<Scalars['String']['output']>;
/** GitHub account linked to a user */
githubUser?: Maybe<GitHubUser>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
githubUsername?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
industry?: Maybe<Scalars['String']['output']>;
isExpoAdmin: Scalars['Boolean']['output'];
isStaffModeEnabled: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
pinnedApps: Array<App>;
preferences: UserPreferences;
@@ -10519,7 +10838,7 @@ export type UserActor = {
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this user's personal account */
snacks: Array<Snack>;
/** @deprecated Field no longer supported */
/** @deprecated No longer supported */
twitterUsername?: Maybe<Scalars['String']['output']>;
username: Scalars['String']['output'];
websiteNotificationsPaginated: WebsiteNotificationsConnection;
@@ -10765,22 +11084,12 @@ export type UserDashboardViewPinMutationUnpinDashboardViewArgs = {
};
export type UserDataInput = {
/** @deprecated Field no longer supported */
appetizeCode?: InputMaybe<Scalars['String']['input']>;
email?: InputMaybe<Scalars['String']['input']>;
firstName?: InputMaybe<Scalars['String']['input']>;
fullName?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Field no longer supported */
githubUsername?: InputMaybe<Scalars['String']['input']>;
id?: InputMaybe<Scalars['ID']['input']>;
/** @deprecated Field no longer supported */
industry?: InputMaybe<Scalars['String']['input']>;
lastName?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Field no longer supported */
location?: InputMaybe<Scalars['String']['input']>;
profilePhoto?: InputMaybe<Scalars['String']['input']>;
/** @deprecated Field no longer supported */
twitterUsername?: InputMaybe<Scalars['String']['input']>;
username?: InputMaybe<Scalars['String']['input']>;
};
@@ -12395,6 +12704,8 @@ export enum WorkflowRunTriggerEventType {
AppStoreConnectExternalBetaStateChanged = 'APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED',
EasSubmit = 'EAS_SUBMIT',
ExpoLaunch = 'EXPO_LAUNCH',
GithubPullRequestBaseRefChanged = 'GITHUB_PULL_REQUEST_BASE_REF_CHANGED',
GithubPullRequestEdited = 'GITHUB_PULL_REQUEST_EDITED',
GithubPullRequestLabeled = 'GITHUB_PULL_REQUEST_LABELED',
GithubPullRequestOpened = 'GITHUB_PULL_REQUEST_OPENED',
GithubPullRequestReadyForReview = 'GITHUB_PULL_REQUEST_READY_FOR_REVIEW',
@@ -13253,12 +13564,12 @@ export type CreateLocalBuildMutationVariables = Exact<{
export type CreateLocalBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createLocalBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } } };
export type CreatePostHogAccountRequestMutationVariables = Exact<{
export type StartPostHogConnectionMutationVariables = Exact<{
input: CreatePostHogAccountRequestInput;
}>;
export type CreatePostHogAccountRequestMutation = { __typename?: 'RootMutation', posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnectionMutation', createPostHogAccountRequest: { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } } };
export type StartPostHogConnectionMutation = { __typename?: 'RootMutation', posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnectionMutation', startPostHogConnection: { __typename: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } | { __typename: 'PostHogPendingConnection', url: string } } };
export type SetupPostHogProjectMutationVariables = Exact<{
input: SetupPostHogProjectInput;
@@ -13444,7 +13755,7 @@ export type AccountUsageForOverageWarningQueryVariables = Exact<{
}>;
export type AccountUsageForOverageWarningQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null } | null, usageMetrics: { __typename?: 'AccountUsageMetrics', EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, serviceMetric: EasServiceMetric, value: number, limit: number }> } } } } };
export type AccountUsageForOverageWarningQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null } | null, usageMetrics: { __typename?: 'AccountUsageMetrics', EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, serviceMetric: EasServiceMetric, value: number, limit: number }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, value: number }> } } } } };
export type AccountBillingPeriodQueryVariables = Exact<{
accountId: Scalars['String']['input'];
@@ -13816,7 +14127,7 @@ export type AppObserveEventsQueryVariables = Exact<{
}>;
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null } }> } } } } };
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null, routeName?: string | null } }> } } } } };
export type AppObserveCustomEventListQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -14089,7 +14400,7 @@ export type AppObserveTimeSeriesFragment = { __typename?: 'AppObserveTimeSeries'
export type AppObserveCustomEventFragment = { __typename?: 'AppObserveCustomEvent', id: string, eventName: string, timestamp: any, sessionId?: string | null, severityNumber?: number | null, severityText?: string | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, properties: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }> };
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null };
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null, routeName?: string | null };
export type AppObserveAppVersionFragment = { __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> };
@@ -5,19 +5,19 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { withErrorHandlingAsync } from '../client';
import { CreatePostHogAccountRequestInput, SetupPostHogProjectInput } from '../generated';
import {
PostHogOrganizationConnectionData,
PostHogOrganizationConnectionFragmentNode,
PostHogProjectData,
PostHogProjectFragmentNode,
StartPostHogConnectionResult,
} from '../types/PostHogConnection';
type CreatePostHogAccountRequestMutation = {
type StartPostHogConnectionMutation = {
posthogOrganizationConnection: {
createPostHogAccountRequest: PostHogOrganizationConnectionData;
startPostHogConnection: StartPostHogConnectionResult;
};
};
type CreatePostHogAccountRequestMutationVariables = {
type StartPostHogConnectionMutationVariables = {
input: CreatePostHogAccountRequestInput;
};
@@ -42,32 +42,36 @@ type DeletePostHogProjectMutationVariables = {
};
export const PostHogMutation = {
async createPostHogAccountRequestAsync(
async startPostHogConnectionAsync(
graphqlClient: ExpoGraphqlClient,
input: CreatePostHogAccountRequestInput
): Promise<PostHogOrganizationConnectionData> {
): Promise<StartPostHogConnectionResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
CreatePostHogAccountRequestMutation,
CreatePostHogAccountRequestMutationVariables
>(
.mutation<StartPostHogConnectionMutation, StartPostHogConnectionMutationVariables>(
gql`
mutation CreatePostHogAccountRequest($input: CreatePostHogAccountRequestInput!) {
mutation StartPostHogConnection($input: CreatePostHogAccountRequestInput!) {
posthogOrganizationConnection {
createPostHogAccountRequest(input: $input) {
id
...PostHogOrganizationConnectionFragment
startPostHogConnection(input: $input) {
__typename
... on PostHogOrganizationConnection {
id
...PostHogOrganizationConnectionFragment
}
... on PostHogPendingConnection {
url
}
}
}
}
${print(PostHogOrganizationConnectionFragmentNode)}
`,
{ input }
{ input },
{ additionalTypenames: ['PostHogOrganizationConnection'] }
)
.toPromise()
);
return data.posthogOrganizationConnection.createPostHogAccountRequest;
return data.posthogOrganizationConnection.startPostHogConnection;
},
async setupPostHogProjectAsync(
@@ -198,6 +198,11 @@ export const AccountQuery = {
value
limit
}
overageMetrics {
id
value
}
totalCost
}
}
}
@@ -39,7 +39,8 @@ type PostHogProjectByAppIdQueryVariables = {
export const PostHogQuery = {
async getPostHogOrganizationConnectionByAccountIdAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string
accountId: string,
{ useCache = true }: { useCache?: boolean } = {}
): Promise<PostHogOrganizationConnectionData | null> {
const data = await withErrorHandlingAsync(
graphqlClient
@@ -62,7 +63,10 @@ export const PostHogQuery = {
${print(PostHogOrganizationConnectionFragmentNode)}
`,
{ accountId },
{ additionalTypenames: ['PostHogOrganizationConnection'] }
{
additionalTypenames: ['PostHogOrganizationConnection'],
requestPolicy: useCache ? 'cache-first' : 'network-only',
}
)
.toPromise()
);
@@ -60,6 +60,7 @@ export const AppObserveEventFragmentNode = gql`
sessionId
easClientId
customParams
routeName
}
`;
@@ -12,6 +12,15 @@ export type PostHogOrganizationConnectionData = Pick<
| 'updatedAt'
>;
// `state` is the website callback's correlator; the CLI only needs `url`, so it omits it.
export type PostHogPendingConnectionData = {
url: string;
};
export type StartPostHogConnectionResult =
| ({ __typename: 'PostHogOrganizationConnection' } & PostHogOrganizationConnectionData)
| ({ __typename: 'PostHogPendingConnection' } & PostHogPendingConnectionData);
export type PostHogProjectData = Pick<
PostHogProject,
| 'id'
@@ -0,0 +1,193 @@
import {
AppObserveCustomEvent,
AppObserveEvent,
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
} from '../../graphql/generated';
import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents';
import { fetchObserveEventsAsync } from '../fetchEvents';
import { fetchObserveSessionEventsAsync } from '../fetchSessions';
jest.mock('../fetchCustomEvents');
jest.mock('../fetchEvents');
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
return {
__typename: 'AppObserveEvent' as const,
id: 'evt-m-1',
metricName: 'expo.app_startup.tti',
metricValue: 0.5,
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: 'update-xyz',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
customParams: null,
...overrides,
} as AppObserveEvent;
}
function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
return {
__typename: 'AppObserveCustomEvent' as const,
id: 'evt-c-1',
eventName: 'login_pressed',
timestamp: '2025-01-15T10:01:00.000Z',
sessionId: 'session-1',
severityNumber: null,
severityText: null,
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
appEasBuildId: null,
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
environment: 'production',
easClientId: 'client-1',
countryCode: 'US',
properties: [],
...overrides,
} as AppObserveCustomEvent;
}
const baseOptions = {
limit: 100,
};
describe('fetchObserveSessionEventsAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
it('forwards sessionId to both event sources and orders metric events oldest-first', async () => {
await fetchObserveSessionEventsAsync({} as any, 'project-1', {
...baseOptions,
sessionId: 'session-1',
});
expect(mockFetchObserveEventsAsync.mock.calls[0][2].sessionId).toBe('session-1');
expect(mockFetchObserveEventsAsync.mock.calls[0][2].orderBy).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
});
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].sessionId).toBe('session-1');
});
it('returns combined entries sorted chronologically, tagged with their source', async () => {
mockFetchObserveEventsAsync.mockResolvedValue({
events: [makeMetricEvent({ timestamp: '2025-01-15T10:05:00.000Z' })],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
makeCustomEvent({ timestamp: '2025-01-15T10:01:00.000Z' }),
makeCustomEvent({
id: 'evt-c-2',
timestamp: '2025-01-15T10:10:00.000Z',
eventName: 'logout',
}),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
...baseOptions,
sessionId: 'session-1',
});
expect(result.entries.map(e => e.timestamp)).toEqual([
'2025-01-15T10:01:00.000Z',
'2025-01-15T10:05:00.000Z',
'2025-01-15T10:10:00.000Z',
]);
expect(result.entries.map(e => e.source)).toEqual(['log', 'metric', 'log']);
});
it('derives session metadata from the entries (first/last timestamps, device, app version)', async () => {
mockFetchObserveEventsAsync.mockResolvedValue({
events: [
makeMetricEvent({
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
deviceModel: 'iPhone 15',
appUpdateId: 'update-xyz',
countryCode: 'US',
}),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
makeCustomEvent({
timestamp: '2025-01-15T10:05:00.000Z',
appUpdateId: 'update-xyz',
}),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
...baseOptions,
sessionId: 'session-1',
});
expect(result.metadata).toEqual({
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: 'update-xyz',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
deviceModel: 'iPhone 15',
countryCode: 'US',
firstSeenAt: '2025-01-15T10:00:00.000Z',
lastSeenAt: '2025-01-15T10:05:00.000Z',
});
});
it('returns null metadata when no entries are found for the session', async () => {
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
...baseOptions,
sessionId: 'unknown',
});
expect(result.metadata).toBeNull();
});
it('reports hasMore* flags from the underlying page info', async () => {
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cm' },
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
...baseOptions,
sessionId: 'session-1',
});
expect(result.hasMoreMetricEvents).toBe(true);
expect(result.hasMoreLogEvents).toBe(false);
});
});
@@ -159,6 +159,7 @@ describe(buildObserveEventsJson, () => {
easClientId: 'client-1',
timestamp: '2025-01-15T10:30:00.000Z',
customParams: null,
routeName: null,
},
],
pageInfo: {
@@ -0,0 +1,242 @@
import { SessionEventEntry, SessionMetadata } from '../fetchSessions';
import { buildObserveSessionEventsJson, buildObserveSessionEventsTable } from '../formatSessions';
function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
return {
source: 'metric',
timestamp: '2025-01-15T10:00:00.000Z',
sessionId: 'session-1',
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
easClientId: 'client-1',
metricName: 'expo.app_startup.tti',
metricValue: 0.8,
customParams: null,
...overrides,
};
}
function makeLogEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
return {
source: 'log',
timestamp: '2025-01-15T10:01:00.000Z',
sessionId: 'session-1',
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
easClientId: 'client-1',
eventName: 'login_pressed',
severityText: null,
severityNumber: null,
properties: [],
environment: 'production',
...overrides,
};
}
describe(buildObserveSessionEventsTable, () => {
it('renders a chronological table with display name for metric rows and event name for log rows', () => {
const entries: SessionEventEntry[] = [
makeMetricEntry(),
makeLogEntry({ timestamp: '2025-01-15T10:02:00.000Z' }),
];
const output = buildObserveSessionEventsTable(entries);
expect(output).not.toContain('Session session-1');
expect(output).not.toContain('for the last');
expect(output).toContain('TTI');
expect(output).toContain('0.80s');
expect(output).toContain('login_pressed');
expect(output).toContain('metric');
expect(output).toContain('log');
});
it('appends the routeName to navigation metric rows', () => {
const entries = [
makeMetricEntry({
metricName: 'expo.navigation.tti',
metricValue: 0.32,
routeName: '/home',
}),
];
const output = buildObserveSessionEventsTable(entries);
expect(output).toContain('Nav TTI · /home');
});
it('shows event times as offsets in seconds from the first event', () => {
const entries: SessionEventEntry[] = [
makeMetricEntry({ timestamp: '2025-01-15T10:00:00.000Z' }),
makeLogEntry({ timestamp: '2025-01-15T10:00:01.234Z' }),
makeLogEntry({ timestamp: '2025-01-15T10:00:12.500Z' }),
];
const output = buildObserveSessionEventsTable(entries);
expect(output).toContain('Offset');
expect(output).toContain('0.00s');
expect(output).toContain('1.23s');
expect(output).toContain('12.50s');
// The absolute-time column header is gone now.
expect(output).not.toContain('Timestamp');
});
it('renders separate Value, Properties, and Severity columns', () => {
const output = buildObserveSessionEventsTable([makeLogEntry()]);
const headerLine = output.split('\n').find(line => line.includes('Offset'));
expect(headerLine).toContain('Value');
expect(headerLine).toContain('Properties');
expect(headerLine).toContain('Severity');
expect(headerLine).not.toContain('Value / Severity');
});
it('shows severity text in the Severity column for log entries', () => {
const entries = [makeLogEntry({ severityText: 'WARN' })];
const output = buildObserveSessionEventsTable(entries);
expect(output).toContain('WARN');
});
it('falls back to severity number in the Severity column when only a number is set', () => {
const entries = [makeLogEntry({ severityNumber: 13 })];
const output = buildObserveSessionEventsTable(entries);
expect(output).toContain('13');
});
it('shows primitive properties in the Properties column, one row per property', () => {
const entries = [
makeLogEntry({
properties: [
{ key: 'user_id', value: 'abc', type: 'STRING' },
{ key: 'is_premium', value: 'true', type: 'BOOLEAN' },
{ key: 'level', value: '7', type: 'NUMBER' },
],
}),
];
const output = buildObserveSessionEventsTable(entries);
expect(output).toContain('user_id=abc');
expect(output).toContain('is_premium=true');
expect(output).toContain('level=7');
});
it('omits non-primitive (JSON) properties from the Properties column', () => {
const entries = [
makeLogEntry({
properties: [
{ key: 'user_id', value: 'abc', type: 'STRING' },
{ key: 'context', value: '{"foo":"bar"}', type: 'JSON' },
],
}),
];
const output = buildObserveSessionEventsTable(entries);
expect(output).toContain('user_id=abc');
expect(output).not.toContain('context=');
});
it('leaves the Value column as "-" for log entries (metric rows keep their value)', () => {
const entries = [
makeMetricEntry({ metricValue: 0.8 }),
makeLogEntry({
properties: [{ key: 'user_id', value: 'abc', type: 'STRING' }],
}),
];
const output = buildObserveSessionEventsTable(entries);
expect(output).toContain('0.80s');
// The log row's Value cell is '-'; the property goes into Properties.
expect(output).toContain('user_id=abc');
});
it('shows "-" in the Properties column when a log entry has no primitive properties', () => {
const entries = [
makeLogEntry({
properties: [{ key: 'context', value: '{}', type: 'JSON' }],
}),
];
const output = buildObserveSessionEventsTable(entries);
const dataLines = output.split('\n').filter(line => line.includes('log'));
expect(dataLines.some(line => line.includes('-'))).toBe(true);
});
it('shows an empty-state message when entries are empty', () => {
const output = buildObserveSessionEventsTable([]);
expect(output).toContain('No events found for this session.');
});
it('warns when more events are available from either source', () => {
const output = buildObserveSessionEventsTable([makeMetricEntry()], {
hasMoreMetricEvents: true,
hasMoreLogEvents: true,
});
expect(output).toContain('More metric events and log events are available');
expect(output).toContain('only the first 100 of each are shown');
});
it('renders metadata (app version, device, first/last seen) in the header when provided', () => {
const metadata: SessionMetadata = {
appVersion: '1.2.0',
appBuildNumber: '99',
appUpdateId: 'update-xyz',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
deviceModel: 'iPhone 15',
countryCode: 'US',
firstSeenAt: '2025-01-15T10:00:00.000Z',
lastSeenAt: '2025-01-15T10:05:00.000Z',
};
const output = buildObserveSessionEventsTable([makeMetricEntry()], { metadata });
expect(output).toContain('App version: 1.2.0 (99)');
expect(output).toContain('Device:');
expect(output).toContain('iPhone 15');
expect(output).toContain('iOS 17.0');
expect(output).toContain('First seen:');
expect(output).toContain('Last seen:');
});
it('omits the metadata block when metadata is null', () => {
const output = buildObserveSessionEventsTable([makeMetricEntry()], {
metadata: null,
});
expect(output).not.toContain('App version:');
expect(output).not.toContain('Device:');
expect(output).not.toContain('First seen:');
});
});
describe(buildObserveSessionEventsJson, () => {
it('returns the entries, sessionId, metadata, and hasMore flags', () => {
const entries = [makeMetricEntry()];
const metadata: SessionMetadata = {
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
deviceOs: 'iOS',
deviceOsVersion: '17.0',
deviceModel: 'iPhone 15',
countryCode: 'US',
firstSeenAt: '2025-01-15T10:00:00.000Z',
lastSeenAt: '2025-01-15T10:05:00.000Z',
};
const result = buildObserveSessionEventsJson(entries, 'session-1', metadata, false, true);
expect(result).toEqual({
sessionId: 'session-1',
metadata,
entries,
hasMoreMetricEvents: false,
hasMoreLogEvents: true,
});
});
it('returns null metadata when there were no entries', () => {
const result = buildObserveSessionEventsJson([], 'session-1', null, false, false);
expect(result.metadata).toBeNull();
});
});
@@ -11,8 +11,8 @@ interface FetchCustomEventsOptions {
eventName?: string;
limit: number;
after?: string;
startTime: string;
endTime: string;
startTime?: string;
endTime?: string;
platform?: AppObservePlatform;
appVersion?: string;
updateId?: string;
@@ -30,8 +30,8 @@ export async function fetchObserveCustomEventsAsync(
options: FetchCustomEventsOptions
): Promise<FetchCustomEventsResult> {
const filter: AppObserveCustomEventListFilter = {
startTime: options.startTime,
endTime: options.endTime,
...(options.startTime && { startTime: options.startTime }),
...(options.endTime && { endTime: options.endTime }),
...(options.eventName && { eventName: options.eventName }),
...(options.platform && { platform: options.platform }),
...(options.appVersion && { appVersion: options.appVersion }),
+8 -6
View File
@@ -45,15 +45,16 @@ export function resolveOrderBy(input: string): AppObserveEventsOrderBy {
}
interface FetchObserveEventsOptions {
metricName: string;
metricName?: string;
orderBy: AppObserveEventsOrderBy;
limit: number;
after?: string;
startTime: string;
endTime: string;
startTime?: string;
endTime?: string;
platform?: AppObservePlatform;
appVersion?: string;
updateId?: string;
sessionId?: string;
}
interface FetchObserveEventsResult {
@@ -67,12 +68,13 @@ export async function fetchObserveEventsAsync(
options: FetchObserveEventsOptions
): Promise<FetchObserveEventsResult> {
const filter: AppObserveEventsFilter = {
metricName: options.metricName,
startTime: options.startTime,
endTime: options.endTime,
...(options.startTime && { startTime: options.startTime }),
...(options.endTime && { endTime: options.endTime }),
...(options.metricName && { metricName: options.metricName }),
...(options.platform && { platform: options.platform }),
...(options.appVersion && { appVersion: options.appVersion }),
...(options.updateId && { appUpdateId: options.updateId }),
...(options.sessionId && { sessionId: options.sessionId }),
};
return await ObserveQuery.eventsAsync(graphqlClient, {
@@ -0,0 +1,148 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveCustomEvent,
AppObserveEvent,
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
} from '../graphql/generated';
import { fetchObserveCustomEventsAsync } from './fetchCustomEvents';
import { fetchObserveEventsAsync } from './fetchEvents';
export interface SessionEventEntry {
source: 'metric' | 'log';
timestamp: string;
sessionId: string;
appVersion: string;
appBuildNumber: string;
appUpdateId: string | null;
deviceModel: string;
deviceOs: string;
deviceOsVersion: string;
countryCode: string | null;
easClientId: string;
// metric-only fields
metricName?: string;
metricValue?: number;
customParams?: { [key: string]: any } | null;
routeName?: string | null;
// log-only fields
eventName?: string;
severityText?: string | null;
severityNumber?: number | null;
properties?: Array<{ key: string; value: string; type: string }>;
environment?: string | null;
}
function metricEventToEntry(event: AppObserveEvent): SessionEventEntry {
return {
source: 'metric',
timestamp: event.timestamp,
sessionId: event.sessionId ?? '',
appVersion: event.appVersion,
appBuildNumber: event.appBuildNumber,
appUpdateId: event.appUpdateId ?? null,
deviceModel: event.deviceModel,
deviceOs: event.deviceOs,
deviceOsVersion: event.deviceOsVersion,
countryCode: event.countryCode ?? null,
easClientId: event.easClientId,
metricName: event.metricName,
metricValue: event.metricValue,
customParams: event.customParams ?? null,
routeName: event.routeName ?? null,
};
}
function customEventToEntry(event: AppObserveCustomEvent): SessionEventEntry {
return {
source: 'log',
timestamp: event.timestamp,
sessionId: event.sessionId ?? '',
appVersion: event.appVersion,
appBuildNumber: event.appBuildNumber,
appUpdateId: event.appUpdateId ?? null,
deviceModel: event.deviceModel,
deviceOs: event.deviceOs,
deviceOsVersion: event.deviceOsVersion,
countryCode: event.countryCode ?? null,
easClientId: event.easClientId,
eventName: event.eventName,
severityText: event.severityText ?? null,
severityNumber: event.severityNumber ?? null,
properties: event.properties.map(p => ({ key: p.key, value: p.value, type: p.type })),
environment: event.environment ?? null,
};
}
export interface SessionMetadata {
appVersion: string;
appBuildNumber: string;
appUpdateId: string | null;
deviceOs: string;
deviceOsVersion: string;
deviceModel: string;
countryCode: string | null;
firstSeenAt: string;
lastSeenAt: string;
}
export interface FetchSessionEventsOptions {
sessionId: string;
limit: number;
}
export interface FetchSessionEventsResult {
entries: SessionEventEntry[];
metadata: SessionMetadata | null;
hasMoreMetricEvents: boolean;
hasMoreLogEvents: boolean;
}
export async function fetchObserveSessionEventsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options: FetchSessionEventsOptions
): Promise<FetchSessionEventsResult> {
const [metricResult, logResult] = await Promise.all([
fetchObserveEventsAsync(graphqlClient, appId, {
orderBy: {
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
},
limit: options.limit,
sessionId: options.sessionId,
}),
fetchObserveCustomEventsAsync(graphqlClient, appId, {
limit: options.limit,
sessionId: options.sessionId,
}),
]);
const entries = [
...metricResult.events.map(metricEventToEntry),
...logResult.events.map(customEventToEntry),
].sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
let metadata: SessionMetadata | null = null;
if (entries.length > 0) {
const newest = entries[entries.length - 1];
metadata = {
appVersion: newest.appVersion,
appBuildNumber: newest.appBuildNumber,
appUpdateId: newest.appUpdateId,
deviceOs: newest.deviceOs,
deviceOsVersion: newest.deviceOsVersion,
deviceModel: newest.deviceModel,
countryCode: newest.countryCode,
firstSeenAt: entries[0].timestamp,
lastSeenAt: newest.timestamp,
};
}
return {
entries,
metadata,
hasMoreMetricEvents: metricResult.pageInfo.hasNextPage,
hasMoreLogEvents: logResult.pageInfo.hasNextPage,
};
}
@@ -20,6 +20,7 @@ export interface ObserveEventJson {
easClientId: string;
timestamp: string;
customParams: { [key: string]: any } | null;
routeName: string | null;
}
function resolveCustomParams(event: AppObserveEvent): { [key: string]: any } | null {
@@ -106,6 +107,7 @@ export function buildObserveEventsJson(
easClientId: event.easClientId,
timestamp: event.timestamp,
customParams: resolveCustomParams(event),
routeName: event.routeName ?? null,
})),
pageInfo: {
hasNextPage: pageInfo.hasNextPage,
@@ -0,0 +1,144 @@
import chalk from 'chalk';
import { SessionEventEntry, SessionMetadata } from './fetchSessions';
import { formatLogTimestamp } from './formatUtils';
import { getMetricDisplayName } from './metricNames';
import renderTextTable from '../utils/renderTextTable';
export interface BuildSessionEventsOptions {
metadata?: SessionMetadata | null;
hasMoreMetricEvents?: boolean;
hasMoreLogEvents?: boolean;
}
function formatEntryName(entry: SessionEventEntry): string {
if (entry.source === 'metric' && entry.metricName) {
const name = getMetricDisplayName(entry.metricName);
return entry.routeName ? `${name} · ${entry.routeName}` : name;
}
return entry.eventName ?? '-';
}
function formatMetricEntryValue(entry: SessionEventEntry): string {
if (typeof entry.metricValue === 'number') {
return `${entry.metricValue.toFixed(2)}s`;
}
return '-';
}
function formatEntrySeverity(entry: SessionEventEntry): string {
if (entry.source !== 'log') {
return '-';
}
if (entry.severityText) {
return entry.severityText;
}
if (entry.severityNumber != null) {
return String(entry.severityNumber);
}
return '-';
}
function primitivePropertyLines(entry: SessionEventEntry): string[] {
if (entry.source !== 'log' || !entry.properties) {
return [];
}
return entry.properties
.filter(p => p.type === 'STRING' || p.type === 'NUMBER' || p.type === 'BOOLEAN')
.map(p => `${p.key}=${p.value}`);
}
function formatOffsetSeconds(startIso: string, currentIso: string): string {
const offsetMs = new Date(currentIso).getTime() - new Date(startIso).getTime();
return `${(offsetMs / 1000).toFixed(2)}s`;
}
export function buildObserveSessionEventsTable(
entries: SessionEventEntry[],
options?: BuildSessionEventsOptions
): string {
const lines: string[] = [];
if (options?.metadata) {
const { metadata } = options;
lines.push(
`App version: ${metadata.appVersion} (${metadata.appBuildNumber})`,
`Device: ${metadata.deviceModel} · ${metadata.deviceOs} ${metadata.deviceOsVersion}`,
`First seen: ${formatLogTimestamp(metadata.firstSeenAt)}`,
`Last seen: ${formatLogTimestamp(metadata.lastSeenAt)}`,
''
);
}
if (entries.length === 0) {
lines.push(chalk.yellow('No events found for this session.'));
return lines.join('\n');
}
const startIso = entries[0].timestamp;
const headers = ['Offset', 'Type', 'Name', 'Value', 'Properties', 'Severity'];
const rows: string[][] = [];
for (const entry of entries) {
const offset = formatOffsetSeconds(startIso, entry.timestamp);
const type = entry.source === 'metric' ? 'metric' : 'log';
const name = formatEntryName(entry);
const severity = formatEntrySeverity(entry);
if (entry.source === 'metric') {
rows.push([offset, type, name, formatMetricEntryValue(entry), '-', severity]);
continue;
}
const propLines = primitivePropertyLines(entry);
if (propLines.length === 0) {
rows.push([offset, type, name, '-', '-', severity]);
continue;
}
rows.push([offset, type, name, '-', propLines[0], severity]);
for (let i = 1; i < propLines.length; i++) {
rows.push(['', '', '', '', propLines[i], '']);
}
}
lines.push(renderTextTable(headers, rows));
if (options?.hasMoreMetricEvents || options?.hasMoreLogEvents) {
const sources: string[] = [];
if (options.hasMoreMetricEvents) {
sources.push('metric events');
}
if (options.hasMoreLogEvents) {
sources.push('log events');
}
lines.push(
'',
chalk.yellow(
`More ${sources.join(' and ')} are available for this session; only the first 100 of each are shown.`
)
);
}
return lines.join('\n');
}
export interface ObserveSessionEventsJson {
sessionId: string;
metadata: SessionMetadata | null;
entries: SessionEventEntry[];
hasMoreMetricEvents: boolean;
hasMoreLogEvents: boolean;
}
export function buildObserveSessionEventsJson(
entries: SessionEventEntry[],
sessionId: string,
metadata: SessionMetadata | null,
hasMoreMetricEvents: boolean,
hasMoreLogEvents: boolean
): ObserveSessionEventsJson {
return {
sessionId,
metadata,
entries,
hasMoreMetricEvents,
hasMoreLogEvents,
};
}
@@ -1,7 +1,12 @@
import { testTarget } from '../../../credentials/__tests__/fixtures-ios';
import { ApplePlatform } from '../../../credentials/ios/appstore/constants';
import { Target } from '../../../credentials/ios/types';
import { getApplePlatformFromDeviceFamily, getApplePlatformFromSdkRoot } from '../target';
import {
getApplePlatformFromDeviceFamily,
getApplePlatformFromSdkRoot,
getApplePlatformFromTarget,
getManagedTvBuildSettings,
} from '../target';
function getApplePlatformWithSdkRoot(sdkRoot: string): ApplePlatform | null {
const target = {
@@ -56,3 +61,64 @@ describe(getApplePlatformFromDeviceFamily, () => {
expect(getApplePlatformWithDeviceFamily('ilovecats')).toBe(null);
});
});
describe(getManagedTvBuildSettings, () => {
const originalExpoTv = process.env.EXPO_TV;
afterEach(() => {
if (originalExpoTv === undefined) {
delete process.env.EXPO_TV;
} else {
process.env.EXPO_TV = originalExpoTv;
}
});
test('returns undefined when EXPO_TV is not set in env or process.env', () => {
delete process.env.EXPO_TV;
expect(getManagedTvBuildSettings(undefined)).toBeUndefined();
expect(getManagedTvBuildSettings({})).toBeUndefined();
expect(getManagedTvBuildSettings({ EXPO_TV: '' })).toBeUndefined();
expect(getManagedTvBuildSettings({ EXPO_TV: '0' })).toBeUndefined();
expect(getManagedTvBuildSettings({ EXPO_TV: 'false' })).toBeUndefined();
expect(getManagedTvBuildSettings({ EXPO_TV: 'no' })).toBeUndefined();
});
test('returns TARGETED_DEVICE_FAMILY=3 when EXPO_TV is truthy in the env parameter (eas.json case)', () => {
delete process.env.EXPO_TV;
for (const value of ['1', 'true', 'yes', 'TRUE', 'Yes']) {
expect(getManagedTvBuildSettings({ EXPO_TV: value })).toEqual({
TARGETED_DEVICE_FAMILY: '3',
});
}
});
test('falls back to process.env when env is missing EXPO_TV (shell case)', () => {
process.env.EXPO_TV = '1';
expect(getManagedTvBuildSettings(undefined)).toEqual({ TARGETED_DEVICE_FAMILY: '3' });
expect(getManagedTvBuildSettings({})).toEqual({ TARGETED_DEVICE_FAMILY: '3' });
});
test('env-parameter EXPO_TV takes precedence over process.env (eas.json overrides shell)', () => {
process.env.EXPO_TV = '0';
expect(getManagedTvBuildSettings({ EXPO_TV: '1' })).toEqual({
TARGETED_DEVICE_FAMILY: '3',
});
process.env.EXPO_TV = '1';
expect(getManagedTvBuildSettings({ EXPO_TV: '0' })).toBeUndefined();
});
test('feeds into getApplePlatformFromTarget so managed tvOS targets resolve to TV_OS', () => {
delete process.env.EXPO_TV;
const target: Target = {
...testTarget,
buildSettings: getManagedTvBuildSettings({ EXPO_TV: '1' }),
};
expect(getApplePlatformFromTarget(target)).toBe(ApplePlatform.TV_OS);
const iosTarget: Target = {
...testTarget,
buildSettings: getManagedTvBuildSettings({}),
};
expect(getApplePlatformFromTarget(iosTarget)).toBe(ApplePlatform.IOS);
});
});
@@ -1,4 +1,5 @@
import { ExportedConfig, IOSConfig, compileModsAsync } from '@expo/config-plugins';
import { Env } from '@expo/eas-build-job';
import { JSONObject } from '@expo/json-file';
import { getPrebuildConfigAsync } from '@expo/prebuild-config';
@@ -17,7 +18,7 @@ interface Target {
export async function getManagedApplicationTargetEntitlementsAsync(
projectDir: string,
env: Record<string, string>,
env: Env,
vcsClient: Client
): Promise<JSONObject> {
let expoConfigError: any;
@@ -95,7 +96,7 @@ export async function getManagedApplicationTargetEntitlementsAsync(
async function resolveManagedApplicationTargetEntitlementsWithBundledConfigAsync(
projectDir: string,
env: Record<string, string>,
env: Env,
vcsClient: Client
): Promise<JSONObject> {
const originalProcessEnv = process.env;
+27 -2
View File
@@ -1,6 +1,6 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig, XcodeProject } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { Env, Platform, Workflow } from '@expo/eas-build-job';
import { JSONObject } from '@expo/json-file';
import Joi from 'joi';
import type { XCBuildConfiguration } from 'xcode';
@@ -26,7 +26,7 @@ interface UserDefinedTarget {
interface ResolveTargetOptions {
projectDir: string;
exp: ExpoConfig;
env?: Record<string, string>;
env?: Env;
xcodeBuildContext: XcodeBuildContext;
vcsClient: Client;
}
@@ -40,6 +40,28 @@ const AppExtensionsConfigSchema = Joi.array().items(
})
);
/**
* For managed projects we don't have an Xcode project to read build settings
* from, but the @react-native-tvos/config-tv plugin signals a tvOS build via
* the EXPO_TV environment variable. That variable can come from the build
* profile's `env` in eas.json (the typical case, which flows in via `env`),
* or from the shell process when the user runs `EXPO_TV=1 eas build ...`
* (which is NOT included in the merged `env` upstream, so we read
* `process.env` directly as a fallback). When set, synthesize
* TARGETED_DEVICE_FAMILY=3 so getApplePlatformFromTarget (and every
* Apple-portal flow downstream) picks TV_OS for these targets instead of
* defaulting to IOS.
*/
export function getManagedTvBuildSettings(
env: Env | undefined
): Target['buildSettings'] | undefined {
const expoTv = (env?.EXPO_TV ?? process.env.EXPO_TV ?? '').toString().toLowerCase();
if (expoTv === '1' || expoTv === 'true' || expoTv === 'yes') {
return { TARGETED_DEVICE_FAMILY: '3' };
}
return undefined;
}
export async function resolveManagedProjectTargetsAsync({
exp,
projectDir,
@@ -76,12 +98,14 @@ export async function resolveManagedProjectTargetsAsync({
);
}
const managedBuildSettings = getManagedTvBuildSettings(env);
const extensionsTargets: Target[] = appExtensions.map(extension => ({
targetName: extension.targetName,
buildConfiguration,
bundleIdentifier: extension.bundleIdentifier,
parentBundleIdentifier: extension.parentBundleIdentifier ?? applicationTargetBundleIdentifier,
entitlements: extension.entitlements ?? {},
...(managedBuildSettings && { buildSettings: managedBuildSettings }),
}));
return [
{
@@ -89,6 +113,7 @@ export async function resolveManagedProjectTargetsAsync({
bundleIdentifier: applicationTargetBundleIdentifier,
buildConfiguration,
entitlements: applicationTargetEntitlements,
...(managedBuildSettings && { buildSettings: managedBuildSettings }),
},
...extensionsTargets,
];
+2 -2
View File
@@ -1,5 +1,5 @@
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { Env, Platform } from '@expo/eas-build-job';
import { SubmitProfile } from '@expo/eas-json';
import { v4 as uuidv4 } from 'uuid';
@@ -47,7 +47,7 @@ export interface SubmitArchiveFlags {
export async function createSubmissionContextAsync<T extends Platform>(params: {
archiveFlags: SubmitArchiveFlags;
credentialsCtx?: CredentialsContext;
env?: Record<string, string>;
env?: Env;
nonInteractive: boolean;
isVerboseFastlaneEnabled: boolean;
groups: string[] | undefined;
@@ -1,5 +1,8 @@
import { instance, mock } from 'ts-mockito';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { ChannelNotFoundError } from '../../channel/errors';
import { createChannelOnAppAsync } from '../../channel/queries';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
App,
@@ -14,6 +17,8 @@ import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../get
jest.mock('../../graphql/queries/ChannelQuery');
jest.mock('../../graphql/queries/BranchQuery');
jest.mock('../../branch/queries');
jest.mock('../../channel/queries');
describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
beforeEach(() => {
@@ -40,7 +45,7 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
expect(result.branchName).toBe('test-branch-name');
});
test('errors when no branch is connected to channel', async () => {
test('creates and links a branch when channel exists but has no branches (e.g. auto-created by a build)', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockImplementationOnce(
async () =>
@@ -48,16 +53,29 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
channelName: 'test-channel-name',
}) as any
);
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branch: { __typename: 'UpdateBranch', id: 'test-branch-id', name: 'test-channel-name' },
createdBranch: true,
});
jest.mocked(createChannelOnAppAsync).mockResolvedValue({
updateChannel: {
createUpdateChannelForApp: {
id: 'test-channel-id',
name: 'test-channel-name',
branchMapping:
'{"data":[{"branchId":"test-branch-id","branchMappingLogic":"true"}],"version":0}',
},
},
});
await expect(
getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
'test-project-id',
'test-channel-name'
)
).rejects.toThrow(
"Channel has no branches associated with it. Run 'eas channel:edit' to map a branch"
const result = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
'test-project-id',
'test-channel-name'
);
expect(result.branchId).toBe('test-branch-id');
expect(result.branchName).toBe('test-channel-name');
});
test('errors when more than one branch is connected to channel', async () => {
@@ -81,15 +99,40 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
);
});
test('rethrows unexpected errors from the channel lookup', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockImplementationOnce(async () => {
throw new Error('Network request failed');
});
await expect(
getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
'test-project-id',
'test-channel-name'
)
).rejects.toThrow('Network request failed');
});
test('creates channel and branch when channel does not exist, and returns branch name', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockImplementationOnce(
async () =>
mockUpdateChannel({
channelName: 'test-channel-name',
branchNames: ['test-branch-name'],
}) as any
);
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockImplementationOnce(async () => {
throw new ChannelNotFoundError('Could not find channel with the name test-channel-name');
});
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branch: { __typename: 'UpdateBranch', id: 'test-branch-id', name: 'test-channel-name' },
createdBranch: true,
});
jest.mocked(createChannelOnAppAsync).mockResolvedValue({
updateChannel: {
createUpdateChannelForApp: {
id: 'test-channel-id',
name: 'test-channel-name',
branchMapping:
'{"data":[{"branchId":"test-branch-id","branchMappingLogic":"true"}],"version":0}',
},
},
});
const result = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
@@ -97,7 +140,8 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
'test-channel-name'
);
expect(result.branchName).toBe('test-branch-name');
expect(result.branchId).toBe('test-branch-id');
expect(result.branchName).toBe('test-channel-name');
});
});
@@ -9,51 +9,47 @@ export async function getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
projectId: string,
channelName: string
): Promise<{ branchName: string; branchId: string }> {
let branchInfo: { branchName: string; branchId: string };
let channel;
try {
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName,
});
if (channel.updateBranches.length === 1) {
const branch = channel.updateBranches[0];
branchInfo = { branchId: branch.id, branchName: branch.name };
} else if (channel.updateBranches.length === 0) {
throw new Error(
"Channel has no branches associated with it. Run 'eas channel:edit' to map a branch"
);
} else {
throw new Error(
`Channel has multiple branches associated with it. Instead, use '--branch' instead of '--channel'`
);
}
} catch (error) {
if (!(error instanceof ChannelNotFoundError)) {
throw error;
}
const { branch } = await ensureBranchExistsAsync(graphqlClient, {
appId: projectId,
branchName: channelName,
});
const {
updateChannel: { createUpdateChannelForApp: newChannel },
} = await createChannelOnAppAsync(graphqlClient, {
appId: projectId,
channelName,
branchId: branch.id,
});
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on project with id ${projectId}`
);
}
branchInfo = { branchId: branch.id, branchName: channelName };
return await createAndLinkBranchToChannelAsync(graphqlClient, projectId, channelName);
}
return branchInfo;
if (channel.updateBranches.length === 1) {
const branch = channel.updateBranches[0];
return { branchId: branch.id, branchName: branch.name };
}
if (channel.updateBranches.length === 0) {
return await createAndLinkBranchToChannelAsync(graphqlClient, projectId, channelName);
}
throw new Error(
`Channel has multiple branches associated with it. Instead, use '--branch' instead of '--channel'`
);
}
async function createAndLinkBranchToChannelAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
channelName: string
): Promise<{ branchName: string; branchId: string }> {
const { branch } = await ensureBranchExistsAsync(graphqlClient, {
appId: projectId,
branchName: channelName,
});
await createChannelOnAppAsync(graphqlClient, {
appId: projectId,
channelName,
branchId: branch.id,
});
return { branchId: branch.id, branchName: channelName };
}
@@ -9,6 +9,7 @@ import {
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
import Log, { link } from '../../../log';
import {
classifyUsageTier,
createProgressBar,
displayOverageWarning,
maybeWarnAboutUsageOveragesAsync,
@@ -44,11 +45,15 @@ function createMockAccountUsage({
name = 'test-account',
subscriptionName = 'Free',
buildPlanMetrics = [],
overageMetrics = [],
totalCost = 0,
}: {
id?: string;
name?: string;
subscriptionName?: string;
buildPlanMetrics?: EstimatedUsage[];
overageMetrics?: { __typename: 'EstimatedOverageAndCost'; id: string; value: number }[];
totalCost?: number;
} = {}): AccountUsageForOverageWarningQuery['account']['byId'] {
return {
__typename: 'Account',
@@ -64,6 +69,8 @@ function createMockAccountUsage({
__typename: 'UsageMetricTotal',
id: 'metric-id',
planMetrics: buildPlanMetrics,
overageMetrics,
totalCost,
},
},
};
@@ -83,7 +90,7 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
mockDebug.mockClear();
});
it('displays a warning for a Free plan with high build usage', async () => {
it('displays an approaching-tier warning for a Free plan at 80%+ usage', async () => {
mockGetUsageForOverageWarningAsync.mockResolvedValue(
createMockAccountUsage({
buildPlanMetrics: [createMockPlanMetric({ value: 85 })],
@@ -102,14 +109,14 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've used 85% of your included build credits for this month.")
expect.stringContaining("You've used 85% of your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You won't be able to start new builds once you reach the limit.")
);
});
it('displays a warning for a Starter plan with high build usage', async () => {
it('displays an approaching-tier warning for a paid plan at 80%+ usage', async () => {
mockGetUsageForOverageWarningAsync.mockResolvedValue(
createMockAccountUsage({
subscriptionName: 'Starter',
@@ -123,7 +130,7 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've used 90% of your included build credits for this month.")
expect.stringContaining("You've used 90% of your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
@@ -132,6 +139,94 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
);
});
it('displays an at-tier warning for a Free plan that has hit the limit', async () => {
mockGetUsageForOverageWarningAsync.mockResolvedValue(
createMockAccountUsage({
buildPlanMetrics: [createMockPlanMetric({ value: 100 })],
})
);
await maybeWarnAboutUsageOveragesAsync({
graphqlClient: mockGraphqlClient,
accountId: 'account-id',
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've reached your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('New builds are blocked until your billing period resets.')
);
});
it('displays an at-tier warning for a paid plan that has hit the limit with no overage', async () => {
mockGetUsageForOverageWarningAsync.mockResolvedValue(
createMockAccountUsage({
subscriptionName: 'Starter',
buildPlanMetrics: [createMockPlanMetric({ value: 100 })],
})
);
await maybeWarnAboutUsageOveragesAsync({
graphqlClient: mockGraphqlClient,
accountId: 'account-id',
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've reached your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('Additional builds will be charged at pay-as-you-go rates.')
);
});
it('displays the at-tier message for a Free plan even if overage is reported (defensive fallback)', async () => {
mockGetUsageForOverageWarningAsync.mockResolvedValue(
createMockAccountUsage({
buildPlanMetrics: [createMockPlanMetric({ value: 105 })],
overageMetrics: [{ __typename: 'EstimatedOverageAndCost', id: 'o1', value: 5 }],
totalCost: 0,
})
);
await maybeWarnAboutUsageOveragesAsync({
graphqlClient: mockGraphqlClient,
accountId: 'account-id',
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've reached your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('New builds are blocked until your billing period resets.')
);
});
it('displays an over-tier warning for a paid plan with overage and cost', async () => {
mockGetUsageForOverageWarningAsync.mockResolvedValue(
createMockAccountUsage({
subscriptionName: 'Starter',
buildPlanMetrics: [createMockPlanMetric({ value: 110 })],
overageMetrics: [{ __typename: 'EstimatedOverageAndCost', id: 'o1', value: 10 }],
totalCost: 1500,
})
);
await maybeWarnAboutUsageOveragesAsync({
graphqlClient: mockGraphqlClient,
accountId: 'account-id',
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
"You've used 10 builds beyond your included credits this billing period ($15.00 in overages so far)."
)
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('Additional builds continue at pay-as-you-go rates.')
);
});
it('does not display a warning when usage is below threshold', async () => {
mockGetUsageForOverageWarningAsync.mockResolvedValue(
createMockAccountUsage({
@@ -171,6 +266,30 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
});
});
describe('classifyUsageTier', () => {
it('returns null when usage is below threshold', () => {
expect(classifyUsageTier({ planValue: 50, limit: 100, overageCount: 0 })).toBeNull();
expect(classifyUsageTier({ planValue: 79, limit: 100, overageCount: 0 })).toBeNull();
});
it('returns "approaching" at >= 80% but below limit', () => {
expect(classifyUsageTier({ planValue: 80, limit: 100, overageCount: 0 })).toBe('approaching');
expect(classifyUsageTier({ planValue: 99, limit: 100, overageCount: 0 })).toBe('approaching');
});
it('returns "at" when planValue >= limit and no overage', () => {
expect(classifyUsageTier({ planValue: 100, limit: 100, overageCount: 0 })).toBe('at');
});
it('returns "over" when any overage has been counted', () => {
expect(classifyUsageTier({ planValue: 105, limit: 100, overageCount: 5 })).toBe('over');
});
it('prefers "over" if overage exists, regardless of planValue', () => {
expect(classifyUsageTier({ planValue: 50, limit: 100, overageCount: 1 })).toBe('over');
});
});
describe('createProgressBar', () => {
it('creates a progress bar with correct fill for a given percentage', () => {
const bar = createProgressBar(85, 20);
@@ -202,16 +321,20 @@ describe('displayOverageWarning', () => {
mockLink.mockClear();
});
it('displays a warning for a Free plan', () => {
it('approaching tier: displays a warning for a Free plan with progress bar', () => {
displayOverageWarning({
percentUsed: 85,
hasFreePlan: true,
tier: 'approaching',
name: 'test-account',
hasFreePlan: true,
planValue: 85,
limit: 100,
overageCount: 0,
overageCostCents: 0,
});
expect(mockWarn).toHaveBeenCalledTimes(2);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've used 85% of your included build credits for this month.")
expect.stringContaining("You've used 85% of your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You won't be able to start new builds once you reach the limit.")
@@ -219,18 +342,23 @@ describe('displayOverageWarning', () => {
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('Upgrade your plan to continue service.')
);
expect(mockWarn).toHaveBeenCalledWith(expect.stringMatching(/█+░+/));
});
it('displays a warning for a paid plan', () => {
it('approaching tier: displays a warning for a paid plan with progress bar', () => {
displayOverageWarning({
percentUsed: 85,
hasFreePlan: false,
tier: 'approaching',
name: 'test-account',
hasFreePlan: false,
planValue: 85,
limit: 100,
overageCount: 0,
overageCostCents: 0,
});
expect(mockWarn).toHaveBeenCalledTimes(2);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've used 85% of your included build credits for this month.")
expect.stringContaining("You've used 85% of your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
@@ -238,16 +366,113 @@ describe('displayOverageWarning', () => {
)
);
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('See usage in billing.'));
});
it('at tier: displays a Free-plan limit-reached message with present-tense block wording', () => {
displayOverageWarning({
tier: 'at',
name: 'test-account',
hasFreePlan: true,
planValue: 100,
limit: 100,
overageCount: 0,
overageCostCents: 0,
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('██████████████████████████░░░░')
expect.stringContaining("You've reached your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('New builds are blocked until your billing period resets.')
);
});
it('at tier: displays a paid-plan limit-reached message with pay-as-you-go warning', () => {
displayOverageWarning({
tier: 'at',
name: 'test-account',
hasFreePlan: false,
planValue: 100,
limit: 100,
overageCount: 0,
overageCostCents: 0,
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've reached your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('Additional builds will be charged at pay-as-you-go rates.')
);
});
it('over tier on Free plan: defensively falls back to the at-tier blocked message', () => {
displayOverageWarning({
tier: 'over',
name: 'test-account',
hasFreePlan: true,
planValue: 105,
limit: 100,
overageCount: 5,
overageCostCents: 0,
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've reached your included build credits this billing period.")
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('New builds are blocked until your billing period resets.')
);
});
it('over tier: displays a paid-plan overage count and accrued cost', () => {
displayOverageWarning({
tier: 'over',
name: 'test-account',
hasFreePlan: false,
planValue: 110,
limit: 100,
overageCount: 10,
overageCostCents: 1500,
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
"You've used 10 builds beyond your included credits this billing period ($15.00 in overages so far)."
)
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('Additional builds continue at pay-as-you-go rates.')
);
});
it('over tier: paid plan with a single overage build uses singular wording', () => {
displayOverageWarning({
tier: 'over',
name: 'test-account',
hasFreePlan: false,
planValue: 101,
limit: 100,
overageCount: 1,
overageCostCents: 150,
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
"You've used 1 build beyond your included credits this billing period ($1.50 in overages so far)."
)
);
});
it('includes correct account name in billing URL', () => {
displayOverageWarning({
percentUsed: 85,
hasFreePlan: true,
tier: 'approaching',
name: 'my-custom-account',
hasFreePlan: true,
planValue: 85,
limit: 100,
overageCount: 0,
overageCostCents: 0,
});
expect(mockLink).toHaveBeenCalledWith(
@@ -255,27 +480,4 @@ describe('displayOverageWarning', () => {
expect.objectContaining({ text: 'Upgrade your plan to continue service.' })
);
});
it('shows progress bar when usage is under 100%', () => {
displayOverageWarning({
percentUsed: 85,
hasFreePlan: true,
name: 'test-account',
});
expect(mockWarn).toHaveBeenCalledWith(expect.stringMatching(/█+░+/));
});
it('does not show progress bar when usage is at 100%', () => {
displayOverageWarning({
percentUsed: 100,
hasFreePlan: true,
name: 'test-account',
});
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("You've used 100% of your included build credits for this month.")
);
expect(mockWarn).not.toHaveBeenCalledWith(expect.stringMatching(/[█░]/));
});
});
@@ -4,7 +4,9 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { AccountQuery } from '../../graphql/queries/AccountQuery';
import Log, { link } from '../../log';
const THRESHOLD_PERCENT = 85;
const APPROACHING_THRESHOLD_PERCENT = 80;
export type UsageTier = 'approaching' | 'at' | 'over';
export async function maybeWarnAboutUsageOveragesAsync({
graphqlClient,
@@ -15,28 +17,71 @@ export async function maybeWarnAboutUsageOveragesAsync({
}): Promise<void> {
try {
const currentDate = new Date();
const {
name,
subscription,
usageMetrics: { EAS_BUILD },
} = await AccountQuery.getUsageForOverageWarningAsync(graphqlClient, accountId, currentDate);
const account = await AccountQuery.getUsageForOverageWarningAsync(
graphqlClient,
accountId,
currentDate
);
if (!account) {
return;
}
const planMetric = EAS_BUILD?.planMetrics?.[0];
const { name, subscription, usageMetrics } = account;
const buildMetrics = usageMetrics.EAS_BUILD;
const planMetric = buildMetrics?.planMetrics?.[0];
if (!planMetric || !subscription) {
return;
}
const percentUsed = calculatePercentUsed(planMetric.value, planMetric.limit);
if (percentUsed >= THRESHOLD_PERCENT) {
const hasFreePlan = subscription.name === 'Free';
displayOverageWarning({ percentUsed, hasFreePlan, name });
const overageCount = buildMetrics.overageMetrics.reduce((sum, o) => sum + o.value, 0);
const overageCostCents = buildMetrics.totalCost;
const tier = classifyUsageTier({
planValue: planMetric.value,
limit: planMetric.limit,
overageCount,
});
if (!tier) {
return;
}
const hasFreePlan = subscription.name === 'Free';
displayOverageWarning({
tier,
name,
hasFreePlan,
planValue: planMetric.value,
limit: planMetric.limit,
overageCount,
overageCostCents,
});
} catch (error) {
// Silently fail if we can't fetch usage data - we don't want to block the user's workflow
Log.debug(`Failed to fetch usage data: ${error}`);
}
}
export function classifyUsageTier({
planValue,
limit,
overageCount,
}: {
planValue: number;
limit: number;
overageCount: number;
}): UsageTier | null {
if (overageCount > 0) {
return 'over';
}
if (limit > 0 && planValue >= limit) {
return 'at';
}
const percentUsed = calculatePercentUsed(planValue, limit);
if (percentUsed >= APPROACHING_THRESHOLD_PERCENT) {
return 'approaching';
}
return null;
}
export function calculatePercentUsed(value: number, limit: number): number {
if (limit === 0) {
return 0;
@@ -52,31 +97,70 @@ export function createProgressBar(percentUsed: number, width: number = 30): stri
return `${filled}${empty}`;
}
function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
export function displayOverageWarning({
percentUsed,
tier,
name,
hasFreePlan,
name,
planValue,
limit,
overageCount,
overageCostCents,
}: {
percentUsed: number;
tier: UsageTier;
name: string;
hasFreePlan: boolean;
name: string;
planValue: number;
limit: number;
overageCount: number;
overageCostCents: number;
}): void {
const message = chalk.bold(
`You've used ${percentUsed}% of your included build credits for this month.`
);
// Don't show progress bar at 100% - it's redundant when the limit is reached
const progressBar = percentUsed < 100 ? ' ' + createProgressBar(percentUsed) : '';
Log.warn(message + progressBar);
const billingUrl = `https://expo.dev/accounts/${name}/settings/billing`;
if (tier === 'approaching') {
const percentUsed = calculatePercentUsed(planValue, limit);
Log.warn(
chalk.bold(
`You've used ${percentUsed}% of your included build credits this billing period.`
) +
' ' +
createProgressBar(percentUsed)
);
Log.warn(
hasFreePlan
? "You won't be able to start new builds once you reach the limit. " +
link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false })
: 'Additional usage beyond your limit will be charged at pay-as-you-go rates. ' +
link(billingUrl, { text: 'See usage in billing.', dim: false })
);
return;
}
const billingUrl = `https://expo.dev/accounts/${name}/settings/billing`;
const warning = hasFreePlan
? "You won't be able to start new builds once you reach the limit. " +
link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false })
: 'Additional usage beyond your limit will be charged at pay-as-you-go rates. ' +
link(billingUrl, {
text: 'See usage in billing.',
dim: false,
});
// Free users are blocked at the limit, so they can't reach an "over" state.
// If we ever see Free + over (data inconsistency, mid-flight plan change), treat it as "at".
if (tier === 'at' || (tier === 'over' && hasFreePlan)) {
Log.warn(chalk.bold("You've reached your included build credits this billing period."));
Log.warn(
hasFreePlan
? 'New builds are blocked until your billing period resets. ' +
link(billingUrl, { text: 'Upgrade your plan to continue building.', dim: false })
: 'Additional builds will be charged at pay-as-you-go rates. ' +
link(billingUrl, { text: 'See usage in billing.', dim: false })
);
return;
}
Log.warn(warning);
// tier === 'over' && paid plan
Log.warn(
chalk.bold(
`You've used ${overageCount} build${overageCount === 1 ? '' : 's'} beyond your included credits this billing period (${formatCents(overageCostCents)} in overages so far).`
)
);
Log.warn(
'Additional builds continue at pay-as-you-go rates. ' +
link(billingUrl, { text: 'See usage in billing.', dim: false })
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "20.3.0",
"version": "20.4.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,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "20.3.0",
"@expo/build-tools": "20.4.0",
"@expo/eas-build-job": "20.1.0",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "20.3.0",
"version": "20.5.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "20.3.0",
"@expo/build-tools": "20.4.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "20.0.0",
+2 -2
View File
@@ -268,8 +268,8 @@ describe(getBuildEnv.name, () => {
buildId: 'build-id',
});
expect(env.NPM_CACHE_URL).toBe('https://npm.example');
expect(env.NPM_CONFIG_REGISTRY).toBe('https://npm.example');
expect(env.NPM_CACHE_URL).toBeUndefined();
expect(env.NPM_CONFIG_REGISTRY).toBeUndefined();
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');
+1
View File
@@ -74,6 +74,7 @@ export async function createBuildContext<TJob extends Job>({
artifactPaths: paths,
logger,
name: artifact.name,
metadata: artifact.metadata,
}),
analytics
);
+11 -5
View File
@@ -70,24 +70,30 @@ function printImageDescription(ctx: BuildContext<Job>): void {
function printEnvs(ctx: BuildContext<Job>): void {
const { logger, job } = ctx;
const publicEnv: Record<string, string> = {};
const secretEnv: Record<string, string> = {};
// We don't expect environment secrets to be missing from ctx.env,
// but if they do we want to see it in logs as "=undefined".
const secretEnv: Record<string, string | undefined> = {};
const instanceEnv: Record<string, string> = {};
// skip development and testing to avoid leaking local credentials from envs to bucket
if (config.env !== Environment.DEVELOPMENT && config.env !== Environment.TEST) {
Object.entries(ctx.env).forEach(([key, value]) => {
instanceEnv[key] = value;
if (value !== undefined) {
instanceEnv[key] = value;
}
});
}
Object.entries(job.builderEnvironment?.env ?? ({} as Record<string, string>)).forEach(
([key, value]) => {
Object.entries(job.builderEnvironment?.env ?? {}).forEach(([key, value]) => {
if (value !== undefined) {
publicEnv[key] = value;
delete instanceEnv[key];
}
);
});
job.secrets?.environmentSecrets?.forEach(({ name, type }) => {
if (type === EnvironmentSecretType.FILE) {
// ctx.env[name] for a FILE-typed environment secret
// should be a path to the file.
secretEnv[name] = ctx.env[name];
} else {
secretEnv[name] = '********';
+1 -3
View File
@@ -31,18 +31,16 @@ export function getBuildEnv({
// https://github.com/mobile-dev-inc/maestro/issues/1257#issuecomment-1654803779
setEnv(env, 'MAESTRO_DRIVER_STARTUP_TIMEOUT', '120000');
setEnv(env, 'MAESTRO_CLI_NO_ANALYTICS', '1');
setEnv(env, 'HOMEBREW_NO_AUTO_UPDATE', '1');
setEnv(env, 'EAS_BUILD', 'true');
setEnv(env, 'EAS_BUILD_RUNNER', 'eas-build');
setEnv(env, 'EAS_BUILD_PLATFORM', job.platform);
setEnv(env, 'EAS_CLI_SENTRY_DSN', config.sentry.dsn);
// NPM_CACHE_URL is deprecated
const npmCacheUrl = RuntimeSettings.getNpmCacheUrl();
const nodeJsCacheUrl = RuntimeSettings.getNodeJsCacheUrl();
const mavenCacheUrl = RuntimeSettings.getMavenCacheUrl();
const cocoapodsCacheUrl = RuntimeSettings.getCocoapodsCacheUrl();
setEnv(env, 'NPM_CACHE_URL', npmCacheUrl);
setEnv(env, 'NPM_CONFIG_REGISTRY', npmCacheUrl);
setEnv(env, 'NVM_NODEJS_ORG_MIRROR', nodeJsCacheUrl);
setEnv(env, 'EAS_BUILD_NPM_CACHE_URL', npmCacheUrl);
setEnv(env, 'EAS_BUILD_PROFILE', metadata.buildProfile);
+11 -3
View File
@@ -143,10 +143,12 @@ export async function uploadWorkflowArtifactAsync(
name: _name,
logger,
artifactPaths,
metadata,
}: {
name: string;
logger: bunyan;
artifactPaths: string[];
metadata?: Record<string, unknown>;
}
): Promise<{ artifactId: string | null }> {
const { localPath, filename, size } = await prepareArtifactsForUploadAsync(logger, artifactPaths);
@@ -157,6 +159,7 @@ export async function uploadWorkflowArtifactAsync(
filename,
name,
size,
metadata,
});
await GCS.uploadWithSignedUrl({
@@ -238,7 +241,12 @@ function getCommonParentDir(path1: string, path2: string): string {
async function createUploadSessionAsync(
ctx: BuildContext,
{ filename, name, size }: { filename: string; name: string; size: number }
{
filename,
name,
size,
metadata,
}: { filename: string; name: string; size: number; metadata?: Record<string, unknown> }
): Promise<{
bucketKey: string;
signedUrl: GCS.SignedUrl;
@@ -265,7 +273,7 @@ async function createUploadSessionAsync(
'POST',
// 'name' is ignored by Turtle Build router, but provide it for potential use for telemetry, etc.
{
json: { filename, name, size },
json: { filename, name, size, metadata },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
@@ -282,7 +290,7 @@ async function createUploadSessionAsync(
new URL(`workflows/${workflowJobId}/upload-sessions/`, config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: { filename, name, size },
json: { filename, name, size, metadata },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
Generated
+8 -8
View File
@@ -2847,16 +2847,16 @@ __metadata:
languageName: node
linkType: hard
"@expo/apple-utils@npm:2.1.19":
version: 2.1.19
resolution: "@expo/apple-utils@npm:2.1.19"
"@expo/apple-utils@npm:2.1.22":
version: 2.1.22
resolution: "@expo/apple-utils@npm:2.1.22"
bin:
apple-utils: bin.js
checksum: 10c0/42eb1038bd0906d53b37d830499c7a8ee7ec9f9b9ef4078ff898ca84cb5af1e6ac39d3a392818825151aa2ea237a44698f539b973936733350f314efff020858
checksum: 10c0/19a5d21f80cde11e7502d7380b17bb4634568dcd243eb72ce741277dd71aab2b20168e44060143b10bfa01f0b03a6b8a2628b3cf3e5d697b12cf7f78a16d469a
languageName: node
linkType: hard
"@expo/build-tools@npm:20.3.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:20.4.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -3558,7 +3558,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:20.3.0"
"@expo/build-tools": "npm:20.4.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:20.0.0"
@@ -11100,7 +11100,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:20.3.0"
"@expo/build-tools": "npm:20.4.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/logger": "npm:20.0.0"
"@expo/spawn-async": "npm:^1.7.2"
@@ -11154,7 +11154,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli@workspace:packages/eas-cli"
dependencies:
"@expo/apple-utils": "npm:2.1.19"
"@expo/apple-utils": "npm:2.1.22"
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"