Compare commits

..
Author SHA1 Message Date
Expo CI 4b3dcda99d v18.0.5 2026-02-25 00:40:34 +00:00
20 changed files with 95 additions and 2549 deletions

No files matched your search

-2
View File
@@ -10,8 +10,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Add `eas observe:metrics` and `eas observe:events` commands for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax))
### 🐛 Bug fixes
### 🧹 Chores
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.0.4",
"version": "18.0.5",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+92 -90
View File
@@ -178,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -195,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -217,7 +217,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -234,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -248,7 +248,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -300,7 +300,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -321,7 +321,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -341,7 +341,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -361,7 +361,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -384,7 +384,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -425,7 +425,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -444,7 +444,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -461,7 +461,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -480,7 +480,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -500,7 +500,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/v18.0.4/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -521,7 +521,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -556,7 +556,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -608,7 +608,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -637,7 +637,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -663,7 +663,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -717,7 +717,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -736,7 +736,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -755,7 +755,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -772,7 +772,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -793,7 +793,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -814,7 +814,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -836,7 +836,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -856,7 +856,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -878,7 +878,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -900,7 +900,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -933,7 +933,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -956,7 +956,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -977,7 +977,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -994,7 +994,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1012,7 +1012,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1040,7 +1040,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1065,7 +1065,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1086,7 +1086,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1107,7 +1107,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1144,7 +1144,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1164,7 +1164,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1185,7 +1185,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1206,7 +1206,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1220,7 +1220,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1234,7 +1234,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1264,7 +1264,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1289,7 +1289,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1311,7 +1311,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1336,7 +1336,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1362,7 +1362,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1384,7 +1384,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1406,7 +1406,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1438,7 +1438,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1482,7 +1482,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1512,7 +1512,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1621,7 +1621,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1638,7 +1638,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/v18.0.4/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1655,7 +1655,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/v18.0.4/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1706,7 +1706,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1720,7 +1720,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1743,7 +1743,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1766,7 +1766,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1784,7 +1784,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1819,7 +1819,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1841,7 +1841,8 @@ FLAGS
--emit-metadata Emit "eas-update-metadata.json" in the bundle folder with detailed information about
the generated updates
--environment=<value> Environment to use for the server-side defined EAS environment variables during
command execution, e.g. "production", "preview", "development"
command execution, e.g. "production", "preview", "development". Required for
projects using Expo SDK 55 or greater.
--input-dir=<value> [default: dist] Location of the bundle
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
@@ -1859,7 +1860,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1872,14 +1873,15 @@ USAGE
FLAGS
-p, --platform=(android|ios|all) [default: all] Platform to configure
--environment=<value> Environment to use for the server-side defined EAS environment variables during
command execution, e.g. "production", "preview", "development"
command execution, e.g. "production", "preview", "development". Required for
projects using Expo SDK 55 or greater.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1900,7 +1902,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1924,7 +1926,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1949,7 +1951,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1985,7 +1987,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2012,7 +2014,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2040,7 +2042,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2061,7 +2063,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2081,7 +2083,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2102,7 +2104,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2123,7 +2125,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2143,7 +2145,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2161,7 +2163,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2183,7 +2185,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2200,7 +2202,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2320,7 +2322,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2340,7 +2342,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2364,7 +2366,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2404,7 +2406,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2429,7 +2431,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2457,7 +2459,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2477,7 +2479,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2499,5 +2501,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.0.5/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.0.4",
"version": "18.0.5",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -203,9 +203,6 @@
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
@@ -35,16 +35,12 @@ const parseFlagInputStringAsInteger = (
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
description,
}: {
defaultTo: number;
limit: number;
description?: string;
}): OptionFlag<number | undefined> =>
Flags.integer({
description:
description ??
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
@@ -1,237 +0,0 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveEventsAsync } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvents from '../events';
jest.mock('../../../observe/fetchEvents');
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
buildObserveEventsJson: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveEvents, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
function createCommand(argv: string[]): ObserveEvents {
const command = new ObserveEvents(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('uses --days-from-now to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti', '--days-from-now', '7']);
await command.runAsync();
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days-from-now nor --start/--end are provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--metric',
'tti',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti', '--start', '2025-01-01T00:00:00.000Z']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days-from-now combined with --start', async () => {
const command = createCommand([
'--metric',
'tti',
'--days-from-now',
'7',
'--start',
'2025-01-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --limit to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.after).toBe('cursor-xyz');
});
it('does not pass after when --after flag is not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options).not.toHaveProperty('after');
});
it('rejects --days-from-now combined with --end', async () => {
const command = createCommand([
'--metric',
'tti',
'--days-from-now',
'7',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
const command = createCommand(['--metric', 'tti', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
const command = createCommand(['--metric', 'tti', '--platform', 'android']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Android);
});
it('passes --app-version to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--app-version', '2.1.0']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.appVersion).toBe('2.1.0');
});
it('passes --update-id to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--update-id', 'update-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.updateId).toBe('update-xyz');
});
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
const mockEvents = [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
},
];
mockFetchObserveEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['--metric', 'tti', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('does not call enableJsonOutput when --json is not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
});
@@ -1,214 +0,0 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetrics from '../metrics';
jest.mock('../../../observe/fetchMetrics', () => {
const actual = jest.requireActual('../../../observe/fetchMetrics');
return {
...actual,
fetchObserveMetricsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatMetrics', () => ({
...jest.requireActual('../../../observe/formatMetrics'),
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveMetrics, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveMetricsAsync.mockResolvedValue(new Map());
});
function createCommand(argv: string[]): ObserveMetrics {
const command = new ObserveMetrics(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches metrics with default parameters (both platforms)', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
jest.useRealTimers();
});
it('queries only Android when --platform android is passed', async () => {
const command = createCommand(['--platform', 'android']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
});
it('queries only iOS when --platform ios is passed', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
await command.runAsync();
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
const command = createCommand(['--stat', 'p90', '--stat', 'count']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['p90', 'eventCount']
);
});
it('deduplicates --stat flags that resolve to the same key', async () => {
const command = createCommand(['--stat', 'med', '--stat', 'median']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median']
);
});
it('uses --days-from-now to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days-from-now', '7']);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days-from-now combined with --start', async () => {
const command = createCommand(['--days-from-now', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('rejects --days-from-now combined with --end', async () => {
const command = createCommand(['--days-from-now', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('uses default stats when --stat is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median', 'eventCount']
);
});
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
const command = createCommand([
'--json',
'--non-interactive',
'--stat',
'min',
'--stat',
'avg',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['min', 'average']
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
});
describe(validateDateFlag, () => {
it('throws on invalid --start date', () => {
expect(() => validateDateFlag('not-a-date', '--start')).toThrow(
'Invalid --start date: "not-a-date"'
);
});
it('throws on invalid --end date', () => {
expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"');
});
it('accepts valid ISO date in --start', () => {
expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow();
});
});
@@ -1,133 +0,0 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import { AppObservePlatform } from '../../graphql/generated';
import Log from '../../log';
import {
type EventsOrderPreset,
fetchObserveEventsAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import { resolveMetricName } from '../../observe/metricNames';
import { validateDateFlag } from '../../observe/fetchMetrics';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
const DEFAULT_DAYS_BACK = 60;
export default class ObserveEvents extends EasCommand {
static override description = 'display individual app performance events ordered by metric value';
static override flags = {
metric: Flags.string({
description:
'Metric to query (full name or alias: tti, ttr, cold_launch, warm_launch, bundle_load)',
required: true,
}),
sort: Flags.enum<EventsOrderPreset>({
description: 'Sort order for events',
options: ['slowest', 'fastest', 'newest', 'oldest'],
default: 'slowest',
}),
platform: Flags.enum<'android' | 'ios'>({
description: 'Filter by platform',
options: ['android', 'ios'],
}),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_EVENTS_LIMIT, limit: 100 }),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days-from-now'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days-from-now'],
}),
'days-from-now': Flags.integer({
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'app-version': Flags.string({
description: 'Filter by app version',
}),
'update-id': Flags.string({
description: 'Filter by EAS update ID',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveEvents);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
if (flags.start) {
validateDateFlag(flags.start, '--start');
}
if (flags.end) {
validateDateFlag(flags.end, '--end');
}
const metricName = resolveMetricName(flags.metric);
const orderBy = resolveOrderBy(flags.sort);
let startTime: string;
let endTime: string;
if (flags['days-from-now']) {
endTime = new Date().toISOString();
startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString();
} else {
endTime = flags.end ?? new Date().toISOString();
startTime =
flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
}
const platform = flags.platform
? flags.platform === 'android'
? AppObservePlatform.Android
: AppObservePlatform.Ios
: undefined;
const { events, pageInfo } = await fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
});
if (flags.json) {
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(buildObserveEventsTable(events, pageInfo));
}
}
}
@@ -1,142 +0,0 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../observe/fetchMetrics';
import {
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
resolveStatKey,
} from '../../observe/formatMetrics';
import { resolveMetricName } from '../../observe/metricNames';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_METRICS = [
'expo.app_startup.cold_launch_time',
'expo.app_startup.warm_launch_time',
'expo.app_startup.tti',
'expo.app_startup.ttr',
'expo.app_startup.bundle_load_time',
];
const DEFAULT_DAYS_BACK = 60;
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
const DEFAULT_STATS_JSON: StatisticKey[] = [
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
];
export default class ObserveMetrics extends EasCommand {
static override description = 'display app performance metrics grouped by app version';
static override flags = {
platform: Flags.enum<'android' | 'ios'>({
description: 'Filter by platform',
options: ['android', 'ios'],
}),
metric: Flags.string({
description:
'Metric name to display (can be specified multiple times). Supports aliases: tti, ttr, cold_launch, warm_launch, bundle_load',
multiple: true,
}),
stat: Flags.string({
description:
'Statistic to display per metric (can be specified multiple times). Options: min, max, med, avg, p80, p90, p99, count',
multiple: true,
}),
start: Flags.string({
description: 'Start of time range for metrics data (ISO date).',
exclusive: ['days-from-now'],
}),
end: Flags.string({
description: 'End of time range for metrics data (ISO date).',
exclusive: ['days-from-now'],
}),
'days-from-now': Flags.integer({
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetrics);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
if (flags.start) {
validateDateFlag(flags.start, '--start');
}
if (flags.end) {
validateDateFlag(flags.end, '--end');
}
const metricNames = flags.metric?.length
? flags.metric.map(resolveMetricName)
: DEFAULT_METRICS;
let startTime: string;
let endTime: string;
if (flags['days-from-now']) {
endTime = new Date().toISOString();
startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString();
} else {
endTime = flags.end ?? new Date().toISOString();
startTime =
flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
}
const platforms: AppPlatform[] = flags.platform
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
: [AppPlatform.Android, AppPlatform.Ios];
const metricsMap = await fetchObserveMetricsAsync(
graphqlClient,
projectId,
metricNames,
platforms,
startTime,
endTime
);
const argumentsStat = flags.stat?.length
? Array.from(new Set(flags.stat.map(resolveStatKey)))
: undefined;
if (flags.json) {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(buildObserveMetricsJson(metricsMap, metricNames, stats));
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
Log.addNewLineIfNone();
Log.log(buildObserveMetricsTable(metricsMap, metricNames, stats));
}
}
}
@@ -1,175 +0,0 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObservePlatform,
AppObserveTimeSeriesInput,
AppObserveVersionMarker,
PageInfo,
} from '../generated';
type AppObserveTimeSeriesQuery = {
app: {
byId: {
id: string;
observe: {
timeSeries: {
versionMarkers: AppObserveVersionMarker[];
};
};
};
};
};
type AppObserveTimeSeriesQueryVariables = {
appId: string;
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
};
type AppObserveEventsQuery = {
app: {
byId: {
id: string;
observe: {
events: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveEvent;
}>;
};
};
};
};
};
type AppObserveEventsQueryVariables = {
appId: string;
filter?: AppObserveEventsFilter;
first?: number;
after?: string;
orderBy?: AppObserveEventsOrderBy;
};
export const ObserveQuery = {
async timeSeriesVersionMarkersAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
metricName,
platform,
startTime,
endTime,
}: {
appId: string;
metricName: string;
platform: AppObservePlatform;
startTime: string;
endTime: string;
}
): Promise<AppObserveVersionMarker[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
gql`
query AppObserveTimeSeries($appId: String!, $input: AppObserveTimeSeriesInput!) {
app {
byId(appId: $appId) {
id
observe {
timeSeries(input: $input) {
versionMarkers {
appVersion
eventCount
firstSeenAt
statistics {
min
max
median
average
p80
p90
p99
}
}
}
}
}
}
}
`,
{
appId,
input: { metricName, platform, startTime, endTime },
}
)
.toPromise()
);
return data.app.byId.observe.timeSeries.versionMarkers;
},
async eventsAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveEventsQueryVariables
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
gql`
query AppObserveEvents(
$appId: String!
$filter: AppObserveEventsFilter
$first: Int
$after: String
$orderBy: AppObserveEventsOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
events(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
metricName
metricValue
timestamp
appVersion
appBuildNumber
deviceModel
deviceOs
deviceOsVersion
countryCode
sessionId
easClientId
}
}
}
}
}
}
}
`,
variables
)
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.events;
return {
events: edges.map(edge => edge.node),
pageInfo,
};
},
};
@@ -1,272 +0,0 @@
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
} from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents';
jest.mock('../../graphql/queries/ObserveQuery');
describe(resolveOrderBy, () => {
it('maps "slowest" to METRIC_VALUE DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('maps "fastest" to METRIC_VALUE ASC', () => {
expect(resolveOrderBy('fastest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
});
});
it('maps "newest" to TIMESTAMP DESC', () => {
expect(resolveOrderBy('newest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('maps "oldest" to TIMESTAMP ASC', () => {
expect(resolveOrderBy('oldest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
});
});
});
describe(fetchObserveEventsAsync, () => {
const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync);
const mockGraphqlClient = {} as any;
beforeEach(() => {
mockEventsAsync.mockClear();
});
it('calls ObserveQuery.eventsAsync with assembled filter', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
expect(mockEventsAsync).toHaveBeenCalledTimes(1);
expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
appId: 'app-123',
filter: {
metricName: 'expo.app_startup.tti',
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
},
first: 10,
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
});
});
it('includes platform in filter when provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 5,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platform: AppObservePlatform.Ios,
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({
filter: expect.objectContaining({
platform: AppObservePlatform.Ios,
}),
})
);
});
it('includes appVersion in filter when provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
appVersion: '1.2.0',
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({
filter: expect.objectContaining({
appVersion: '1.2.0',
}),
})
);
});
it('includes appUpdateId in filter when updateId is provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
updateId: 'update-abc-123',
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({
filter: expect.objectContaining({
appUpdateId: 'update-abc-123',
}),
})
);
});
it('omits platform, appVersion, and appUpdateId from filter when not provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
const calledFilter = mockEventsAsync.mock.calls[0][1].filter;
expect(calledFilter).not.toHaveProperty('platform');
expect(calledFilter).not.toHaveProperty('appVersion');
expect(calledFilter).not.toHaveProperty('appUpdateId');
});
it('returns events and pageInfo from the query result', async () => {
const mockEvents = [
{
__typename: 'AppObserveEvent' as const,
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
},
];
mockEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cursor-1' },
});
const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
expect(result.events).toHaveLength(1);
expect(result.events[0].metricValue).toBe(1.23);
expect(result.pageInfo.hasNextPage).toBe(true);
});
it('passes after cursor to ObserveQuery.eventsAsync', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
after: 'cursor-abc',
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({ first: 10, after: 'cursor-abc' })
);
});
it('omits after when not provided', async () => {
mockEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
});
const calledVars = mockEventsAsync.mock.calls[0][1];
expect(calledVars).not.toHaveProperty('after');
});
});
@@ -1,222 +0,0 @@
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { makeMetricsKey } from '../formatMetrics';
import { fetchObserveMetricsAsync } from '../fetchMetrics';
jest.mock('../../graphql/queries/ObserveQuery');
describe('fetchObserveMetricsAsync', () => {
const mockTimeSeriesMarkers = jest.mocked(ObserveQuery.timeSeriesVersionMarkersAsync);
const mockGraphqlClient = {} as any;
beforeEach(() => {
mockTimeSeriesMarkers.mockClear();
});
it('fans out queries for each metric+platform combo and assembles metricsMap', async () => {
mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName, platform }) => {
if (metricName === 'expo.app_startup.tti' && platform === AppObservePlatform.Ios) {
return [
{
__typename: 'AppObserveVersionMarker' as const,
appVersion: '1.0.0',
eventCount: 100,
firstSeenAt: '2025-01-01T00:00:00.000Z',
statistics: {
__typename: 'AppObserveVersionMarkerStatistics' as const,
min: 0.01,
max: 0.5,
median: 0.1,
average: 0.15,
p80: 0.3,
p90: 0.4,
p99: 0.48,
},
},
];
}
if (
metricName === 'expo.app_startup.cold_launch_time' &&
platform === AppObservePlatform.Ios
) {
return [
{
__typename: 'AppObserveVersionMarker' as const,
appVersion: '1.0.0',
eventCount: 80,
firstSeenAt: '2025-01-01T00:00:00.000Z',
statistics: {
__typename: 'AppObserveVersionMarkerStatistics' as const,
min: 0.05,
max: 1.2,
median: 0.3,
average: 0.4,
p80: 0.8,
p90: 1.0,
p99: 1.15,
},
},
];
}
return [];
});
const metricsMap = await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
[AppPlatform.Ios],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
// Should have called the query twice (2 metrics x 1 platform)
expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2);
// Verify metricsMap was assembled correctly
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
expect(metricsMap.has(key)).toBe(true);
const metricsForVersion = metricsMap.get(key)!;
expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({
min: 0.01,
max: 0.5,
median: 0.1,
average: 0.15,
p80: 0.3,
p90: 0.4,
p99: 0.48,
eventCount: 100,
});
expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({
min: 0.05,
max: 1.2,
median: 0.3,
average: 0.4,
p80: 0.8,
p90: 1.0,
p99: 1.15,
eventCount: 80,
});
});
it('fans out across multiple platforms', async () => {
mockTimeSeriesMarkers.mockResolvedValue([]);
await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Ios, AppPlatform.Android],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
// 1 metric x 2 platforms = 2 calls
expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2);
const platforms = mockTimeSeriesMarkers.mock.calls.map(call => call[1].platform);
expect(platforms).toContain(AppObservePlatform.Ios);
expect(platforms).toContain(AppObservePlatform.Android);
});
it('handles partial failures gracefully — successful queries still populate metricsMap', async () => {
mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName }) => {
if (metricName === 'bad.metric') {
throw new Error('Unknown metric');
}
return [
{
__typename: 'AppObserveVersionMarker' as const,
appVersion: '2.0.0',
eventCount: 50,
firstSeenAt: '2025-01-01T00:00:00.000Z',
statistics: {
__typename: 'AppObserveVersionMarkerStatistics' as const,
min: 0.1,
max: 0.9,
median: 0.5,
average: 0.5,
p80: 0.7,
p90: 0.8,
p99: 0.85,
},
},
];
});
const metricsMap = await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti', 'bad.metric'],
[AppPlatform.Android],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
// Should not throw; the good metric should still be in the map
const key = makeMetricsKey('2.0.0', AppPlatform.Android);
expect(metricsMap.has(key)).toBe(true);
expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({
min: 0.1,
max: 0.9,
median: 0.5,
average: 0.5,
p80: 0.7,
p90: 0.8,
p99: 0.85,
eventCount: 50,
});
// The bad metric should not be present
expect(metricsMap.get(key)!.has('bad.metric')).toBe(false);
});
it('returns empty map when all queries fail', async () => {
mockTimeSeriesMarkers.mockRejectedValue(new Error('Network error'));
const metricsMap = await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Ios],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
expect(metricsMap.size).toBe(0);
});
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
mockTimeSeriesMarkers.mockResolvedValue([
{
__typename: 'AppObserveVersionMarker' as const,
appVersion: '3.0.0',
eventCount: 10,
firstSeenAt: '2025-01-01T00:00:00.000Z',
statistics: {
__typename: 'AppObserveVersionMarkerStatistics' as const,
min: 0.1,
max: 0.2,
median: 0.15,
average: 0.15,
p80: 0.18,
p90: 0.19,
p99: 0.2,
},
},
]);
const metricsMap = await fetchObserveMetricsAsync(
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Android],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
// The key should use AppPlatform (ANDROID), not AppObservePlatform
expect(metricsMap.has('3.0.0:ANDROID')).toBe(true);
expect(metricsMap.has('3.0.0:Android' as any)).toBe(false);
});
});
@@ -1,189 +0,0 @@
import { AppObserveEvent, PageInfo } from '../../graphql/generated';
import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents';
function createMockEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
return {
__typename: 'AppObserveEvent',
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
appIdentifier: 'com.example.app',
appName: 'ExampleApp',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
eventBatchId: 'batch-1',
tags: {},
...overrides,
};
}
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
const withNextPage: PageInfo = {
hasNextPage: true,
hasPreviousPage: false,
endCursor: 'cursor-abc',
};
describe(buildObserveEventsTable, () => {
it('formats events into aligned columns', () => {
const events = [
createMockEvent({
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
appVersion: '1.2.0',
appBuildNumber: '42',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
deviceModel: 'iPhone 15',
countryCode: 'US',
timestamp: '2025-01-15T10:30:00.000Z',
}),
createMockEvent({
id: 'evt-2',
metricName: 'expo.app_startup.tti',
metricValue: 0.85,
appVersion: '1.1.0',
appBuildNumber: '38',
deviceOs: 'Android',
deviceOsVersion: '14',
deviceModel: 'Pixel 8',
countryCode: 'PL',
timestamp: '2025-01-14T08:15:00.000Z',
}),
];
const output = buildObserveEventsTable(events, noNextPage);
// Escape codes are included, because the header is bolded.
expect(output).toMatchInlineSnapshot(`
"Metric Value App Version Platform Device Country Timestamp 
------ ----- ----------- ---------- --------- ------- ----------------------
TTI 1.23s 1.2.0 (42) iOS 17.0 iPhone 15 US Jan 15, 2025, 10:30 AM
TTI 0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 AM"
`);
});
it('returns yellow warning for empty array', () => {
const output = buildObserveEventsTable([], noNextPage);
expect(output).toContain('No events found.');
});
it('uses short names for known metrics', () => {
const events = [
createMockEvent({ metricName: 'expo.app_startup.cold_launch_time' }),
createMockEvent({
id: 'evt-2',
metricName: 'expo.app_startup.warm_launch_time',
}),
createMockEvent({ id: 'evt-3', metricName: 'expo.app_startup.ttr' }),
createMockEvent({
id: 'evt-4',
metricName: 'expo.app_startup.bundle_load_time',
}),
];
const output = buildObserveEventsTable(events, noNextPage);
expect(output).toContain('Cold Launch');
expect(output).toContain('Warm Launch');
expect(output).toContain('TTR');
expect(output).toContain('Bundle Load');
});
it('shows - for null countryCode', () => {
const events = [createMockEvent({ countryCode: null })];
const output = buildObserveEventsTable(events, noNextPage);
// The country column should contain a dash
const lines = output.split('\n');
const dataLine = lines[2]; // header, separator, first data row
expect(dataLine).toContain('-');
});
it('appends next page hint when hasNextPage is true', () => {
const events = [createMockEvent()];
const output = buildObserveEventsTable(events, withNextPage);
expect(output).toContain('Next page: --after cursor-abc');
});
it('does not append next page hint when hasNextPage is false', () => {
const events = [createMockEvent()];
const output = buildObserveEventsTable(events, noNextPage);
expect(output).not.toContain('Next page');
});
});
describe(buildObserveEventsJson, () => {
it('maps event to JSON shape with all relevant fields and pageInfo', () => {
const events = [
createMockEvent({
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
timestamp: '2025-01-15T10:30:00.000Z',
}),
];
const result = buildObserveEventsJson(events, withNextPage);
expect(result).toEqual({
events: [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
timestamp: '2025-01-15T10:30:00.000Z',
},
],
pageInfo: {
hasNextPage: true,
endCursor: 'cursor-abc',
},
});
});
it('handles null optional fields', () => {
const events = [
createMockEvent({
countryCode: null,
sessionId: null,
}),
];
const result = buildObserveEventsJson(events, noNextPage);
expect(result.events[0].countryCode).toBeNull();
expect(result.events[0].sessionId).toBeNull();
});
it('returns empty events array for empty input', () => {
const result = buildObserveEventsJson([], noNextPage);
expect(result.events).toEqual([]);
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
});
});
@@ -1,346 +0,0 @@
import { AppPlatform } from '../../graphql/generated';
import {
ObserveMetricsMap,
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
makeMetricsKey,
resolveStatKey,
type MetricValues,
} from '../formatMetrics';
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
const DEFAULT_STATS_JSON: StatisticKey[] = [
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
];
const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'];
function makeMetricValueWithDefaults(overrides: Partial<MetricValues>): MetricValues {
return {
min: 0.1,
median: 0.3,
max: 1.1,
average: 0.5,
p80: 0.8,
p90: 0.9,
p99: 1.0,
eventCount: 100,
...overrides,
};
}
describe(buildObserveMetricsTable, () => {
it('formats metrics grouped by version with metric columns', () => {
const metricsMap: ObserveMetricsMap = new Map();
const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios);
metricsMap.set(
iosKey,
new Map([
[
'expo.app_startup.cold_launch_time',
makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }),
],
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })],
])
);
const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android);
metricsMap.set(
androidKey,
new Map([
[
'expo.app_startup.cold_launch_time',
makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }),
],
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })],
])
);
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
// The header is bolded, thus the escape characters in the snapshot
expect(output).toMatchInlineSnapshot(`
"App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count
----------- -------- --------------- ----------------- ------- ---------
1.2.0 iOS 0.35s 110 1.32s 90
1.1.0 Android 0.25s 120 1.12s 100 "
`);
});
it('shows - for versions with no matching metric data', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
metricsMap.set(key, new Map());
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
expect(output).toMatchInlineSnapshot(`
"App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count
----------- -------- --------------- ----------------- ------- ---------
2.0.0 iOS - - - - "
`);
});
it('returns message when no metrics data found', () => {
const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE);
expect(output).toMatchInlineSnapshot(`"No metrics data found."`);
});
});
describe(buildObserveMetricsJson, () => {
it('produces JSON with all stats per metric', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })],
])
);
const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], DEFAULT_STATS_JSON);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
appVersion: '1.0.0',
platform: AppPlatform.Ios,
metrics: {
'expo.app_startup.tti': {
min: 0.1,
median: 0.12,
max: 1.1,
average: 0.5,
p80: 0.8,
p90: 0.9,
p99: 1.0,
eventCount: 90,
},
},
});
});
it('produces null values when no observe data matches for a metric', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('3.0.0', AppPlatform.Android);
metricsMap.set(key, new Map());
const result = buildObserveMetricsJson(
metricsMap,
['expo.app_startup.tti'],
DEFAULT_STATS_JSON
);
expect(result[0].metrics).toEqual({
'expo.app_startup.tti': {
min: null,
median: null,
max: null,
average: null,
p80: null,
p90: null,
p99: null,
eventCount: null,
},
});
});
});
describe(makeMetricsKey, () => {
it('creates a key from version and platform', () => {
expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS');
expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID');
});
});
describe(resolveStatKey, () => {
it('resolves canonical stat names', () => {
expect(resolveStatKey('min')).toBe('min');
expect(resolveStatKey('max')).toBe('max');
expect(resolveStatKey('median')).toBe('median');
expect(resolveStatKey('average')).toBe('average');
expect(resolveStatKey('p80')).toBe('p80');
expect(resolveStatKey('p90')).toBe('p90');
expect(resolveStatKey('p99')).toBe('p99');
expect(resolveStatKey('eventCount')).toBe('eventCount');
});
it('resolves short aliases', () => {
expect(resolveStatKey('med')).toBe('median');
expect(resolveStatKey('avg')).toBe('average');
expect(resolveStatKey('count')).toBe('eventCount');
expect(resolveStatKey('event_count')).toBe('eventCount');
});
it('throws on unknown stat', () => {
expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"');
});
});
describe('DEFAULT_STATS_TABLE', () => {
it('defaults to median, eventCount', () => {
expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']);
});
});
describe('DEFAULT_STATS_JSON', () => {
it('includes all stats', () => {
expect(DEFAULT_STATS_JSON).toEqual([
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
]);
});
});
describe('custom stats parameter', () => {
it('table renders only selected stats', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
[
'expo.app_startup.tti',
{
min: 0.01,
median: 0.1,
max: 0.5,
average: null,
p80: null,
p90: null,
p99: 0.9,
eventCount: 42,
},
],
])
);
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], [
'p99',
'eventCount',
]);
expect(output).toContain('TTI P99');
expect(output).toContain('TTI Count');
expect(output).toContain('0.90s');
expect(output).toContain('42');
expect(output).not.toContain('TTI Min');
expect(output).not.toContain('TTI Med');
expect(output).not.toContain('TTI Max');
});
it("table formats eventCount as integer without 's' suffix", () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
[
'expo.app_startup.tti',
{
min: 0.01,
median: 0.1,
max: 0.5,
average: null,
p80: null,
p90: null,
p99: null,
eventCount: 100,
},
],
])
);
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], ['eventCount']);
expect(output).toContain('100');
expect(output).not.toContain('100s');
expect(output).not.toContain('100.00s');
});
it('JSON includes only selected stats', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
[
'expo.app_startup.tti',
{
min: 0.01,
median: 0.1,
max: 0.5,
average: 0.15,
p80: 0.3,
p90: 0.4,
p99: 0.9,
eventCount: 42,
},
],
])
);
const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], [
'p90',
'eventCount',
]);
expect(result[0].metrics['expo.app_startup.tti']).toEqual({
p90: 0.4,
eventCount: 42,
});
});
it('JSON uses default stats when not specified', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
metricsMap.set(
key,
new Map([
[
'expo.app_startup.tti',
{
min: 0.02,
median: 0.1,
max: 0.4,
average: null,
p80: null,
p90: null,
p99: null,
eventCount: null,
},
],
])
);
const result = buildObserveMetricsJson(
metricsMap,
['expo.app_startup.tti'],
DEFAULT_STATS_JSON
);
expect(result[0].metrics['expo.app_startup.tti']).toEqual({
min: 0.02,
median: 0.1,
max: 0.4,
average: null,
p80: null,
p90: null,
p99: null,
eventCount: null,
});
});
});
@@ -1,52 +0,0 @@
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
describe(resolveMetricName, () => {
it('resolves short alias "tti" to full metric name', () => {
expect(resolveMetricName('tti')).toBe('expo.app_startup.tti');
});
it('resolves short alias "ttr" to full metric name', () => {
expect(resolveMetricName('ttr')).toBe('expo.app_startup.ttr');
});
it('resolves short alias "cold_launch" to full metric name', () => {
expect(resolveMetricName('cold_launch')).toBe('expo.app_startup.cold_launch_time');
});
it('resolves short alias "warm_launch" to full metric name', () => {
expect(resolveMetricName('warm_launch')).toBe('expo.app_startup.warm_launch_time');
});
it('resolves short alias "bundle_load" to full metric name', () => {
expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time');
});
it('passes through full metric names unchanged', () => {
expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti');
expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe(
'expo.app_startup.cold_launch_time'
);
});
it('throws on unknown alias', () => {
expect(() => resolveMetricName('unknown_metric')).toThrow('Unknown metric: "unknown_metric"');
});
it('passes through dot-containing custom metric names', () => {
expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name');
});
});
describe(getMetricDisplayName, () => {
it('returns short display name for known metrics', () => {
expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch');
expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch');
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI');
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR');
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
});
it('returns the full metric name for unknown metrics', () => {
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
});
});
@@ -1,78 +0,0 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
export type EventsOrderPreset = 'slowest' | 'fastest' | 'newest' | 'oldest';
export function resolveOrderBy(preset: EventsOrderPreset): AppObserveEventsOrderBy {
switch (preset) {
case 'slowest':
return {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
};
case 'fastest':
return {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
};
case 'newest':
return {
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
};
case 'oldest':
return {
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
};
}
}
interface FetchObserveEventsOptions {
metricName: string;
orderBy: AppObserveEventsOrderBy;
limit: number;
after?: string;
startTime: string;
endTime: string;
platform?: AppObservePlatform;
appVersion?: string;
updateId?: string;
}
interface FetchObserveEventsResult {
events: AppObserveEvent[];
pageInfo: PageInfo;
}
export async function fetchObserveEventsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options: FetchObserveEventsOptions
): Promise<FetchObserveEventsResult> {
const filter: AppObserveEventsFilter = {
metricName: options.metricName,
startTime: options.startTime,
endTime: options.endTime,
...(options.platform && { platform: options.platform }),
...(options.appVersion && { appVersion: options.appVersion }),
...(options.updateId && { appUpdateId: options.updateId }),
};
return await ObserveQuery.eventsAsync(graphqlClient, {
appId,
filter,
first: options.limit,
...(options.after && { after: options.after }),
orderBy: options.orderBy,
});
}
@@ -1,99 +0,0 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { AppObservePlatform, AppObserveVersionMarker, AppPlatform } from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import Log from '../log';
import { MetricValues, ObserveMetricsMap, makeMetricsKey } from './formatMetrics';
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
[AppPlatform.Android]: AppObservePlatform.Android,
[AppPlatform.Ios]: AppObservePlatform.Ios,
};
const observePlatformToAppPlatform: Record<AppObservePlatform, AppPlatform> = {
[AppObservePlatform.Android]: AppPlatform.Android,
[AppObservePlatform.Ios]: AppPlatform.Ios,
};
interface ObserveQueryResult {
metricName: string;
platform: AppObservePlatform;
markers: AppObserveVersionMarker[];
}
export function validateDateFlag(value: string, flagName: string): void {
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new EasCommandError(
`Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).`
);
}
}
export async function fetchObserveMetricsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
metricNames: string[],
platforms: AppPlatform[],
startTime: string,
endTime: string
): Promise<ObserveMetricsMap> {
const observeQueries: Promise<ObserveQueryResult | null>[] = [];
for (const metricName of metricNames) {
for (const appPlatform of platforms) {
const observePlatform = appPlatformToObservePlatform[appPlatform];
observeQueries.push(
ObserveQuery.timeSeriesVersionMarkersAsync(graphqlClient, {
appId,
metricName,
platform: observePlatform,
startTime,
endTime,
})
.then(markers => ({
metricName,
platform: observePlatform,
markers,
}))
.catch(error => {
Log.warn(
`Failed to fetch observe data for metric "${metricName}" on ${observePlatform}: ${error.message}`
);
return null;
})
);
}
}
const observeResults = await Promise.all(observeQueries);
const metricsMap: ObserveMetricsMap = new Map();
for (const result of observeResults) {
if (!result) {
continue;
}
const { metricName, platform, markers } = result;
const appPlatform = observePlatformToAppPlatform[platform];
for (const marker of markers) {
const key = makeMetricsKey(marker.appVersion, appPlatform);
if (!metricsMap.has(key)) {
metricsMap.set(key, new Map());
}
const values: MetricValues = {
min: marker.statistics.min,
max: marker.statistics.max,
median: marker.statistics.median,
average: marker.statistics.average,
p80: marker.statistics.p80,
p90: marker.statistics.p90,
p99: marker.statistics.p99,
eventCount: marker.eventCount,
};
metricsMap.get(key)!.set(metricName, values);
}
}
return metricsMap;
}
@@ -1,88 +0,0 @@
import chalk from 'chalk';
import { AppObserveEvent, PageInfo } from '../graphql/generated';
import { getMetricDisplayName } from './metricNames';
function formatTimestamp(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export interface ObserveEventJson {
id: string;
metricName: string;
metricValue: number;
appVersion: string;
appBuildNumber: string;
deviceModel: string;
deviceOs: string;
deviceOsVersion: string;
countryCode: string | null;
sessionId: string | null;
easClientId: string;
timestamp: string;
}
export function buildObserveEventsTable(events: AppObserveEvent[], pageInfo: PageInfo): string {
if (events.length === 0) {
return chalk.yellow('No events found.');
}
const headers = ['Metric', 'Value', 'App Version', 'Platform', 'Device', 'Country', 'Timestamp'];
const rows: string[][] = events.map(event => [
getMetricDisplayName(event.metricName),
`${event.metricValue.toFixed(2)}s`,
`${event.appVersion} (${event.appBuildNumber})`,
`${event.deviceOs} ${event.deviceOsVersion}`,
event.deviceModel,
event.countryCode ?? '-',
formatTimestamp(event.timestamp),
]);
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
if (pageInfo.hasNextPage && pageInfo.endCursor) {
lines.push('', `Next page: --after ${pageInfo.endCursor}`);
}
return lines.join('\n');
}
export function buildObserveEventsJson(
events: AppObserveEvent[],
pageInfo: PageInfo
): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } {
return {
events: events.map(event => ({
id: event.id,
metricName: event.metricName,
metricValue: event.metricValue,
appVersion: event.appVersion,
appBuildNumber: event.appBuildNumber,
deviceModel: event.deviceModel,
deviceOs: event.deviceOs,
deviceOsVersion: event.deviceOsVersion,
countryCode: event.countryCode ?? null,
sessionId: event.sessionId ?? null,
easClientId: event.easClientId,
timestamp: event.timestamp,
})),
pageInfo: {
hasNextPage: pageInfo.hasNextPage,
endCursor: pageInfo.endCursor ?? null,
},
};
}
@@ -1,165 +0,0 @@
import chalk from 'chalk';
import { EasCommandError } from '../commandUtils/errors';
import { AppPlatform } from '../graphql/generated';
import { appPlatformDisplayNames } from '../platform';
import { getMetricDisplayName } from './metricNames';
export type StatisticKey =
| 'min'
| 'max'
| 'median'
| 'average'
| 'p80'
| 'p90'
| 'p99'
| 'eventCount';
export const STAT_ALIASES: Record<string, StatisticKey> = {
min: 'min',
max: 'max',
med: 'median',
median: 'median',
avg: 'average',
average: 'average',
p80: 'p80',
p90: 'p90',
p99: 'p99',
count: 'eventCount',
event_count: 'eventCount',
eventCount: 'eventCount',
};
export const STAT_DISPLAY_NAMES: Record<StatisticKey, string> = {
min: 'Min',
max: 'Max',
median: 'Med',
average: 'Avg',
p80: 'P80',
p90: 'P90',
p99: 'P99',
eventCount: 'Count',
};
export function resolveStatKey(input: string): StatisticKey {
const resolved = STAT_ALIASES[input];
if (resolved) {
return resolved;
}
throw new EasCommandError(
`Unknown statistic: "${input}". Valid options: ${Object.keys(STAT_ALIASES).join(', ')}`
);
}
function formatStatValue(stat: StatisticKey, value: number | null | undefined): string {
if (value == null) {
return '-';
}
if (stat === 'eventCount') {
return String(value);
}
return `${value.toFixed(2)}s`;
}
export interface MetricValues {
min: number | null | undefined;
max: number | null | undefined;
median: number | null | undefined;
average: number | null | undefined;
p80: number | null | undefined;
p90: number | null | undefined;
p99: number | null | undefined;
eventCount: number | null | undefined;
}
type ObserveMetricsKey = `${string}:${AppPlatform}`;
export type ObserveMetricsMap = Map<ObserveMetricsKey, Map<string, MetricValues>>;
export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey {
return `${appVersion}:${platform}`;
}
function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } {
const lastColon = key.lastIndexOf(':');
return {
appVersion: key.slice(0, lastColon),
platform: key.slice(lastColon + 1) as AppPlatform,
};
}
export type MetricValuesJson = Partial<Record<StatisticKey, number | null>>;
export interface ObserveMetricsVersionResult {
appVersion: string;
platform: AppPlatform;
metrics: Record<string, MetricValuesJson>;
}
export function buildObserveMetricsJson(
metricsMap: ObserveMetricsMap,
metricNames: string[],
stats: StatisticKey[]
): ObserveMetricsVersionResult[] {
const results: ObserveMetricsVersionResult[] = [];
for (const [key, versionMetrics] of metricsMap) {
const { appVersion, platform } = parseMetricsKey(key);
const metrics: Record<string, MetricValuesJson> = {};
for (const metricName of metricNames) {
const values = versionMetrics.get(metricName);
const statValues: MetricValuesJson = {};
for (const stat of stats) {
statValues[stat] = values?.[stat] ?? null;
}
metrics[metricName] = statValues;
}
results.push({ appVersion, platform, metrics });
}
return results;
}
export function buildObserveMetricsTable(
metricsMap: ObserveMetricsMap,
metricNames: string[],
stats: StatisticKey[]
): string {
const results = buildObserveMetricsJson(metricsMap, metricNames, stats);
if (results.length === 0) {
return chalk.yellow('No metrics data found.');
}
const fixedHeaders = ['App Version', 'Platform'];
const metricHeaders: string[] = [];
for (const m of metricNames) {
const name = getMetricDisplayName(m);
for (const stat of stats) {
metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`);
}
}
const headers = [...fixedHeaders, ...metricHeaders];
const rows: string[][] = results.map(result => {
const metricCells: string[] = [];
for (const m of metricNames) {
const values = result.metrics[m];
for (const stat of stats) {
metricCells.push(formatStatValue(stat, values?.[stat] ?? null));
}
}
return [result.appVersion, appPlatformDisplayNames[result.platform], ...metricCells];
});
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n');
}
@@ -1,35 +0,0 @@
import { EasCommandError } from '../commandUtils/errors';
export const METRIC_ALIASES: Record<string, string> = {
tti: 'expo.app_startup.tti',
ttr: 'expo.app_startup.ttr',
cold_launch: 'expo.app_startup.cold_launch_time',
warm_launch: 'expo.app_startup.warm_launch_time',
bundle_load: 'expo.app_startup.bundle_load_time',
};
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
export const METRIC_SHORT_NAMES: Record<string, string> = {
'expo.app_startup.cold_launch_time': 'Cold Launch',
'expo.app_startup.warm_launch_time': 'Warm Launch',
'expo.app_startup.tti': 'TTI',
'expo.app_startup.ttr': 'TTR',
'expo.app_startup.bundle_load_time': 'Bundle Load',
};
export function resolveMetricName(input: string): string {
if (METRIC_ALIASES[input]) {
return METRIC_ALIASES[input];
}
if (KNOWN_FULL_NAMES.has(input) || input.includes('.')) {
return input;
}
throw new EasCommandError(
`Unknown metric: "${input}". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: ${Object.keys(METRIC_ALIASES).join(', ')}`
);
}
export function getMetricDisplayName(metricName: string): string {
return METRIC_SHORT_NAMES[metricName] ?? metricName;
}