Compare commits

...
26 Commits
Author SHA1 Message Date
Expo CI 5409c03d58 v24.0.0 2026-09-09 20:07:38 +00:00
Gabe Debes a941930ea7 [eas-cli] adds serve-sim session token in simulator preview links (#4312)
* [eas-cli] carry the serve-sim session token in simulator preview links

* [eas-cli] sanitize the preview config in json output
2026-09-09 12:52:08 -07:00
Szymon Świerk 74572101ac [eas-cli] Make ASC API key issuer identifier optional in types (#4248)
* [eas-cli] Make ASC API key issuer identifier optional in types

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

* [eas-cli] Pass optional issuer identifier through to TestFlight app lookup
2026-09-09 21:22:32 +02:00
Stanisław Chmiela 7b050cae3f [worker] Add MCP server URL to build context (#4335)
# Why

Worker has config for API server URL. I now need MCP server URL too.

# How

Added `mcpServerUrl` in `config` and in contexts.

# Test Plan

CI should pass.
2026-09-09 16:56:49 +02:00
Douglas LowderandClaude Opus 4.8 d193860668 [eas-cli] Add observe:event command to fetch a single event by ID (#4252)
* [eas-cli] Add observe:event command to fetch a single event by ID

Add `eas observe:event <id>` to display one Observe event (either metric or userEvent) by its ID. Also, ensure event IDs are propagated to the results of `eas observe:session <sessionId>` so that they appear in JSON output.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-09 06:07:57 -07:00
Krystof WoldrichandCodex 1da0be8529 [build-tools] Use bun x to start Argent remote sessions (#4366)
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-08 15:22:47 +02:00
Douglas LowderandClaude Opus 4.8 08b4346620 [eas-cli] Add observe:errors command (#4339)
* [eas-cli] Add observe:errors command

Add `eas observe:errors` to display error and exception issue groups grouped by
fingerprint, using the new observe.errors.groups GraphQL namespace. Exceptions
are excluded from observe:events (userEvents.list) and surfaced here instead.
Supports --platform, --severity (fatal|error), time-range, --app-version,
--build-number, --update-id, and --environment filters, with table and --json
output.

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

* [eas-cli] Add --fingerprint drill-down to observe:errors

Add `eas observe:errors --fingerprint <fingerprint>` to list an error group's
individual occurrences with stack traces, instead of only the grouped summary.
Occurrences are fetched via observe.errors.occurrences (newest first) with
--limit and --after paging, and rendered as per-occurrence detail (fields,
stack trace, body, properties) or as --json. The grouped table now shows a
Fingerprint column and a hint pointing to the flag.

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

* Respond to review comments

* Regenerate GraphQL from server

* Fix errors from nullability change in issuerIdentifier

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-09-07 20:29:56 -07:00
4d6d9b1fc4 [build-tools] Allow configuring Android emulator display (#4349)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:39:56 +02:00
Stanisław Chmiela 59f9add53c [build-tools] Add custom log phase helper (#4334)
# Why

I want a single EAS function to appear as running as two separate phases.

# How

Added `withLogPhaseAsync` which is going to create a logger child with necessary fields overwritten so a new log phase appears.

# Test Plan

CI should pass. Verified locally.

<img width="1343" height="712" alt="Zrzut ekranu 2026-09-4 o 12 17 25" src="https://github.com/user-attachments/assets/f4733c83-3119-42d6-b7dc-fc46aedaf434" />
2026-09-07 15:11:57 +02:00
1946562247 [build-tools] Update Appium command list (#4356)
Co-authored-by: Expo Bot <[email protected]>
Co-authored-by: Stanisław Chmiela <[email protected]>
2026-09-07 10:41:29 +00:00
SJ Kim 24a65ad80f [eas-cli] Support protected update channels (#4319)
* [eas-cli] Support protected update channels

* [eas-cli] Add protected channels changelog entry

* [eas-cli] Handle missing protected channels

* [eas-cli] Address channel protection review feedback (ENG-25975)
2026-09-04 14:03:53 -04:00
Hirbod 37ea60c20c [eas-cli] remove hidden AI chat command (#4348) 2026-09-04 17:03:29 +02:00
Douglas LowderandClaude Opus 4.8 99cbcae249 [eas-cli] Align Observe code with new server schema (#4338)
* [eas-cli] Align observe commands with the new Observe GraphQL schema

The Observe GraphQL schema was reorganized into scoped namespaces (universe
#30606). Migrate the observe queries onto the new fields — metrics.list,
userEvents.list/names, navigation.routes, session(id){metrics logs} — and adopt
the renamed fields in --json output (metricName->name, metricValue->value,
eventName->name). observe:events now lists user-defined events only.
observe:session timelines now include errors via session.logs. appVersions
(observe:versions, observe:metrics-summary) is unchanged. Regenerate GraphQL and
update tests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 06:29:01 -07:00
Szymon Świerk a426042784 [build-tools] Support individual ASC API keys in upload_to_asc (#4247)
* [build-tools] Support individual ASC API keys in upload_to_asc

* [build-tools] In upoload_to_asc function, set jwt subject with a dedicated function instead of setting the claim manually
2026-09-03 16:34:50 +02:00
1cb06a6cb2 [build-tools] Install FFmpeg before Android device hub (#4332)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-09-03 15:08:04 +02:00
Krystof WoldrichandCodex 5236871b0b fix(ci): changelog for PRs from forks (#4331)
Co-authored-by: Codex <[email protected]>
2026-09-03 12:49:30 +02:00
ceb20c9dd6 [build-tools] Use lower 980px Android preview resolution (#4326)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-09-03 10:43:09 +02:00
Krystof WoldrichandCodex 83fa42c179 [eas-cli] Avoid GraphQL argument type collisions (#4328)
Co-authored-by: Codex <[email protected]>
2026-09-02 16:50:52 +02:00
Stanisław Chmiela 7727b9c347 [ci] Gate worker CLI versions on npm availability (#4313)
We currently:
- publish to NPM without awaiting the release being ready and public
- immediately change `cli-versions.json`
- this makes builds immediately try to use a new `eas-cli` version despite it NOT being public yet, which causes problems, see [Datadog](https://app.datadoghq.com/logs?query=%22No%20matching%20version%20found%20for%20eas-cli%22&agg_m=count&agg_m_source=base&agg_t=count&cols=host%2Cservice&fromUser=true&messageDisplay=inline&refresh_mode=paused&storage=hot&stream_sort=desc&viz=stream&from_ts=1788174847023&to_ts=1788178586558&live=false).

This pull request makes it so we:
- publish to NPM, make a GitHub release
- update changelog and push to main
- await NPM readiness for 10 minutes (last release took 6)
- only once it's ready, update `cli-versions.json`.
2026-09-02 09:02:12 +00:00
Stanisław Chmiela b015c0b226 [worker] Keep legacy cache failures non-fatal (#4295)
Add Sentry logging to legacy build cache.
2026-09-02 09:29:59 +02:00
Kadi Kraman 882687818f [eas-cli] add --build-number to observe:metrics and observe:events (#4278)
* Add --build-number to observe:metrics and observe:events

* Add changelog
2026-09-01 20:46:41 +01:00
Kadi Kraman 37f5163872 [eas-cli] add apple platform value to observe commands (#4276)
* Regenerate GraphQL schema

* Add apple platform target model

* Query observe data by platform target

* Wire --platform apple through the observe commands

* Update observe tests for platform targets

* Add changelog

* Format

* Regenerate gql

* Sync gql
2026-09-01 20:08:39 +01:00
Stanisław Chmiela df2c81d131 [eas-cli] Update simulator device GraphQL inputs (#4327) 2026-09-01 19:37:08 +01:00
Kadi Kraman 236398a823 [eas-cli] add environment filter to observe commands (#4275)
* Add shared --environment flag and thread it through the observe query layer

* Add --environment to observe:metrics-summary

* Add --environment to observe:metrics

* Add --environment to observe:routes

* Add --environment to observe:events

* Add --environment to observe:versions

* Add --environment to the observe:session picker

* Add changelog

* Add links to changelog
2026-09-01 13:49:33 +01:00
Jon Samp eebe02eef2 [eas-cli] Add project:status command (#4006)
* [eas-cli] Add project:status command

Adds `eas project:status` (alias `eas status`), a snapshot of the project
overview: recent production builds, development builds, workflow runs,
submissions, and updates. Mirrors the website project dashboard and
supports --json and --non-interactive for agent consumption.

* [eas-cli] Fix changelog PR link

* [eas-cli] Address project status review feedback

* [eas-cli] Improve project status for agents

* [eas-cli] Clarify project status pagination lookahead

* [eas-cli] Adapt project status to current GraphQL fields

* [eas-cli] Remove dead README link to simulator/start.ts

* [eas-cli] Normalize unknown workflow run statuses in project status
2026-08-31 22:43:59 -05:00
Expo CI 4b1e5a76e2 update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json 2026-08-31 20:28:03 +00:00
131 changed files with 15623 additions and 5463 deletions

No files matched your search

+3 -1
View File
@@ -31,7 +31,9 @@ jobs:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: always()
if: >-
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
with:
message-id: changelog-entry-check
refresh-message-position: true
+41 -5
View File
@@ -53,6 +53,10 @@ jobs:
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Configure git
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Update changelog
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
@@ -60,20 +64,52 @@ jobs:
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push
- name: Commit and push changelog
run: |
git fetch
git checkout main
git add CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git push
- name: Verify eas-cli is available on npm
run: |
availability_deadline_epoch=$(( $(date +%s) + 600 ))
while true; do
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
if [ "$published" = "$EAS_CLI_VERSION" ]; then
echo "Verified eas-cli@$EAS_CLI_VERSION is available on npm."
exit 0
fi
current_epoch=$(date +%s)
if [ "$current_epoch" -ge "$availability_deadline_epoch" ]; then
break
fi
retry_delay=$((availability_deadline_epoch - current_epoch))
if [ "$retry_delay" -gt 10 ]; then
retry_delay=10
fi
echo "eas-cli@$EAS_CLI_VERSION is not available on npm yet. Retrying in ${retry_delay}s."
sleep "$retry_delay"
done
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
exit 1
- name: Bump STAGING and PRODUCTION
run: |
git pull --rebase --autostash origin main
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add cli-versions.json CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json"
- name: Commit and push version update
run: |
git add cli-versions.json
git commit --allow-empty -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
git push
- name: Prepare changelog for Slack
run: |
+26 -2
View File
@@ -6,6 +6,28 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
### 🎉 New features
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
### 🎉 New features
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
@@ -14,8 +36,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
@@ -51,6 +71,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
- [eas-cli] Add `eas project:status` (alias `eas status`) command that prints a snapshot of the project's recent builds, development builds, workflow runs, submissions, and updates, with agent-friendly JSON metadata, activity links, structured errors, submission-to-build correlation, and `--non-interactive` support. ([#4006](https://github.com/expo/eas-cli/pull/4006) by [@jonsamp](https://github.com/jonsamp))
### 🐛 Bug fixes
@@ -96,6 +117,9 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `--environment` flag to the `eas observe:*` commands. ([#4275](https://github.com/expo/eas-cli/pull/4275) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `apple` value to the `--platform` flag of the `eas observe:*` commands. ([#4276](https://github.com/expo/eas-cli/pull/4276) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--build-number` flag to `eas observe:metrics` and `eas observe:events`. ([#4278](https://github.com/expo/eas-cli/pull/4278) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "23.1.0",
"PRODUCTION": "23.1.0"
"STAGING": "23.2.0",
"PRODUCTION": "23.2.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "23.2.0",
"version": "24.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "23.2.0",
"version": "24.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -39,17 +39,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "23.0.0",
"@expo/eas-build-job": "23.1.0",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "23.1.0",
"@expo/logger": "24.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.1.0",
"@expo/template-file": "23.0.0",
"@expo/turtle-spawn": "23.1.0",
"@expo/steps": "24.0.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
describe(CustomBuildContext, () => {
it('copies the MCP server URL from the build context', () => {
const ctx = new BuildContext(createTestIosJob(), {
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
mcpServerUrl: 'ws://localhost:8787',
});
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
});
it('should not lose workflowInterpolationContext', () => {
const contextUploadArtifact = jest.fn();
const ctx = new BuildContext(
+3
View File
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
}
export class SkipNativeBuildError extends Error {}
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public readonly mcpServerUrl?: string;
public job: TJob;
public metadata?: Metadata;
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
this.startTime = new Date();
}
@@ -83,6 +83,31 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it('passes profile and LCD inputs to emulator creation', async () => {
const systemImagePackage = 'system-images;android-35;default;x86_64';
await createStep({
device_identifier: 'medium_phone',
system_image_package: systemImagePackage,
lcd_width: 720,
lcd_height: 1600,
lcd_density: 262,
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'medium_phone',
systemImagePackage,
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 262,
})
);
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
);
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
@@ -9,6 +9,7 @@ import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
import {
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -41,7 +42,7 @@ jest.mock('../../utils/argentEvents', () => ({
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
ensureFfmpegInstalledAsync: jest.fn(),
ensureFfmpegInstalledOnceAsync: jest.fn(),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokAuthtokenOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
@@ -127,18 +128,31 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
} as never
);
// (1) The event log flag is enabled, before the tool-server is launched.
// (1) FFmpeg setup starts in the background before Argent setup.
expect(ensureFfmpegInstalledOnceAsync).toHaveBeenCalledWith({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: { EXISTING: 'value' },
logger: expect.anything(),
});
expect(jest.mocked(ensureFfmpegInstalledOnceAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(spawn).mock.invocationCallOrder[0]
);
// (2) The event log flag is enabled, before the tool-server is launched.
const spawnCalls = jest.mocked(spawn).mock.calls;
const enableEventLogIndex = spawnCalls.findIndex(
([command, args]) =>
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
command === 'bun' &&
Array.isArray(args) &&
args[0] === 'x' &&
args.includes('tool-server-event-log')
);
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
);
// (2) The tool-server and the collector are pinned to the exact same event log path.
// (3) The tool-server and the collector are pinned to the exact same event log path.
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
expect(serverEnv.EXISTING).toBe('value');
@@ -147,7 +161,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
eventLogPath: EXPECTED_EVENT_LOG_PATH,
});
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
// (4) Collection starts (before we wait for the session to stop) and (5) is torn down
// afterwards, exactly once.
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bunx resolve it.')
expect.stringContaining('Continuing and letting bun x resolve it.')
);
});
});
@@ -46,6 +46,21 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'lcd_width',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_height',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_density',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'count',
required: false,
@@ -86,6 +101,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
const lcdWidth = inputs.lcd_width.value as number | undefined;
const lcdHeight = inputs.lcd_height.value as number | undefined;
const lcdDensity = inputs.lcd_density.value as number | undefined;
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
@@ -129,6 +147,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
lcdWidth: lcdWidth ?? null,
lcdHeight: lcdHeight ?? null,
lcdDensity: lcdDensity ?? null,
env,
logger,
});
@@ -20,7 +20,7 @@ import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import {
ensureFfmpegInstalledAsync,
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -103,32 +103,39 @@ export function createStartArgentRemoteSessionBuildFunction(
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Argent shells out to ffmpeg to record the screen. Installing it pulls a
// large Homebrew dependency tree, so do not block session readiness on it:
// the session comes up at its usual speed and only a recording started in
// the first moments misses ffmpeg. Never rejects, so `void` is safe.
void ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
// recording started in the first moments may miss ffmpeg.
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bunx',
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
'bun',
[
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
],
{ env, logger }
);
logger.info('Enabling the Argent tool-server event log flag.');
await spawn(
'bunx',
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
'bun',
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
{ env, logger }
);
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
// Keep Argent itself in foreground mode under the detached bun process. This preserves
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const argentServer = spawnDetached({
command: 'bunx',
command: 'bun',
args: [
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
@@ -293,7 +300,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting bunx resolve it.`
`Continuing and letting bun x resolve it.`
);
return;
}
@@ -90,19 +90,24 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
const ascApiKey = z
.object({
issuer_id: z.string(),
issuer_id: z.string().nullish(),
key_id: z.string(),
key: z.string(),
})
.parse(ascApiKeyJson);
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
const token = await new jose.SignJWT({})
const jwt = new jose.SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
.setIssuer(ascApiKey.issuer_id)
.setAudience('appstoreconnect-v1')
.setExpirationTime('20m')
.sign(privateKey);
.setExpirationTime('20m');
if (ascApiKey.issuer_id) {
jwt.setIssuer(ascApiKey.issuer_id);
} else {
// Nullish issuer_id means an individual API key
jwt.setSubject('user');
}
const token = await jwt.sign(privateKey);
const client = new AscApiClient({ token, logger: stepsCtx.logger });
@@ -15,10 +15,11 @@ import { sleepAsync } from '../../../utils/retry';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
ensureFfmpegInstalledAsync,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
metricsCorsOriginToServeSimArgs,
startDeviceWebPreviewWithTunnelAsync,
startExpoDeviceHubWithTunnelAsync,
startNgrokTunnelAsync,
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
@@ -198,7 +199,7 @@ describe(createExpoDeviceHubArgs, () => {
'--webrtc-ice-policy',
'all',
'--max-dimension',
'1280',
'960',
'--video-bitrate',
'6000000',
'--video-fps',
@@ -359,13 +360,20 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
});
});
it('starts expo-device-hub for Linux and cleans up the preview resources', async () => {
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
const packageVersion = '1.2.3';
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close,
} as never);
jest
.mocked(spawn)
.mockReturnValueOnce(
Promise.reject(new Error('ffmpeg is missing')) as ReturnType<typeof spawn>
)
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
@@ -376,7 +384,28 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
packageVersion,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
expect(spawnCalls[1]).toEqual([
'sudo',
['apt-get', 'update'],
expect.objectContaining({
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
}),
]);
expect(spawnCalls[2]).toEqual([
'sudo',
['apt-get', 'install', '-y', 'ffmpeg'],
expect.objectContaining({
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
}),
]);
const expoDeviceHubCallIndex = spawnCalls.findIndex(([command]) => command === 'npx');
expect(expoDeviceHubCallIndex).toBeGreaterThan(2);
expect(jest.mocked(spawn).mock.invocationCallOrder[2]).toBeLessThan(
jest.mocked(spawn).mock.invocationCallOrder[expoDeviceHubCallIndex]
);
const [command, args] = spawnCalls[expoDeviceHubCallIndex];
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
@@ -416,6 +445,32 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close,
} as never);
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
'ffmpeg',
expect.anything(),
expect.anything()
);
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
});
describe(turnIceServersToWebPreviewArgs, () => {
@@ -785,7 +840,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
});
describe(ensureFfmpegInstalledAsync, () => {
describe(ensureFfmpegInstalledOnceAsync, () => {
const spawnMock = jest.mocked(spawn);
function spawnResolved(): ReturnType<typeof spawn> {
@@ -804,7 +859,7 @@ describe(ensureFfmpegInstalledAsync, () => {
it('does not install when ffmpeg is on PATH', async () => {
spawnMock.mockReturnValueOnce(spawnResolved());
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger: createLoggerMock(),
@@ -817,7 +872,7 @@ describe(ensureFfmpegInstalledAsync, () => {
it('installs ffmpeg with Homebrew on darwin when it is missing', async () => {
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved());
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger: createLoggerMock(),
@@ -838,7 +893,7 @@ describe(ensureFfmpegInstalledAsync, () => {
.mockReturnValueOnce(spawnResolved()) // apt-get update
.mockReturnValueOnce(spawnResolved()); // apt-get install
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
logger: createLoggerMock(),
@@ -860,6 +915,33 @@ describe(ensureFfmpegInstalledAsync, () => {
);
});
it('shares an in-flight ffmpeg setup between callers', async () => {
let finishInstall: (() => void) | undefined;
const pendingInstall = new Promise<void>(resolve => {
finishInstall = resolve;
});
spawnMock
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
.mockReturnValueOnce(spawnResolved()) // apt-get update
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>); // apt-get install
const options = {
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
logger: createLoggerMock(),
};
const firstSetup = ensureFfmpegInstalledOnceAsync(options);
await new Promise<void>(resolve => setImmediate(resolve));
expect(spawnMock).toHaveBeenCalledTimes(3);
const secondSetup = ensureFfmpegInstalledOnceAsync(options);
expect(spawnMock).toHaveBeenCalledTimes(3);
finishInstall?.();
await Promise.all([firstSetup, secondSetup]);
expect(spawnMock).toHaveBeenCalledTimes(3);
});
it('still installs on linux when the apt index refresh fails', async () => {
spawnMock
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
@@ -867,7 +949,7 @@ describe(ensureFfmpegInstalledAsync, () => {
.mockReturnValueOnce(spawnResolved()); // apt-get install
const logger = createLoggerMock();
await ensureFfmpegInstalledAsync({
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
logger,
@@ -886,7 +968,7 @@ describe(ensureFfmpegInstalledAsync, () => {
const logger = createLoggerMock();
await expect(
ensureFfmpegInstalledAsync({
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger,
@@ -907,7 +989,7 @@ describe(ensureFfmpegInstalledAsync, () => {
const logger = createLoggerMock();
await expect(
ensureFfmpegInstalledAsync({
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger,
@@ -370,6 +370,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
mobileSimctl: 'Ran a simctl command',
mobileSiriCommand: 'Sent a Siri command',
mobileStartActivity: 'Started an activity',
mobileStartAutomationSession: 'Started an automation session',
mobileStartLogsBroadcast: 'Started log streaming',
mobileStartMediaProjectionRecording: 'Started media projection recording',
mobileStartNetworkMonitor: 'Started network monitoring',
@@ -379,6 +380,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
mobileStartService: 'Started a service',
mobileStartSystemMonitor: 'Started system monitoring',
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
mobileStopAutomationSession: 'Stopped an automation session',
mobileStopLogsBroadcast: 'Stopped log streaming',
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
mobileStopNetworkMonitor: 'Stopped network monitoring',
@@ -400,6 +402,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
mobileUnscheduleAction: 'Canceled a scheduled action',
mobileUpdateSafariPreferences: 'Updated Safari preferences',
mobileViewPortRect: 'Read the viewport size',
mobileViewportElementRect: 'Read an element position and size in the viewport',
mobileViewportScreenshot: 'Took a viewport screenshot',
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
@@ -234,12 +234,14 @@ export type XCUITestAppiumCommand =
| 'mobileShake'
| 'mobileSimctl'
| 'mobileSiriCommand'
| 'mobileStartAutomationSession'
| 'mobileStartLogsBroadcast'
| 'mobileStartNetworkMonitor'
| 'mobileStartPerfRecord'
| 'mobileStartScreenRecording'
| 'mobileStartSystemMonitor'
| 'mobileStartXctestScreenRecording'
| 'mobileStopAutomationSession'
| 'mobileStopLogsBroadcast'
| 'mobileStopNetworkMonitor'
| 'mobileStopPerfRecord'
@@ -374,6 +376,7 @@ export type UiAutomator2AppiumCommand =
| 'mobileUnlock'
| 'mobileUnscheduleAction'
| 'mobileViewPortRect'
| 'mobileViewportElementRect'
| 'mobileViewportScreenshot'
| 'networkSpeed'
| 'openNotifications'
@@ -26,7 +26,7 @@ const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
const SERVE_SIM_VIDEO_FPS = '60';
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '1280';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
@@ -297,10 +297,9 @@ async function sleepUntilAbortedAsync(
}
}
// Argent encodes screen recordings by piping simulator frames into `ffmpeg`,
// which it resolves from PATH. The tool-server inherits this step's env, so
// spawning ffmpeg resolves against the same PATH argent will search: it rejects
// with ENOENT when the binary is absent, and running it also proves it works.
// Device-session tools resolve `ffmpeg` from PATH. Spawning it with the step's
// environment rejects with ENOENT when the binary is absent, and running it also
// proves that the installed binary works.
async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise<boolean> {
return (await asyncResult(spawn('ffmpeg', ['-version'], { env }))).ok;
}
@@ -333,14 +332,15 @@ async function installFfmpegWithAptAsync({
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
}
let ffmpegSetupPromise: Promise<void> | undefined;
/**
* Install ffmpeg when the runtime does not already provide it, so argent's
* `screen-recording-start` tool can encode a video. The worker images do not
* ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found
* on PATH" — on macOS (iOS simulators) and Linux (Android emulators) alike.
* Install ffmpeg when the runtime does not already provide it. Device-session
* tools use it for video encoding on macOS (iOS simulators) and Linux (Android
* emulators) alike, but the worker images do not ship it yet.
*
* Best-effort by design: screen recording is one optional argent tool, so a
* failure here is logged and the session continues without it.
* Best-effort by design: a failure here is logged and the session continues
* without FFmpeg-dependent features.
*
* The whole body is wrapped because the caller runs this in the background with
* `void`. There is no unhandledRejection handler in the worker, so a rejection
@@ -348,7 +348,7 @@ async function installFfmpegWithAptAsync({
* `spawn` is not an async function and can throw synchronously, which
* `asyncResult` cannot catch — it only wraps an already-created promise.
*/
export async function ensureFfmpegInstalledAsync({
async function ensureFfmpegInstalledAsync({
runtimePlatform,
env,
logger,
@@ -367,7 +367,7 @@ export async function ensureFfmpegInstalledAsync({
logger.info(
`ffmpeg is not installed, installing it with ${
isDarwin ? 'Homebrew' : 'apt'
} for argent screen recording.`
} for the device session.`
);
if (isDarwin) {
await installFfmpegWithHomebrewAsync({ env, logger });
@@ -377,16 +377,41 @@ export async function ensureFfmpegInstalledAsync({
logger.info('Installed ffmpeg.');
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not install ffmpeg for argent screen recording', error, {
Sentry.capture('Could not install ffmpeg for the device session', error, {
level: 'warning',
});
logger.warn(
{ err: error },
'Could not install ffmpeg. Argent screen recording will not work in this session.'
'Could not install ffmpeg. FFmpeg-dependent features may not work in this session.'
);
}
}
export async function ensureFfmpegInstalledOnceAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if (ffmpegSetupPromise) {
await ffmpegSetupPromise;
return;
}
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
ffmpegSetupPromise = setupPromise;
try {
await setupPromise;
} finally {
if (ffmpegSetupPromise === setupPromise) {
ffmpegSetupPromise = undefined;
}
}
}
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
@@ -838,12 +863,14 @@ export async function startServeSimWithTunnelAsync(
export async function startExpoDeviceHubWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
baseDomain,
env,
logger,
timeoutMs,
packageVersion,
}: {
runtimePlatform: BuildRuntimePlatform;
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
@@ -851,6 +878,9 @@ export async function startExpoDeviceHubWithTunnelAsync(
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
}
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
@@ -880,7 +910,7 @@ export async function startDeviceWebPreviewWithTunnelAsync(
case BuildRuntimePlatform.DARWIN:
return await startServeSimWithTunnelAsync(ctx, options);
case BuildRuntimePlatform.LINUX:
return await startExpoDeviceHubWithTunnelAsync(ctx, options);
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
}
}
@@ -99,12 +99,18 @@ export namespace AndroidEmulatorUtils {
deviceName,
systemImagePackage,
deviceIdentifier,
lcdWidth,
lcdHeight,
lcdDensity,
env,
logger,
}: {
deviceName: AndroidVirtualDeviceName;
systemImagePackage: string;
deviceIdentifier: AndroidDeviceName | null;
lcdWidth: number | null;
lcdHeight: number | null;
lcdDensity: number | null;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
@@ -176,6 +182,13 @@ export namespace AndroidEmulatorUtils {
}
}
if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) {
logger.info(
`Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.`
);
configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`;
}
const shouldAdjustHeapSize =
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0';
@@ -71,6 +71,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
@@ -95,6 +98,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
@@ -158,6 +164,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
@@ -209,6 +218,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: envForAttempt,
logger: createMockLogger({ logToConsole: true }),
});
@@ -290,6 +302,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger,
});
@@ -361,6 +376,9 @@ describe('AndroidEmulatorUtils', () => {
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
@@ -8,7 +8,11 @@ import path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
import {
AndroidDeviceName,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
@@ -48,6 +52,120 @@ describe('AndroidEmulatorUtils', () => {
);
});
describe(AndroidEmulatorUtils.createAsync, () => {
it('applies an explicit LCD configuration after screen scaling', async () => {
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(
`${avdDirectory}/config.ini`,
[
'hw.ramSize=1536',
'hw.lcd.height=2424',
'hw.lcd.width=1080',
'hw.lcd.density=420',
'skin.path=pixel_9',
'showDeviceFrame=yes',
].join('\n')
);
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
const write = jest.fn();
const end = jest.fn();
avdManagerPromise.child = { stdin: { write, end } };
mockedSpawn.mockReturnValue(avdManagerPromise);
const logger = createMockLogger();
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: 'system-images;android-35;default;x86_64',
deviceIdentifier: 'medium_phone' as AndroidDeviceName,
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 262,
env: {
HOME: '/home/expo',
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
},
logger,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'avdmanager',
[
'create',
'avd',
'--name',
deviceName,
'--package',
'system-images;android-35;default;x86_64',
'--force',
'--device',
'medium_phone',
],
{ env: expect.any(Object), stdio: 'pipe' }
);
expect(write).toHaveBeenCalledWith('no');
expect(end).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
'Setting scaled screen resolution: hw.lcd.height to 1270 and hw.lcd.width to 566.'
);
expect(logger.info).toHaveBeenCalledWith(
'Setting screen resolution to 720x1600 and density to 262 ppi.'
);
await expect(fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8')).resolves.toBe(
[
'hw.ramSize=1536',
'hw.lcd.height=2424',
'hw.lcd.width=1080',
'hw.lcd.density=420',
'skin.path=pixel_9',
'showDeviceFrame=yes',
'hw.ramSize=2048',
'',
'hw.lcd.density=220',
'',
'hw.lcd.height=1270',
'hw.lcd.width=566',
'',
'hw.lcd.height=1600',
'hw.lcd.width=720',
'hw.lcd.density=262',
'',
].join('\n')
);
});
it('does not apply an LCD configuration when it is omitted', async () => {
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
avdManagerPromise.child = { stdin: { write: jest.fn(), end: jest.fn() } };
mockedSpawn.mockReturnValue(avdManagerPromise);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
lcdWidth: null,
lcdHeight: null,
lcdDensity: null,
env: {
HOME: '/home/expo',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
},
logger: createMockLogger(),
});
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
expect(config).toContain(initialConfig);
expect(config).not.toContain('hw.lcd.');
});
});
describe(AndroidEmulatorUtils.startAsync, () => {
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
@@ -0,0 +1,51 @@
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
import { withLogPhaseAsync } from '../logPhase';
describe(withLogPhaseAsync.name, () => {
const phaseLogger = { info: jest.fn() };
const logger = { child: jest.fn(() => phaseLogger) };
beforeEach(() => {
jest.clearAllMocks();
});
it('returns the callback result and ends the phase successfully', async () => {
const result = await withLogPhaseAsync(logger as any, 'Phase name', async callbackLogger => {
expect(callbackLogger).toBe(phaseLogger);
return 'result';
});
expect(result).toBe('result');
expect(logger.child).toHaveBeenCalledWith({
phase: BuildPhase.CUSTOM,
buildStepId: expect.stringMatching(/^step-\d{3,}$/),
buildStepDisplayName: 'Phase name',
});
expect(phaseLogger.info).toHaveBeenNthCalledWith(
1,
{ marker: LogMarker.START_PHASE },
'Start phase: Phase name'
);
expect(phaseLogger.info).toHaveBeenNthCalledWith(
2,
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
'End phase: Phase name'
);
});
it('ends the phase with failure and rethrows the error', async () => {
const error = new Error('failed');
await expect(
withLogPhaseAsync(logger as any, 'Phase name', async () => {
throw error;
})
).rejects.toBe(error);
expect(phaseLogger.info).toHaveBeenLastCalledWith(
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
'End phase: Phase name'
);
});
});
@@ -0,0 +1,30 @@
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { BuildStep } from '@expo/steps';
export async function withLogPhaseAsync<T>(
logger: bunyan,
name: string,
fn: (logger: bunyan) => Promise<T>
): Promise<T> {
const phaseLogger = logger.child({
phase: BuildPhase.CUSTOM,
buildStepId: BuildStep.getNewId(),
buildStepDisplayName: name,
});
phaseLogger.info({ marker: LogMarker.START_PHASE }, `Start phase: ${name}`);
try {
const result = await fn(phaseLogger);
phaseLogger.info(
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
`End phase: ${name}`
);
return result;
} catch (error) {
phaseLogger.info(
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
`End phase: ${name}`
);
throw error;
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "23.1.0",
"version": "24.0.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "23.1.0"
"@expo/steps": "24.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "23.0.0",
"version": "24.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "23.0.0",
"version": "24.0.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "23.1.0",
"version": "24.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "23.1.0",
"@expo/logger": "24.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "23.1.0",
"@expo/turtle-spawn": "24.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+380 -205
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -13,6 +13,7 @@ const config: CodegenConfig = {
'src/graphql/generated.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
addUnderscoreToArgsType: true,
dedupeOperationSuffix: true,
},
hooks: {
+8035 -1743
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "23.2.0",
"version": "24.0.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -44,15 +44,15 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.22",
"@expo/apple-utils": "2.2.0",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "23.1.0",
"@expo/eas-json": "23.1.0",
"@expo/eas-build-job": "24.0.0",
"@expo/eas-json": "24.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "23.1.0",
"@expo/logger": "24.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "23.1.0",
"@expo/steps": "24.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -1,4 +1,4 @@
import { getProjectDashboardUrl, getProjectPageUrl } from '../url';
import { getProjectDashboardUrl, getProjectPageUrl, getSubmissionUrl } from '../url';
describe(getProjectPageUrl, () => {
it('builds the project dashboard URL when no page is provided', () => {
@@ -33,3 +33,11 @@ describe(getProjectDashboardUrl, () => {
);
});
});
describe(getSubmissionUrl, () => {
it('builds an encoded submission URL', () => {
expect(getSubmissionUrl('my org', 'my app', 'submission/id')).toBe(
'https://expo.dev/accounts/my%20org/projects/my%20app/submissions/submission%2Fid'
);
});
});
+13
View File
@@ -56,6 +56,19 @@ export function getUpdateGroupUrl(
).toString();
}
export function getSubmissionUrl(
accountName: string,
projectName: string,
submissionId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/submissions/${encodeURIComponent(submissionId)}`,
getExpoWebsiteBaseUrl()
).toString();
}
export function getWorkflowRunUrl(
accountName: string,
projectName: string,
@@ -133,6 +133,7 @@ export const testChannelObject: UpdateChannelObject = {
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
updateBranches: [testUpdateBranch1, testUpdateBranch2],
isPaused: false,
isProtected: false,
__typename: 'UpdateChannel',
};
@@ -141,6 +142,7 @@ export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
isProtected: false,
__typename: 'UpdateChannel',
};
@@ -149,5 +151,6 @@ export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
name: 'staging',
branchMapping:
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
isProtected: false,
__typename: 'UpdateChannel',
};
@@ -0,0 +1,74 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { protectUpdateChannelAsync, unprotectUpdateChannelAsync } from '../protection';
function makeGraphqlClient(data: unknown): {
graphqlClient: ExpoGraphqlClient;
mutation: jest.Mock;
} {
const mutation = jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
});
return { graphqlClient: { mutation } as unknown as ExpoGraphqlClient, mutation };
}
describe(protectUpdateChannelAsync.name, () => {
it('protects a channel by ID and returns the server state', async () => {
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: true,
};
const { graphqlClient, mutation } = makeGraphqlClient({
updateChannel: { protectUpdateChannel: channel },
});
await expect(
protectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' })
).resolves.toEqual(channel);
expect(mutation.mock.calls[0][0].loc.source.body).toContain('protectUpdateChannel');
expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' });
});
it('throws a clear error when the channel is not returned', async () => {
const { graphqlClient } = makeGraphqlClient({
updateChannel: { protectUpdateChannel: null },
});
await expect(
protectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' })
).rejects.toThrow('Could not find a channel with id: missing-channel-id');
});
});
describe(unprotectUpdateChannelAsync.name, () => {
it('unprotects a channel by ID and returns the server state', async () => {
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: false,
};
const { graphqlClient, mutation } = makeGraphqlClient({
updateChannel: { unprotectUpdateChannel: channel },
});
await expect(
unprotectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' })
).resolves.toEqual(channel);
expect(mutation.mock.calls[0][0].loc.source.body).toContain('unprotectUpdateChannel');
expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' });
});
it('throws a clear error when the channel is not returned', async () => {
const { graphqlClient } = makeGraphqlClient({
updateChannel: { unprotectUpdateChannel: null },
});
await expect(
unprotectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' })
).rejects.toThrow('Could not find a channel with id: missing-channel-id');
});
});
@@ -0,0 +1,26 @@
import { renderChannelHeaderContent } from '../queries';
import Log from '../../log';
jest.mock('../../log');
describe(renderChannelHeaderContent.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([
[true, 'Protected'],
[false, 'Unprotected'],
])('renders protection state when isProtected is %s', (isProtected, expected) => {
renderChannelHeaderContent({
channelName: 'production',
channelId: 'channel-id',
isPaused: false,
isProtected,
});
const output = jest.mocked(Log.log).mock.calls.flat().join('\n');
expect(output).toContain('Protection');
expect(output).toContain(expected);
});
});
@@ -0,0 +1,71 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
ProtectUpdateChannelMutation,
ProtectUpdateChannelMutationVariables,
UnprotectUpdateChannelMutation,
UnprotectUpdateChannelMutationVariables,
UpdateChannelBasicInfoFragment,
} from '../graphql/generated';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
export async function protectUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: ProtectUpdateChannelMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<ProtectUpdateChannelMutation, ProtectUpdateChannelMutationVariables>(
gql`
mutation ProtectUpdateChannel($channelId: ID!) {
updateChannel {
protectUpdateChannel(channelId: $channelId) {
id
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId }
)
.toPromise()
);
const channel = data.updateChannel.protectUpdateChannel;
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
}
export async function unprotectUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: UnprotectUpdateChannelMutationVariables
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UnprotectUpdateChannelMutation, UnprotectUpdateChannelMutationVariables>(
gql`
mutation UnprotectUpdateChannel($channelId: ID!) {
updateChannel {
unprotectUpdateChannel(channelId: $channelId) {
id
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId }
)
.toPromise()
);
const channel = data.updateChannel.unprotectUpdateChannel;
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return channel;
}
+6 -1
View File
@@ -125,6 +125,7 @@ export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
isProtected: channel.isProtected,
});
if (paginatedQueryOptions.nonInteractive) {
@@ -186,6 +187,7 @@ function renderPageOfChannels(
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
isProtected: channel.isProtected,
});
Log.addNewLineIfNone();
logChannelDetails(channel);
@@ -212,14 +214,16 @@ function renderPageOfBranchesOnChannel(
}
}
function renderChannelHeaderContent({
export function renderChannelHeaderContent({
channelName,
channelId,
isPaused,
isProtected,
}: {
channelName: string;
channelId: string;
isPaused: boolean;
isProtected: boolean;
}): void {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:'));
@@ -228,6 +232,7 @@ function renderChannelHeaderContent({
{ label: 'Name', value: channelName },
{ label: 'ID', value: channelId },
{ label: 'Status', value: isPaused ? 'Paused' : 'Active' },
{ label: 'Protection', value: isProtected ? 'Protected' : 'Unprotected' },
])
);
Log.addNewLineIfNone();
@@ -1,241 +0,0 @@
import nock from 'nock';
import { makeUserMessage, streamChatResponseAsync } from '../chatClient';
const mockSpinners: { isSpinning: boolean; text: string }[] = [];
jest.mock('../../log');
jest.mock('../../ora', () => ({
ora: () => {
const spinner = {
isSpinning: true,
text: '',
start: () => spinner,
stop: () => {
spinner.isSpinning = false;
return spinner;
},
};
mockSpinners.push(spinner);
return spinner;
},
}));
const WEBSITE_ORIGIN = 'https://expo.dev';
function sseBody(frames: object[]): string {
return [...frames.map(frame => `data: ${JSON.stringify(frame)}`), 'data: [DONE]', ''].join(
'\n\n'
);
}
describe(streamChatResponseAsync, () => {
let writeSpy: jest.SpyInstance;
beforeEach(() => {
nock.cleanAll();
mockSpinners.length = 0;
writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
});
afterEach(() => {
writeSpy.mockRestore();
});
it('streams text deltas to stdout and returns the full text', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'start' },
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'Your latest ' },
{ type: 'text-delta', id: '0', delta: 'build passed.' },
{ type: 'finish' },
]),
{ 'content-type': 'text/event-stream' }
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('how are my builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"session-id","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
expect(written).toContain('Your latest ');
expect(written).toContain('build passed.');
expect(result.text).toBe('Your latest build passed.');
});
it('neutralizes terminal control characters in streamed text deltas', async () => {
const delta = 'Safe \u001b[2Jtext\u009b31m\u0007.';
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(200, sseBody([{ type: 'text-delta', id: '0', delta }]), {
'content-type': 'text/event-stream',
});
const result = await streamChatResponseAsync({
messages: [makeUserMessage('show the build log')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
expect(written).toContain('Safe [2Jtext31m.');
expect(written).not.toContain('\u001b[2J');
expect(written).not.toContain('\u009b31m');
expect(written).not.toContain('\u0007');
expect(result.text).toBe(delta);
});
it('keeps the spinner active until a full line is ready instead of clearing it on the first delta', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'Looking good' },
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
{ type: 'text-delta', id: '0', delta: ' so far.' },
{ type: 'finish' },
]),
{ 'content-type': 'text/event-stream' }
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('how are my builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
// No newline arrives until the end, so nothing is flushed mid-stream: the tool activity
// is shown on the still-running spinner rather than being printed inline.
expect(mockSpinners[0].text).toContain('Looking up builds');
expect(written).not.toContain('Looking up builds');
// The buffered text is rendered in one go once the response completes.
expect(written).toContain('Looking good so far.');
expect(result.text).toBe('Looking good so far.');
});
it('captures tool calls with inputs and outputs without writing to stdout when not streaming', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'start-step' },
{
type: 'tool-input-available',
toolCallId: 't1',
toolName: 'get_latest_builds',
input: { limit: 1 },
},
{ type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } },
{ type: 'finish-step' },
{ type: 'start-step' },
{ type: 'text-delta', delta: 'Done.' },
{ type: 'finish-step' },
{ type: 'finish' },
])
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
});
expect(writeSpy).not.toHaveBeenCalled();
expect(result.text).toBe('Done.');
expect(result.toolCalls).toEqual([
{
toolName: 'get_latest_builds',
input: { limit: 1 },
output: { builds: ['b1'] },
errorText: undefined,
},
]);
expect(result.assistantMessage).toEqual(
expect.objectContaining({
role: 'assistant',
parts: [
{ type: 'step-start' },
{
type: 'tool-get_latest_builds',
toolCallId: 't1',
state: 'output-available',
input: { limit: 1 },
output: { builds: ['b1'] },
},
{ type: 'step-start' },
{ type: 'text', text: 'Done.' },
],
})
);
});
it('sends the session secret as a cookie and the account as a header', async () => {
let capturedHeaders: Record<string, string | string[]> = {};
let capturedBody: any;
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(function (_uri, body) {
capturedHeaders = this.req.headers;
capturedBody = body;
return [200, sseBody([{ type: 'text-delta', delta: 'hi' }])];
});
await streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
});
const cookie = [capturedHeaders.cookie].flat().join('; ');
expect(cookie).toContain('io.expo.auth.sessionSecret=');
expect(cookie).toContain(encodeURIComponent('{"id":"abc","version":"1"}'));
expect([capturedHeaders['x-account-name']].flat()[0]).toBe('my-account');
expect(capturedBody.messages[0].role).toBe('system');
expect(capturedBody.messages[0].parts[0].text).toContain('https://expo.dev');
expect(capturedBody.messages[1]).toEqual(
expect.objectContaining({ role: 'user', parts: [{ type: 'text', text: 'hello' }] })
);
});
it('maps a 429 response to a usage-limit error', async () => {
nock(WEBSITE_ORIGIN).post('/api/chat').reply(429, 'Chat usage limit reached');
await expect(
streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
})
).rejects.toThrow(/usage limit/);
});
it('maps a 403 response to a not-enabled error', async () => {
nock(WEBSITE_ORIGIN).post('/api/chat').reply(403, 'Forbidden');
await expect(
streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
})
).rejects.toThrow(/not enabled/);
});
});
@@ -1,77 +0,0 @@
import chalk from 'chalk';
import {
createMarkdownRenderState,
renderInlineMarkdown,
renderMarkdownLine,
wrapToWidth,
} from '../renderMarkdown';
describe(renderInlineMarkdown, () => {
it('renders bold with chalk.bold', () => {
expect(renderInlineMarkdown('a **bold** word')).toBe(`a ${chalk.bold('bold')} word`);
expect(renderInlineMarkdown('a __bold__ word')).toBe(`a ${chalk.bold('bold')} word`);
});
it('renders inline code with chalk.cyan', () => {
expect(renderInlineMarkdown('run `eas build` now')).toBe(`run ${chalk.cyan('eas build')} now`);
});
it('leaves underscores inside a word untouched', () => {
expect(renderInlineMarkdown('my_project_name')).toBe('my_project_name');
});
it('renders links as underlined label with dimmed url', () => {
expect(renderInlineMarkdown('see [build](https://expo.dev/b/1)')).toBe(
`see ${chalk.underline('build')} ${chalk.dim('(https://expo.dev/b/1)')}`
);
});
});
describe(renderMarkdownLine, () => {
it('renders bullet lines with a bullet glyph', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('- first', state)).toBe(`${chalk.dim('•')} first`);
});
it('drops code fences and dims code block contents', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('```ts', state)).toBeNull();
expect(state.inCodeBlock).toBe(true);
expect(renderMarkdownLine('const x = 1;', state)).toBe(chalk.gray('const x = 1;'));
expect(renderMarkdownLine('```', state)).toBeNull();
expect(state.inCodeBlock).toBe(false);
});
it('applies inline styling to plain lines', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('the **latest** build', state)).toBe(
`the ${chalk.bold('latest')} build`
);
});
});
describe(wrapToWidth, () => {
it('word-wraps to the given width', () => {
const segments = wrapToWidth('one two three four five', 12);
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
expect(segment.length).toBeLessThanOrEqual(12);
}
expect(segments.join(' ').replace(/\s+/g, ' ').trim()).toBe('one two three four five');
});
it('hard-breaks long unbreakable tokens like URLs', () => {
const url = 'https://expo.dev/accounts/acme/projects/mobile/builds/cb7c9bdd-56e6-42b8';
const segments = wrapToWidth(url, 20);
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
expect(segment.length).toBeLessThanOrEqual(20);
}
expect(segments.join('')).toBe(url);
});
it('returns the text unchanged when the width is too small to wrap into', () => {
expect(wrapToWidth('anything at all here', 4)).toEqual(['anything at all here']);
});
});
@@ -1,51 +0,0 @@
import { PassThrough } from 'node:stream';
import { createChatReplInput } from '../replInput';
describe(createChatReplInput, () => {
it('resolves the typed line', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
const pending = replInput.askAsync('> ');
input.write('how are my builds?\n');
expect(await pending).toBe('how are my builds?');
replInput.close();
});
it('reads input even when the stream was left paused (e.g. by a spinner)', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
input.pause();
const pending = replInput.askAsync('> ');
input.write('hello\n');
expect(await pending).toBe('hello');
replInput.close();
});
it('resolves null when the input stream ends', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
const pending = replInput.askAsync('> ');
input.end();
expect(await pending).toBeNull();
replInput.close();
});
it('resolves null immediately once closed', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
replInput.close();
expect(await replInput.askAsync('> ')).toBeNull();
});
});
-449
View File
@@ -1,449 +0,0 @@
import { StringDecoder } from 'node:string_decoder';
import chalk from 'chalk';
import { v4 as uuidv4 } from 'uuid';
import { getExpoWebsiteBaseUrl } from '../api';
import fetch, { RequestError } from '../fetch';
import Log from '../log';
import { ora } from '../ora';
import {
MarkdownRenderState,
createMarkdownRenderState,
renderMarkdownLine,
wrapToWidth,
} from './renderMarkdown';
const CHAT_SYSTEM_GUIDANCE = [
'You are being used through the EAS CLI in a terminal, not the web dashboard.',
'The user cannot see the interactive tables the dashboard renders from tool results, so include the important details directly in your text answer instead of assuming a table is shown.',
'Whenever you reference a specific artifact (a build, update, submission, deployment, workflow run, channel, or branch), include its direct https://expo.dev link so the user can open it.',
'Use only simple markdown: bold, inline code, and bullet or numbered lists.',
].join(' ');
function getWebsiteAuthCookieName(): string {
if (process.env.EXPO_STAGING) {
return 'staging.expo.auth.sessionSecret';
} else if (process.env.EXPO_LOCAL) {
return 'local.expo.auth.sessionSecret';
} else {
return 'io.expo.auth.sessionSecret';
}
}
const TOOL_LABELS: Record<string, string> = {
get_projects: 'Looking up your projects',
get_latest_builds: 'Looking up builds',
get_build: 'Looking up a build',
get_build_logs: 'Reading build logs',
get_activity: 'Looking up recent activity',
get_updates: 'Looking up updates',
get_workflow_runs: 'Looking up workflow runs',
get_workflow_run: 'Looking up a workflow run',
get_workflow_run_logs: 'Reading workflow logs',
get_submissions: 'Looking up submissions',
get_channels: 'Looking up channels',
get_branches: 'Looking up branches',
get_worker_deployments: 'Looking up deployments',
get_environment_variables: 'Looking up environment variables',
get_account_members: 'Looking up account members',
get_usage: 'Looking up usage',
get_usage_history: 'Looking up usage history',
get_invoices: 'Looking up invoices',
search_expo_docs: 'Searching Expo docs',
navigate: 'Finding the right page',
};
function describeTool(toolName: string): string {
return TOOL_LABELS[toolName] ?? `Using ${toolName.replace(/_/g, ' ')}`;
}
const ASSISTANT_LABEL_TEXT = 'Expo';
const ASSISTANT_LABEL = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' > ')}`;
const ASSISTANT_INDENT = ' '.repeat(`${ASSISTANT_LABEL_TEXT} > `.length);
const ASSISTANT_SPINNER_PREFIX = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' >')}`;
type UIMessageStreamFrame = {
type: string;
id?: string;
delta?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
output?: unknown;
errorText?: string;
};
type ChatMessagePart =
| { type: 'text'; text: string }
| { type: 'step-start' }
| {
type: `tool-${string}`;
toolCallId: string;
state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
input: unknown;
output?: unknown;
errorText?: string;
};
export type ChatMessage = {
id: string;
role: 'system' | 'user' | 'assistant';
parts: ChatMessagePart[];
};
function makeSystemMessage(text: string): ChatMessage {
return { id: uuidv4(), role: 'system', parts: [{ type: 'text', text }] };
}
export function makeUserMessage(text: string): ChatMessage {
return { id: uuidv4(), role: 'user', parts: [{ type: 'text', text }] };
}
function makeAssistantMessage(parts: ChatMessagePart[]): ChatMessage {
return { id: uuidv4(), role: 'assistant', parts };
}
export type ChatToolCall = {
toolName: string;
input?: unknown;
output?: unknown;
errorText?: string;
};
export type ChatResult = {
text: string;
toolCalls: ChatToolCall[];
assistantMessage: ChatMessage;
};
export async function streamChatResponseAsync({
messages,
accountName,
sessionSecret,
stream,
}: {
messages: ChatMessage[];
accountName: string | undefined;
sessionSecret: string;
stream: boolean;
}): Promise<ChatResult> {
let response;
try {
response = await fetch(`${getExpoWebsiteBaseUrl()}/api/chat`, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'text/event-stream',
cookie: `${getWebsiteAuthCookieName()}=${encodeURIComponent(sessionSecret)}`,
...(accountName ? { 'x-account-name': accountName } : {}),
},
body: JSON.stringify({ messages: [makeSystemMessage(CHAT_SYSTEM_GUIDANCE), ...messages] }),
});
} catch (error) {
throw toFriendlyChatError(error);
}
if (!response.body) {
throw new Error('The chat service returned an empty response.');
}
const spinner = stream
? ora({ text: 'Thinking…', discardStdin: false, prefixText: ASSISTANT_SPINNER_PREFIX }).start()
: undefined;
let spinnerStopped = false;
const stopSpinner = (): void => {
if (!spinner || spinnerStopped) {
return;
}
spinnerStopped = true;
spinner.stop();
if (process.stdout.isTTY) {
process.stdout.write('\r');
}
};
const toolMessagePartsById = new Map<string, Extract<ChatMessagePart, { toolCallId: string }>>();
const textMessagePartsById = new Map<string, Extract<ChatMessagePart, { type: 'text' }>>();
const assistantMessageParts: ChatMessagePart[] = [];
const announcedTools = new Set<string>();
const markdownState: MarkdownRenderState = createMarkdownRenderState();
let fullText = '';
let streamingText = false;
let errorText: string | undefined;
let buffer = '';
let displayBuffer = '';
let wroteAssistantLine = false;
let currentTextMessagePart: Extract<ChatMessagePart, { type: 'text' }> | undefined;
const getOrCreateToolMessagePart = (
toolCallId: string,
toolName: string
): Extract<ChatMessagePart, { toolCallId: string }> => {
const existing = toolMessagePartsById.get(toolCallId);
if (existing) {
return existing;
}
const part: Extract<ChatMessagePart, { toolCallId: string }> = {
type: `tool-${toolName}`,
toolCallId,
state: 'input-streaming',
input: undefined,
};
toolMessagePartsById.set(toolCallId, part);
assistantMessageParts.push(part);
return part;
};
const getOrCreateTextMessagePart = (
id: string | undefined
): Extract<ChatMessagePart, { type: 'text' }> => {
const existing = id ? textMessagePartsById.get(id) : currentTextMessagePart;
if (existing) {
return existing;
}
const part: Extract<ChatMessagePart, { type: 'text' }> = { type: 'text', text: '' };
if (id) {
textMessagePartsById.set(id, part);
}
currentTextMessagePart = part;
assistantMessageParts.push(part);
return part;
};
const writeAssistantLine = (rendered: string, withNewline: boolean): void => {
if (!streamingText) {
stopSpinner();
streamingText = true;
}
const columns = process.stdout.columns ?? 0;
const width = columns > 0 ? columns - ASSISTANT_INDENT.length : 0;
const segments = wrapToWidth(rendered, width);
segments.forEach((segment, index) => {
const prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL;
wroteAssistantLine = true;
const isLastSegment = index === segments.length - 1;
const needsNewline = !isLastSegment || withNewline;
process.stdout.write(`${prefix}${segment}${needsNewline ? '\n' : ''}`);
});
};
const flushDisplayLines = (flushRemainder: boolean): void => {
let newlineIndex: number;
while ((newlineIndex = displayBuffer.indexOf('\n')) !== -1) {
const rawLine = displayBuffer.slice(0, newlineIndex);
displayBuffer = displayBuffer.slice(newlineIndex + 1);
const rendered = renderMarkdownLine(rawLine, markdownState);
if (rendered !== null) {
writeAssistantLine(rendered, true);
}
}
if (flushRemainder && displayBuffer.length > 0) {
const rendered = renderMarkdownLine(displayBuffer, markdownState);
displayBuffer = '';
if (rendered !== null) {
writeAssistantLine(rendered, false);
}
}
};
const announceTool = (toolName: string): void => {
if (!stream || announcedTools.has(toolName)) {
return;
}
announcedTools.add(toolName);
const label = describeTool(toolName);
if (streamingText) {
flushDisplayLines(true);
process.stdout.write(chalk.dim(`\n${ASSISTANT_INDENT}(${label})\n`));
} else if (spinner) {
spinner.text = `${label}…`;
}
};
const handleFrame = (frame: UIMessageStreamFrame): void => {
switch (frame.type) {
case 'start-step': {
assistantMessageParts.push({ type: 'step-start' });
currentTextMessagePart = undefined;
break;
}
case 'text-start': {
currentTextMessagePart = getOrCreateTextMessagePart(frame.id);
break;
}
case 'text-delta': {
const delta = typeof frame.delta === 'string' ? frame.delta : '';
if (!delta) {
return;
}
fullText += delta;
getOrCreateTextMessagePart(frame.id).text += delta;
if (stream) {
displayBuffer += delta;
flushDisplayLines(false);
}
break;
}
case 'text-end': {
currentTextMessagePart = undefined;
break;
}
case 'tool-input-start':
case 'tool-input-available': {
const { toolCallId, toolName } = frame;
if (!toolCallId || !toolName) {
return;
}
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
if (frame.type === 'tool-input-available') {
toolPart.state = 'input-available';
toolPart.input = frame.input;
delete toolPart.output;
delete toolPart.errorText;
} else if (frame.input !== undefined) {
toolPart.input = frame.input;
}
announceTool(toolName);
break;
}
case 'tool-input-error': {
const { toolCallId, toolName } = frame;
if (!toolCallId || !toolName || typeof frame.errorText !== 'string') {
return;
}
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
toolPart.state = 'output-error';
toolPart.input = frame.input;
delete toolPart.output;
toolPart.errorText = frame.errorText;
announceTool(toolName);
break;
}
case 'tool-output-available': {
const { toolCallId } = frame;
if (!toolCallId) {
return;
}
const toolPart = toolMessagePartsById.get(toolCallId);
if (toolPart) {
toolPart.state = 'output-available';
toolPart.output = frame.output;
delete toolPart.errorText;
}
break;
}
case 'tool-output-error': {
const { toolCallId } = frame;
if (!toolCallId || typeof frame.errorText !== 'string') {
return;
}
const toolPart = toolMessagePartsById.get(toolCallId);
if (toolPart) {
toolPart.state = 'output-error';
delete toolPart.output;
toolPart.errorText = frame.errorText;
}
break;
}
case 'error': {
if (typeof frame.errorText === 'string') {
errorText = frame.errorText;
}
break;
}
}
};
const decoder = new StringDecoder('utf8');
try {
for await (const chunk of response.body) {
buffer += decoder.write(chunk as Buffer);
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line.startsWith('data:')) {
continue;
}
const data = line.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
continue;
}
let frame: UIMessageStreamFrame;
try {
frame = JSON.parse(data);
} catch {
continue;
}
handleFrame(frame);
}
}
} finally {
if (stream) {
flushDisplayLines(true);
}
stopSpinner();
}
if (errorText) {
throw new Error(errorText);
}
const toolCalls: ChatToolCall[] = assistantMessageParts
.filter((part): part is Extract<ChatMessagePart, { toolCallId: string }> =>
part.type.startsWith('tool-')
)
.map(part => ({
toolName: part.type.slice('tool-'.length),
input: part.input,
output: part.output,
errorText: part.errorText,
}));
const assistantMessage = makeAssistantMessage(
assistantMessageParts.filter(
part =>
part.type === 'text' ||
part.type === 'step-start' ||
part.state === 'output-available' ||
part.state === 'output-error'
)
);
if (stream) {
if (fullText.length > 0) {
Log.newLine();
} else {
Log.log(
chalk.dim(
toolCalls.length > 0
? 'The assistant looked up your data but did not return a text response. Try asking a more specific question.'
: 'The assistant did not return a response.'
)
);
}
}
return { text: fullText, toolCalls, assistantMessage };
}
function toFriendlyChatError(error: unknown): Error {
if (error instanceof RequestError) {
const status = error.response.status;
if (status === 401) {
return new Error('Your Expo session is not valid for chat. Run `eas login` and try again.');
}
if (status === 403) {
return new Error(
'Chat is not enabled for this account yet. It is still rolling out. If you think you should have access, reach out to Expo.'
);
}
if (status === 429) {
return new Error(
'You have reached your chat usage limit. Try again later or upgrade your plan for a larger allowance.'
);
}
return new Error(`The chat request failed (HTTP ${status}).`);
}
return error instanceof Error ? error : new Error(String(error));
}
@@ -1,43 +0,0 @@
import { getConfigFilePaths } from '@expo/config';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from '../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { AppQuery } from '../graphql/queries/AppQuery';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
export async function detectCurrentProjectAsync(
graphqlClient: ExpoGraphqlClient
): Promise<{ accountName: string; label: string } | null> {
let projectDir: string;
try {
projectDir = await findProjectDirAndVerifyProjectSetupAsync();
} catch {
return null;
}
if (!projectDir) {
return null;
}
const configPaths = getConfigFilePaths(projectDir);
if (!configPaths.staticConfigPath && !configPaths.dynamicConfigPath) {
return null;
}
let projectId: unknown;
try {
const expoConfig = await getPrivateExpoConfigAsync(projectDir);
projectId = expoConfig.extra?.eas?.projectId;
} catch {
return null;
}
if (typeof projectId !== 'string' || !projectId) {
return null;
}
try {
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
return { accountName: app.ownerAccount.name, label: app.fullName };
} catch {
return null;
}
}
@@ -1,53 +0,0 @@
import chalk from 'chalk';
import wrapAnsi from 'wrap-ansi';
export function wrapToWidth(text: string, width: number): string[] {
if (width < 10) {
return [text];
}
return wrapAnsi(text, width, { hard: true, trim: false }).split('\n');
}
export type MarkdownRenderState = { inCodeBlock: boolean };
export function createMarkdownRenderState(): MarkdownRenderState {
return { inCodeBlock: false };
}
const FENCE_REGEX = /^\s*```/;
const BULLET_REGEX = /^(\s*)[-*+]\s+(.*)$/;
const ORDERED_REGEX = /^(\s*)(\d+)\.\s+(.*)$/;
const TERMINAL_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
export function renderMarkdownLine(line: string, state: MarkdownRenderState): string | null {
const sanitizedLine = line.replace(TERMINAL_CONTROL_CHARACTERS_REGEX, '');
if (FENCE_REGEX.test(sanitizedLine)) {
state.inCodeBlock = !state.inCodeBlock;
return null;
}
if (state.inCodeBlock) {
return chalk.gray(sanitizedLine);
}
const bullet = sanitizedLine.match(BULLET_REGEX);
if (bullet) {
return `${bullet[1]}${chalk.dim('•')} ${renderInlineMarkdown(bullet[2])}`;
}
const ordered = sanitizedLine.match(ORDERED_REGEX);
if (ordered) {
return `${ordered[1]}${chalk.dim(`${ordered[2]}.`)} ${renderInlineMarkdown(ordered[3])}`;
}
return renderInlineMarkdown(sanitizedLine);
}
export function renderInlineMarkdown(text: string): string {
return text
.replace(/`([^`]+)`/g, (_match, code) => chalk.cyan(code))
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_match, label, url) => `${chalk.underline(label)} ${chalk.dim(`(${url})`)}`
)
.replace(/(\*\*|__)(.+?)\1/g, (_match, _marker, content) => chalk.bold(content));
}
-61
View File
@@ -1,61 +0,0 @@
import readline from 'node:readline';
export type ChatReplInput = {
askAsync(prompt: string): Promise<string | null>;
close(): void;
};
export function createChatReplInput(options?: {
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
terminal?: boolean;
history?: string[];
}): ChatReplInput {
const inputStream = options?.input ?? process.stdin;
const readlineInterface = readline.createInterface({
input: inputStream,
output: options?.output ?? process.stdout,
terminal: options?.terminal ?? true,
historySize: 100,
history: options?.history,
});
let closed = false;
readlineInterface.on('close', () => {
closed = true;
});
readlineInterface.on('SIGINT', () => {
readlineInterface.close();
});
return {
async askAsync(prompt: string): Promise<string | null> {
if (closed) {
return null;
}
inputStream.resume();
return await new Promise<string | null>(resolve => {
let settled = false;
const onClose = (): void => {
if (!settled) {
settled = true;
resolve(null);
}
};
readlineInterface.once('close', onClose);
readlineInterface.question(prompt, answer => {
if (!settled) {
settled = true;
readlineInterface.removeListener('close', onClose);
resolve(answer);
}
});
});
},
close(): void {
if (!closed) {
readlineInterface.close();
}
},
};
}
@@ -1,370 +0,0 @@
import { getMockOclifConfig } from '../../__tests__/commands/utils';
import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient';
import { detectCurrentProjectAsync } from '../../chat/detectProject';
import { ChatReplInput, createChatReplInput } from '../../chat/replInput';
import * as flagsModule from '../../commandUtils/flags';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import Chat from '../chat';
jest.mock('../../chat/chatClient', () => ({
...jest.requireActual('../../chat/chatClient'),
streamChatResponseAsync: jest.fn(),
}));
jest.mock('../../chat/replInput');
jest.mock('../../chat/detectProject');
jest.mock('../../log');
jest.mock('../../utils/json');
jest.mock('../../graphql/queries/AppQuery', () => ({
AppQuery: { byFullNameAsync: jest.fn() },
}));
const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync);
const mockCreateChatReplInput = jest.mocked(createChatReplInput);
const mockDetectCurrentProjectAsync = jest.mocked(detectCurrentProjectAsync);
const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeChatResult(text: string, toolCalls: ChatResult['toolCalls'] = []): ChatResult {
return {
text,
toolCalls,
assistantMessage: {
id: 'assistant-message',
role: 'assistant',
parts: [{ type: 'text', text }],
},
};
}
const emptyResult = makeChatResult('ok');
function mockReplInput(lines: (string | null)[]): { askAsync: jest.Mock; close: jest.Mock } {
const askAsync = jest.fn();
for (const line of lines) {
askAsync.mockResolvedValueOnce(line);
}
askAsync.mockResolvedValue(null);
const input = { askAsync, close: jest.fn() };
mockCreateChatReplInput.mockReturnValue(input as ChatReplInput);
return input;
}
function forceInteractive(): void {
jest
.spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags')
.mockReturnValue({ json: false, nonInteractive: false });
}
describe(Chat, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
mockStreamChatResponseAsync.mockResolvedValue(emptyResult);
mockDetectCurrentProjectAsync.mockResolvedValue(null);
});
function createCommand(
argv: string[],
{
sessionSecret = '{"id":"session-id","version":"1"}',
primaryAccountName = 'my-account',
}: { sessionSecret?: string | null; primaryAccountName?: string } = {}
): Chat {
const command = new Chat(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: {
actor: {
__typename: 'User',
id: 'user-id',
primaryAccount: { id: 'account-id', name: primaryAccountName },
accounts: [{ id: 'account-id', name: primaryAccountName }],
},
graphqlClient: {},
authenticationInfo: sessionSecret
? { accessToken: null, sessionSecret }
: { accessToken: 'token', sessionSecret: null },
},
});
return command;
}
it('sends the message for the primary account and does not prompt in non-interactive mode', async () => {
const command = createCommand(['how are my builds?', '--non-interactive']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('my-account');
expect(call.sessionSecret).toBe('{"id":"session-id","version":"1"}');
expect(call.stream).toBe(true);
expect(call.messages).toHaveLength(1);
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
});
it('prefers the --account flag over the primary account and skips project auto-detection', async () => {
const command = createCommand(['hi', '--account', 'other-account', '--non-interactive']);
await command.runAsync();
expect(mockStreamChatResponseAsync.mock.calls[0][0].accountName).toBe('other-account');
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
});
it('auto-scopes to the current directory project when no scope flags are given', async () => {
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
const command = createCommand(['is my build ok?', '--non-interactive']);
await command.runAsync();
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
it('does not auto-detect when --project is given', async () => {
mockAppByFullNameAsync.mockResolvedValue({
id: 'app1',
fullName: '@acme/mobile',
slug: 'mobile',
ownerAccount: { id: 'acc', name: 'acme' },
} as any);
const command = createCommand(['hi', '--project', 'acme/mobile', '--non-interactive']);
await command.runAsync();
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
});
it('resolves --project to its owner account and frames the message', async () => {
mockAppByFullNameAsync.mockResolvedValue({
id: 'app1',
fullName: '@acme/mobile',
slug: 'mobile',
ownerAccount: { id: 'acc', name: 'acme' },
} as any);
const command = createCommand([
'is my build ok?',
'--project',
'acme/mobile',
'--non-interactive',
]);
await command.runAsync();
expect(mockAppByFullNameAsync).toHaveBeenCalledWith(expect.anything(), '@acme/mobile');
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
it('throws a friendly error when --project cannot be found', async () => {
mockAppByFullNameAsync.mockRejectedValue(new Error('not found'));
const command = createCommand(['hi', '--project', 'acme/ghost', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(/@acme\/ghost was not found/);
});
it('continues the conversation with follow-up replies until the user types /exit', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['and my updates?', '/exit']);
const command = createCommand(['how are my builds?']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(3);
expect(secondCall.messages[1].role).toBe('assistant');
expect(secondCall.messages[2].role).toBe('user');
expect(secondCall.messages[2].parts[0]).toEqual({ type: 'text', text: 'and my updates?' });
expect(input.close).toHaveBeenCalled();
expect(mockCreateChatReplInput).toHaveBeenCalledWith(
expect.objectContaining({ history: ['how are my builds?'] })
);
});
it('retains tool results in the conversation history for follow-up questions', async () => {
forceInteractive();
const result = makeChatResult('The latest build passed.', [
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: ['b1'] } },
]);
result.assistantMessage.parts = [
{ type: 'step-start' },
{
type: 'tool-get_latest_builds',
toolCallId: 't1',
state: 'output-available',
input: { limit: 1 },
output: { builds: ['b1'] },
},
{ type: 'step-start' },
{ type: 'text', text: result.text },
];
mockStreamChatResponseAsync.mockResolvedValue(result);
mockReplInput(['what SDK did it use?', '/exit']);
const command = createCommand(['how are my builds?']);
await command.runAsync();
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages[1]).toEqual(result.assistantMessage);
});
it('exits when the input stream closes (Ctrl-D)', async () => {
forceInteractive();
mockReplInput([null]);
const command = createCommand(['how are my builds?']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
});
it('keeps the interactive session alive when a request fails and re-prompts', async () => {
forceInteractive();
mockStreamChatResponseAsync
.mockRejectedValueOnce(new Error('You have reached your chat usage limit.'))
.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['try again', '/exit']);
const command = createCommand(['how are my builds?']);
await expect(command.runAsync()).resolves.toBeUndefined();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'try again' });
expect(input.close).toHaveBeenCalled();
});
it('propagates a request failure in non-interactive mode without retrying', async () => {
mockStreamChatResponseAsync.mockRejectedValue(new Error('boom'));
const command = createCommand(['hi', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow('boom');
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
});
it('starts a new conversation with /clear', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/clear', 'fresh question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'fresh question' });
});
it('restores project scope after /clear', async () => {
forceInteractive();
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/clear', 'fresh question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: fresh question',
});
});
it('ignores unknown slash commands and keeps prompting', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/bogus', 'a real question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponseAsync.mock.calls[1][0].messages[2].parts[0]).toEqual({
type: 'text',
text: 'a real question',
});
});
it('emits structured JSON and does not stream or prompt when --json is passed', async () => {
mockStreamChatResponseAsync.mockResolvedValue(
makeChatResult('Your latest build passed.', [
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } },
])
);
const command = createCommand(['how are my builds?', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockStreamChatResponseAsync.mock.calls[0][0].stream).toBe(false);
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
message: 'how are my builds?',
account: 'my-account',
project: null,
response: 'Your latest build passed.',
toolCalls: [{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } }],
});
});
it('throws when authenticated with an access token instead of a session secret', async () => {
const command = createCommand(['hi', '--non-interactive'], { sessionSecret: null });
await expect(command.runAsync()).rejects.toThrow(/requires an interactive login/);
expect(mockStreamChatResponseAsync).not.toHaveBeenCalled();
});
it('requires a message argument', async () => {
const command = createCommand(['--non-interactive']);
await expect(command.runAsync()).rejects.toThrow();
});
it('opens an interactive prompt when no message is given', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['how are my builds?', '/exit']);
const command = createCommand([]);
await command.runAsync();
expect(mockCreateChatReplInput).toHaveBeenCalledWith(expect.objectContaining({ history: [] }));
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.messages).toHaveLength(1);
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
expect(input.close).toHaveBeenCalled();
});
it('frames the first typed message with the auto-detected project when no message is given', async () => {
forceInteractive();
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['is my build ok?', '/exit']);
const command = createCommand([]);
await command.runAsync();
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
});
@@ -0,0 +1,168 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
protectUpdateChannelAsync,
unprotectUpdateChannelAsync,
} from '../../../channel/protection';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import Log from '../../../log';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ChannelProtect from '../protect';
import ChannelUnprotect from '../unprotect';
jest.mock('../../../channel/protection');
jest.mock('../../../graphql/queries/ChannelQuery');
jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../../utils/json');
const graphqlClient = {} as ExpoGraphqlClient;
const channel = {
id: 'channel-id',
name: 'production',
branchMapping: '{"version":0,"data":[]}',
isProtected: true,
};
function setContext(command: ChannelProtect | ChannelUnprotect): void {
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId: 'project-id',
loggedIn: { graphqlClient },
});
}
describe(ChannelProtect, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue({
...channel,
isProtected: false,
});
jest.mocked(protectUpdateChannelAsync).mockResolvedValue(channel);
});
it('protects a named channel', async () => {
const command = new ChannelProtect(['production'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(protectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, {
channelId: 'channel-id',
});
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('production'));
});
it('prints the server response as JSON', async () => {
const command = new ChannelProtect(['production', '--json'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith(channel);
expect(Log.withTick).not.toHaveBeenCalled();
});
it('does not mutate a channel that is already protected', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel);
const command = new ChannelProtect(['production'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(protectUpdateChannelAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('already protected'));
});
it('requires a channel name in non-interactive mode', async () => {
const command = new ChannelProtect(['--non-interactive', '--json'], getMockOclifConfig());
await expect(command.runAsync()).rejects.toThrow(
'Channel name must be set when running in non-interactive mode'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(protectUpdateChannelAsync).not.toHaveBeenCalled();
});
});
describe(ChannelUnprotect, () => {
let processExitSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel);
jest.mocked(unprotectUpdateChannelAsync).mockResolvedValue({ ...channel, isProtected: false });
jest.mocked(toggleConfirmAsync).mockResolvedValue(true);
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as never);
});
afterEach(() => {
processExitSpy.mockRestore();
});
it('asks for confirmation before removing protection', async () => {
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(toggleConfirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('production'),
});
expect(unprotectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, {
channelId: 'channel-id',
});
});
it('does not mutate the channel when confirmation is declined', async () => {
jest.mocked(toggleConfirmAsync).mockResolvedValue(false);
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
setContext(command);
await expect(command.runAsync()).rejects.toThrow('process.exit');
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('production'));
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('does not mutate a channel that is already unprotected', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue({
...channel,
isProtected: false,
});
const command = new ChannelUnprotect(['production'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(toggleConfirmAsync).not.toHaveBeenCalled();
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('already unprotected'));
});
it('does not prompt in non-interactive mode', async () => {
const command = new ChannelUnprotect(['production', '--non-interactive'], getMockOclifConfig());
setContext(command);
await command.runAsync();
expect(toggleConfirmAsync).not.toHaveBeenCalled();
expect(unprotectUpdateChannelAsync).toHaveBeenCalled();
});
it('requires a channel name in non-interactive mode', async () => {
const command = new ChannelUnprotect(['--non-interactive', '--json'], getMockOclifConfig());
await expect(command.runAsync()).rejects.toThrow(
'Channel name must be set when running in non-interactive mode'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { protectUpdateChannelAsync } from '../../channel/protection';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelProtect extends EasCommand {
static override description = 'protect a channel so only account admins can publish to it';
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to protect',
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelProtect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (!args.name && nonInteractive) {
throw new Error('Channel name must be set when running in non-interactive mode');
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelProtect, { nonInteractive });
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
: await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to protect',
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
if (existingChannel.isProtected) {
if (json) {
printJsonOnlyOutput(existingChannel);
} else {
Log.log(chalk`Channel {bold ${existingChannel.name}} is already protected.`);
}
return;
}
const channel = await protectUpdateChannelAsync(graphqlClient, {
channelId: existingChannel.id,
});
if (json) {
printJsonOnlyOutput(channel);
} else {
Log.withTick(chalk`Channel {bold ${channel.name}} is now protected.`);
}
}
}
@@ -0,0 +1,88 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { unprotectUpdateChannelAsync } from '../../channel/protection';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelUnprotect extends EasCommand {
static override description = "remove a channel's admin-only publishing restriction";
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to unprotect',
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelUnprotect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (!args.name && nonInteractive) {
throw new Error('Channel name must be set when running in non-interactive mode');
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelUnprotect, { nonInteractive });
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
: await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to unprotect',
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
if (!existingChannel.isProtected) {
if (json) {
printJsonOnlyOutput(existingChannel);
} else {
Log.log(chalk`Channel {bold ${existingChannel.name}} is already unprotected.`);
}
return;
}
if (!nonInteractive) {
const confirmed = await toggleConfirmAsync({
message: chalk`Remove protection from channel {bold ${existingChannel.name}}?`,
});
if (!confirmed) {
Log.error(chalk`Canceled removing protection from channel {bold ${existingChannel.name}}.`);
process.exit(1);
}
}
const channel = await unprotectUpdateChannelAsync(graphqlClient, {
channelId: existingChannel.id,
});
if (json) {
printJsonOnlyOutput(channel);
} else {
Log.withTick(chalk`Channel {bold ${channel.name}} is no longer protected.`);
}
}
}
-274
View File
@@ -1,274 +0,0 @@
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import {
ChatMessage,
ChatResult,
makeUserMessage,
streamChatResponseAsync,
} from '../chat/chatClient';
import { detectCurrentProjectAsync } from '../chat/detectProject';
import { ChatReplInput, createChatReplInput } from '../chat/replInput';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../commandUtils/flags';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log from '../log';
import { Actor } from '../user/User';
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
const EXIT_WORDS = new Set(['exit', 'quit', 'q']);
const USER_LABEL = `${chalk.bold.cyan('Chat')}${chalk.dim(' > ')}`;
const CHAT_HELP = [
'Commands:',
' /help Show this help',
' /clear Start a new conversation',
' /exit Quit',
].join('\n');
export default class Chat extends EasCommand {
static override description = 'ask an AI assistant about your Expo account and EAS projects';
static override hidden = true;
static override args = {
message: Args.string({
required: false,
description: 'Message to send to the assistant (omit to open an interactive prompt)',
}),
};
static override flags = {
account: Flags.string({
char: 'a',
description: 'Account to scope the conversation to (defaults to your primary account)',
}),
project: Flags.string({
char: 'p',
description:
'Project to focus the conversation on, as @account-name/project-slug or a project slug',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(Chat);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (!args.message && (json || nonInteractive)) {
throw new Error('Provide a message, e.g. `eas chat "how are my builds?"`.');
}
const {
loggedIn: { actor, authenticationInfo, graphqlClient },
} = await this.getContextAsync(Chat, { nonInteractive });
if (!authenticationInfo.sessionSecret) {
throw new Error(
'`eas chat` requires an interactive login. Run `eas login` first. Chat does not support EXPO_TOKEN access tokens yet.'
);
}
const { sessionSecret } = authenticationInfo;
let accountName = flags.account ?? getDefaultAccountName(actor);
let projectLabel: string | undefined;
let projectDetectedFromDirectory = false;
if (flags.project) {
const resolved = await resolveProjectAsync(graphqlClient, flags.project, accountName);
accountName = resolved.accountName;
projectLabel = resolved.label;
} else if (!flags.account) {
const detected = await detectCurrentProjectAsync(graphqlClient);
if (detected) {
accountName = detected.accountName;
projectLabel = detected.label;
projectDetectedFromDirectory = true;
}
}
if (json) {
const messages: ChatMessage[] = [
makeUserMessage(scopeMessageToProject(args.message as string, projectLabel)),
];
const result = await streamChatResponseAsync({
messages: [...messages],
accountName,
sessionSecret,
stream: false,
});
printJsonOnlyOutput({
message: args.message,
account: accountName ?? null,
project: projectLabel ?? null,
response: result.text,
toolCalls: result.toolCalls,
});
return;
}
if (projectLabel) {
Log.log(
chalk.dim(
projectDetectedFromDirectory
? `Project: ${projectLabel} (from current directory; use --account to widen)`
: `Project: ${projectLabel}`
)
);
}
if (!nonInteractive) {
Log.log(chalk.dim('Use /help for commands, or /exit to quit.'));
}
const input = nonInteractive
? undefined
: createChatReplInput({ history: args.message ? [args.message] : [] });
const messages: ChatMessage[] = [];
if (args.message) {
Log.log(`${USER_LABEL}${args.message}`);
Log.newLine();
messages.push(makeUserMessage(scopeMessageToProject(args.message, projectLabel)));
}
try {
for (;;) {
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role === 'assistant') {
if (!input) {
break;
}
const scopeNextMessage = messages.length === 0;
const nextMessage = await readNextUserMessageAsync(
input,
messages,
projectLabel,
scopeNextMessage
);
if (nextMessage === null) {
Log.log(chalk.dim('Ending chat.'));
break;
}
messages.push(makeUserMessage(nextMessage));
}
let result: ChatResult;
try {
result = await streamChatResponseAsync({
messages: [...messages],
accountName,
sessionSecret,
stream: true,
});
} catch (error) {
if (!input) {
throw error;
}
Log.error(error instanceof Error ? error.message : String(error));
messages.pop();
continue;
}
messages.push(result.assistantMessage);
}
} finally {
input?.close();
}
}
}
async function readNextUserMessageAsync(
input: ChatReplInput,
messages: ChatMessage[],
projectLabel: string | undefined,
scopeFirstMessage = false
): Promise<string | null> {
let shouldRestoreProjectScope = scopeFirstMessage;
for (;;) {
Log.newLine();
const line = await input.askAsync(USER_LABEL);
if (line === null) {
return null;
}
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (EXIT_WORDS.has(trimmed.toLowerCase())) {
return null;
}
if (trimmed.startsWith('/')) {
const command = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
if (['exit', 'quit', 'q'].includes(command)) {
return null;
}
if (['clear', 'new', 'reset'].includes(command)) {
messages.length = 0;
shouldRestoreProjectScope = true;
Log.log(chalk.dim('Started a new conversation.'));
continue;
}
if (['help', 'h', '?'].includes(command)) {
Log.log(chalk.dim(CHAT_HELP));
continue;
}
Log.warn(`Unknown command "/${command}". Type /help for commands.`);
continue;
}
Log.newLine();
return shouldRestoreProjectScope ? scopeMessageToProject(trimmed, projectLabel) : trimmed;
}
}
function scopeMessageToProject(message: string, projectLabel: string | undefined): string {
return projectLabel ? `Regarding the EAS project ${projectLabel}: ${message}` : message;
}
function getDefaultAccountName(actor: Actor): string | undefined {
if ('primaryAccount' in actor && actor.primaryAccount) {
return actor.primaryAccount.name;
}
return actor.accounts[0]?.name ?? undefined;
}
async function resolveProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectReference: string,
fallbackAccountName: string | undefined
): Promise<{ accountName: string; label: string }> {
const parsed = parseProjectReference(projectReference);
const accountForLookup = parsed.account ?? fallbackAccountName;
if (!accountForLookup) {
throw new Error(
`Could not determine which account owns project "${projectReference}". Pass it as @account-name/project-slug.`
);
}
const fullName = `@${accountForLookup}/${parsed.slug}`;
let app;
try {
app = await AppQuery.byFullNameAsync(graphqlClient, fullName);
} catch {
throw new Error(`Project ${fullName} was not found or you do not have access to it.`);
}
return { accountName: app.ownerAccount.name, label: app.fullName };
}
function parseProjectReference(reference: string): { account?: string; slug: string } {
const cleaned = reference.startsWith('@') ? reference.slice(1) : reference;
const parts = cleaned.split('/');
if (parts.length === 2 && parts[0] && parts[1]) {
return { account: parts[0], slug: parts[1] };
}
return { slug: cleaned };
}
@@ -0,0 +1,148 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppObserveErrorSeverity } from '../../../graphql/generated';
import {
fetchObserveErrorGroupsAsync,
fetchObserveErrorOccurrencesAsync,
} from '../../../observe/fetchErrors';
import {
buildObserveErrorGroupsJson,
buildObserveErrorGroupsTable,
buildObserveErrorOccurrencesJson,
buildObserveErrorOccurrencesTable,
} from '../../../observe/formatErrors';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveErrors from '../errors';
jest.mock('../../../observe/fetchErrors');
jest.mock('../../../observe/formatErrors', () => ({
buildObserveErrorGroupsTable: jest.fn().mockReturnValue('errors-table'),
buildObserveErrorGroupsJson: jest.fn().mockReturnValue({ errors: [], isTruncated: false }),
buildObserveErrorOccurrencesTable: jest.fn().mockReturnValue('occurrences-table'),
buildObserveErrorOccurrencesJson: jest
.fn()
.mockReturnValue({ occurrences: [], pageInfo: { hasNextPage: false, endCursor: null } }),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchErrorGroupsAsync = jest.mocked(fetchObserveErrorGroupsAsync);
const mockFetchErrorOccurrencesAsync = jest.mocked(fetchObserveErrorOccurrencesAsync);
const mockBuildTable = jest.mocked(buildObserveErrorGroupsTable);
const mockBuildJson = jest.mocked(buildObserveErrorGroupsJson);
const mockBuildOccurrencesTable = jest.mocked(buildObserveErrorOccurrencesTable);
const mockBuildOccurrencesJson = jest.mocked(buildObserveErrorOccurrencesJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveErrors, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchErrorGroupsAsync.mockResolvedValue({ groups: [], isTruncated: false });
mockFetchErrorOccurrencesAsync.mockResolvedValue({
occurrences: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false, endCursor: null } as any,
});
});
function createCommand(argv: string[]): ObserveErrors {
const command = new ObserveErrors(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches error groups and renders a table by default', async () => {
await createCommand([]).runAsync();
expect(mockFetchErrorGroupsAsync).toHaveBeenCalledTimes(1);
expect(mockBuildTable).toHaveBeenCalledTimes(1);
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
});
it('emits JSON with --json', async () => {
await createCommand(['--json', '--non-interactive']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
expect(mockBuildJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ errors: [], isTruncated: false });
});
it('maps the --severity flag to the AppObserveErrorSeverity enum', async () => {
await createCommand(['--severity', 'fatal']).runAsync();
const options = mockFetchErrorGroupsAsync.mock.calls[0][2];
expect(options.severity).toBe(AppObserveErrorSeverity.Fatal);
});
it('fetches occurrences (not groups) and renders their table when --fingerprint is set', async () => {
await createCommand(['--fingerprint', 'fp-1']).runAsync();
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
expect(mockFetchErrorOccurrencesAsync).toHaveBeenCalledTimes(1);
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({ fingerprint: 'fp-1' });
expect(mockBuildOccurrencesTable).toHaveBeenCalledTimes(1);
expect(mockBuildTable).not.toHaveBeenCalled();
});
it('emits occurrences JSON with --fingerprint --json', async () => {
await createCommand(['--fingerprint', 'fp-1', '--json', '--non-interactive']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
expect(mockBuildOccurrencesJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
occurrences: [],
pageInfo: { hasNextPage: false, endCursor: null },
});
});
it('forwards --limit to the occurrences fetch when --fingerprint is set', async () => {
await createCommand(['--fingerprint', 'fp-1', '--limit', '20']).runAsync();
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({
fingerprint: 'fp-1',
limit: 20,
});
});
it('rejects --limit without --fingerprint', async () => {
await expect(createCommand(['--limit', '20']).runAsync()).rejects.toThrow(
/--after or --limit can only be used in combination with the --fingerprint flag/
);
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
expect(mockFetchErrorOccurrencesAsync).not.toHaveBeenCalled();
});
it('rejects --after without --fingerprint', async () => {
await expect(createCommand(['--after', 'cursor-1']).runAsync()).rejects.toThrow(
/--after or --limit can only be used in combination with the --fingerprint flag/
);
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
});
it('surfaces the plan-gate message when errors are not available on the plan', async () => {
mockFetchErrorGroupsAsync.mockRejectedValueOnce(
new CombinedError({
graphQLErrors: [
new GraphQLError(
'Subscription to EAS is required for this feature.',
null,
null,
null,
null,
null,
{
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}
),
],
})
);
await expect(createCommand([]).runAsync()).rejects.toThrow(/Subscription to EAS is required/);
});
});
@@ -0,0 +1,190 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import {
buildObserveCustomEventDetail,
buildObserveCustomEventJson,
} from '../../../observe/formatCustomEvents';
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../../observe/formatErrors';
import { buildObserveEventDetail, buildObserveEventJson } from '../../../observe/formatEvents';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvent from '../event';
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventDetail: jest.fn().mockReturnValue('metric-detail'),
buildObserveEventJson: jest.fn().mockReturnValue({ id: 'metric-json' }),
}));
jest.mock('../../../observe/formatCustomEvents', () => ({
buildObserveCustomEventDetail: jest.fn().mockReturnValue('log-detail'),
buildObserveCustomEventJson: jest.fn().mockReturnValue({ id: 'log-json' }),
}));
jest.mock('../../../observe/formatErrors', () => ({
buildObserveErrorDetail: jest.fn().mockReturnValue('error-detail'),
buildObserveErrorJson: jest.fn().mockReturnValue({ id: 'error-json' }),
}));
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
ObserveQuery: {
logByIdAsync: jest.fn(),
metricByIdAsync: jest.fn(),
},
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockLogByIdAsync = jest.mocked(ObserveQuery.logByIdAsync);
const mockMetricByIdAsync = jest.mocked(ObserveQuery.metricByIdAsync);
const mockBuildObserveEventDetail = jest.mocked(buildObserveEventDetail);
const mockBuildObserveEventJson = jest.mocked(buildObserveEventJson);
const mockBuildObserveCustomEventDetail = jest.mocked(buildObserveCustomEventDetail);
const mockBuildObserveCustomEventJson = jest.mocked(buildObserveCustomEventJson);
const mockBuildObserveErrorDetail = jest.mocked(buildObserveErrorDetail);
const mockBuildObserveErrorJson = jest.mocked(buildObserveErrorJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
// A log event ID (user event or error) is a UUID; a metric event ID is
// base64url-encoded JSON.
const UUID_ID = '123e4567-e89b-12d3-a456-426614174000';
const BASE64_ID = 'eyJhIjoxfQ';
const INVALID_ID = 'not a valid id';
const userEventLog = { id: UUID_ID, __typename: 'AppObserveUserEvent' } as any;
const errorLog = { id: UUID_ID, __typename: 'AppObserveError' } as any;
describe(ObserveEvent, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockLogByIdAsync.mockResolvedValue(null);
mockMetricByIdAsync.mockResolvedValue(null);
});
function createCommand(argv: string[]): ObserveEvent {
const command = new ObserveEvent(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
function planGateError(): CombinedError {
const serverMessage =
'Subscription to EAS is required for this feature. ' +
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
return new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
}),
],
});
}
it('looks up a UUID id with the log query only', async () => {
mockLogByIdAsync.mockResolvedValue(userEventLog);
await createCommand([UUID_ID]).runAsync();
expect(mockLogByIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
id: UUID_ID,
});
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
expect(mockBuildObserveCustomEventDetail).toHaveBeenCalledTimes(1);
});
it('renders an error detail when a UUID id resolves to an error', async () => {
mockLogByIdAsync.mockResolvedValue(errorLog);
await createCommand([UUID_ID]).runAsync();
expect(mockBuildObserveErrorDetail).toHaveBeenCalledTimes(1);
expect(mockBuildObserveCustomEventDetail).not.toHaveBeenCalled();
});
it('looks up a base64 id with the metric query only', async () => {
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
await createCommand([BASE64_ID]).runAsync();
expect(mockMetricByIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
id: BASE64_ID,
});
expect(mockLogByIdAsync).not.toHaveBeenCalled();
expect(mockBuildObserveEventDetail).toHaveBeenCalledTimes(1);
});
it('errors immediately for an ID that is neither a UUID nor base64, without querying', async () => {
await expect(createCommand([INVALID_ID]).runAsync()).rejects.toThrow(
/is not a valid Observe event ID/
);
expect(mockLogByIdAsync).not.toHaveBeenCalled();
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
});
it('emits typed JSON for a metric event with --json', async () => {
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
await createCommand([BASE64_ID, '--json', '--non-interactive']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
expect(mockBuildObserveEventJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
type: 'metric',
event: { id: 'metric-json' },
});
});
it('emits typed JSON for a log event with --json', async () => {
mockLogByIdAsync.mockResolvedValue(userEventLog);
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
expect(mockBuildObserveCustomEventJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
type: 'log',
event: { id: 'log-json' },
});
});
it('emits typed JSON for an error event with --json', async () => {
mockLogByIdAsync.mockResolvedValue(errorLog);
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
expect(mockBuildObserveErrorJson).toHaveBeenCalledTimes(1);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
type: 'error',
event: { id: 'error-json' },
});
});
it('throws not-found when a well-formed ID resolves to nothing', async () => {
mockLogByIdAsync.mockResolvedValue(null);
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
new RegExp(`No Observe event found with ID "${UUID_ID}"`)
);
});
it('surfaces the plan-gate message when the lookup is not available on the plan', async () => {
mockLogByIdAsync.mockRejectedValueOnce(planGateError());
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
/Subscription to EAS is required/
);
});
it('wraps an unexpected server error with an ID-specific message and preserves the request ID', async () => {
const serverError = new CombinedError({
graphQLErrors: [
new GraphQLError('unexpected server error', null, null, null, null, null, {
requestId: 'req-123',
}),
],
});
mockLogByIdAsync.mockRejectedValue(serverError);
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
new RegExp(
`Could not retrieve Observe event with ID "${UUID_ID}"[\\s\\S]*unexpected server error \\(Request ID: req-123\\)`
)
);
});
});
@@ -115,6 +115,25 @@ describe(ObserveEvents, () => {
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
});
it('passes --environment to the custom events filter', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [{ id: 'evt-1' } as any],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['my_event', '--environment', 'production']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.environment).toBe('production');
});
it('passes --environment to customEventNamesAsync when listing event names', async () => {
const command = createCommand(['--environment', 'production']);
await command.runAsync();
expect(mockCustomEventNamesAsync.mock.calls[0][1].environment).toBe('production');
});
it('routes to customEventNamesAsync when no positional arg is provided', async () => {
const command = createCommand([]);
await command.runAsync();
@@ -164,7 +183,8 @@ describe(ObserveEvents, () => {
appId: projectId,
startTime: '2025-06-08T12:00:00.000Z',
endTime: '2025-06-15T12:00:00.000Z',
platform: AppObservePlatform.Ios,
platforms: [AppObservePlatform.Ios],
environment: undefined,
});
jest.useRealTimers();
@@ -231,7 +251,20 @@ describe(ObserveEvents, () => {
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
expect(options.platforms).toEqual([AppObservePlatform.Ios]);
});
it('passes --platform apple as every Apple platform', async () => {
const command = createCommand(['my_event', '--platform', 'apple']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platforms).toEqual([
AppObservePlatform.Ios,
AppObservePlatform.Ipados,
AppObservePlatform.Tvos,
AppObservePlatform.Macos,
]);
});
it('passes --app-version', async () => {
@@ -242,6 +275,14 @@ describe(ObserveEvents, () => {
expect(options.appVersion).toBe('2.1.0');
});
it('passes --build-number', async () => {
const command = createCommand(['my_event', '--build-number', '42']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.buildNumber).toBe('42');
});
it('passes --update-id', async () => {
const command = createCommand(['my_event', '--update-id', 'update-xyz']);
await command.runAsync();
@@ -258,12 +299,12 @@ describe(ObserveEvents, () => {
expect(options.sessionId).toBe('session-xyz');
});
it('does not pass platform, appVersion, updateId, or sessionId when flags are not provided', async () => {
it('does not pass platforms, appVersion, updateId, or sessionId when flags are not provided', async () => {
const command = createCommand(['my_event']);
await command.runAsync();
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.platforms).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
expect(options.sessionId).toBeUndefined();
@@ -3,11 +3,12 @@ import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { ObservePlatformTarget } from '../../../observe/platforms';
import ObserveMetricsSummary from '../metrics-summary';
jest.mock('../../../observe/fetchMetrics', () => {
@@ -31,6 +32,10 @@ const mockBuildObserveMetricsSummaryJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
describe(ObserveMetricsSummary, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
@@ -83,7 +88,7 @@ describe(ObserveMetricsSummary, () => {
expect(mockFetchObserveMetricsSummaryAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
expect(platforms).toEqual([target(AppObservePlatform.Android), target(AppObservePlatform.Ios)]);
jest.useRealTimers();
});
@@ -93,7 +98,7 @@ describe(ObserveMetricsSummary, () => {
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
expect(platforms).toEqual([target(AppObservePlatform.Android)]);
});
it('queries only iOS when --platform ios is passed', async () => {
@@ -101,7 +106,40 @@ describe(ObserveMetricsSummary, () => {
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
expect(platforms).toEqual([target(AppObservePlatform.Ios)]);
});
it('queries one combined target covering every Apple platform when --platform apple is passed', async () => {
const command = createCommand(['--platform', 'apple']);
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([
{
key: 'APPLE',
platforms: [
AppObservePlatform.Ios,
AppObservePlatform.Ipados,
AppObservePlatform.Tvos,
AppObservePlatform.Macos,
],
},
]);
});
it('queries only macOS when --platform macos is passed', async () => {
const command = createCommand(['--platform', 'macos']);
await command.runAsync();
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
expect(platforms).toEqual([target(AppObservePlatform.Macos)]);
});
it('passes --environment through to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--environment', 'production']);
await command.runAsync();
expect(mockFetchObserveMetricsSummaryAsync.mock.calls[0][6]).toBe('production');
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
@@ -4,8 +4,8 @@ import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObserveMetricsListOrderByField,
AppObserveOrderDirection,
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
@@ -135,6 +135,14 @@ describe(ObserveMetrics, () => {
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('passes --environment to the events filter', async () => {
const command = createCommand(['tti', '--environment', 'production']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.environment).toBe('production');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
@@ -185,20 +193,20 @@ describe(ObserveMetrics, () => {
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
it('passes --platform ios to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
expect(options.platforms).toEqual([AppObservePlatform.Ios]);
});
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
it('passes --platform android to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--platform', 'android']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Android);
expect(options.platforms).toEqual([AppObservePlatform.Android]);
});
it('passes --app-version to fetchObserveEventsAsync', async () => {
@@ -209,6 +217,14 @@ describe(ObserveMetrics, () => {
expect(options.appVersion).toBe('2.1.0');
});
it('passes --build-number to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--build-number', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.buildNumber).toBe('42');
});
it('passes --update-id to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--update-id', 'update-xyz']);
await command.runAsync();
@@ -217,12 +233,12 @@ describe(ObserveMetrics, () => {
expect(options.updateId).toBe('update-xyz');
});
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
it('does not pass platforms, appVersion, or updateId when flags are not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.platforms).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
});
@@ -231,8 +247,8 @@ describe(ObserveMetrics, () => {
const mockEvents = [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -274,8 +290,8 @@ describe(ObserveMetrics, () => {
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.orderBy).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
});
});
@@ -299,29 +315,29 @@ describe(ObserveMetrics, () => {
describe(resolveOrderBy, () => {
it('resolves lowercase "slowest" to MetricValue DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
});
});
it('resolves lowercase "fastest" to MetricValue ASC', () => {
expect(resolveOrderBy('fastest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Asc,
});
});
it('resolves lowercase "newest" to Timestamp DESC', () => {
expect(resolveOrderBy('newest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Desc,
});
});
it('resolves lowercase "oldest" to Timestamp ASC', () => {
expect(resolveOrderBy('oldest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
});
});
});
@@ -3,7 +3,7 @@ import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveNavigationRoutesAsync } from '../../../observe/fetchNavigationRoutes';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
import {
@@ -11,6 +11,7 @@ import {
buildObserveNavigationRoutesTable,
} from '../../../observe/formatNavigationRoutes';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { ObservePlatformTarget } from '../../../observe/platforms';
import ObserveRoutes from '../routes';
jest.mock('../../../observe/fetchNavigationRoutes');
@@ -31,6 +32,10 @@ const mockBuildObserveNavigationRoutesJson = jest.mocked(buildObserveNavigationR
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
describe(ObserveRoutes, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
@@ -78,7 +83,10 @@ describe(ObserveRoutes, () => {
expect(mockFetchObserveNavigationRoutesAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
expect(options.targets).toEqual([
target(AppObservePlatform.Android),
target(AppObservePlatform.Ios),
]);
expect(options.limit).toBe(50);
const tableCall = mockBuildObserveNavigationRoutesTable.mock.calls[0];
@@ -95,7 +103,7 @@ describe(ObserveRoutes, () => {
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.platforms).toEqual([AppPlatform.Ios]);
expect(options.targets).toEqual([target(AppObservePlatform.Ios)]);
});
it('resolves --metric short aliases to navigation metric full names and deduplicates', async () => {
@@ -174,6 +182,14 @@ describe(ObserveRoutes, () => {
expect(options.buildNumber).toBe('42');
});
it('passes --environment through to the fetcher', async () => {
const command = createCommand(['--environment', 'production']);
await command.runAsync();
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
expect(options.environment).toBe('production');
});
it('passes --route-name flags through as routeNames array', async () => {
const command = createCommand(['--route-name', '/home', '--route-name', '/profile']);
await command.runAsync();
@@ -56,10 +56,10 @@ const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeMetricEvent(overrides: any = {}): any {
return {
__typename: 'AppObserveEvent',
__typename: 'AppObserveMetric',
id: 'evt-m-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -78,9 +78,9 @@ function makeMetricEvent(overrides: any = {}): any {
function makeCustomEvent(overrides: any = {}): any {
return {
__typename: 'AppObserveCustomEvent',
__typename: 'AppObserveUserEvent',
id: 'evt-c-1',
eventName: 'login_pressed',
name: 'login_pressed',
timestamp: '2025-01-15T10:00:00.000Z',
sessionId: 'session-log-1',
severityNumber: null,
@@ -239,6 +239,11 @@ describe(ObserveSession, () => {
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
});
it('rejects --environment when a session ID is also provided', async () => {
const command = createCommand(['session-abc', '--environment', 'production']);
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
});
it('picker mode with --event-name tti fetches metric events and threads selected sessionId', async () => {
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
makeMetricEvent({ sessionId: 'picked-session-1' }),
@@ -284,9 +289,7 @@ describe(ObserveSession, () => {
it('picker mode without --event-name or --sort prompts for event name, then sort, then event', async () => {
mockCustomEventNamesAsync.mockResolvedValue({
names: [
{ __typename: 'AppObserveCustomEventName', eventName: 'login_pressed', count: 12 } as any,
],
names: [{ __typename: 'AppObserveUserEventName', name: 'login_pressed', count: 12 } as any],
isTruncated: false,
});
// 1) event-name picker → metric choice
@@ -1,12 +1,13 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveVersionsAsync } from '../../../observe/fetchVersions';
import {
buildObserveVersionsJson,
buildObserveVersionsTable,
} from '../../../observe/formatVersions';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { ObservePlatformTarget } from '../../../observe/platforms';
import ObserveVersions from '../versions';
jest.mock('../../../observe/fetchVersions');
@@ -23,6 +24,10 @@ const mockBuildObserveVersionsJson = jest.mocked(buildObserveVersionsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
describe(ObserveVersions, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
@@ -52,7 +57,7 @@ describe(ObserveVersions, () => {
expect(mockFetchObserveVersionsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
expect(platforms).toEqual([target(AppObservePlatform.Android), target(AppObservePlatform.Ios)]);
jest.useRealTimers();
});
@@ -62,7 +67,7 @@ describe(ObserveVersions, () => {
await command.runAsync();
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Android]);
expect(platforms).toEqual([target(AppObservePlatform.Android)]);
});
it('queries only iOS when --platform ios is passed', async () => {
@@ -70,7 +75,14 @@ describe(ObserveVersions, () => {
await command.runAsync();
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Ios]);
expect(platforms).toEqual([target(AppObservePlatform.Ios)]);
});
it('passes --environment through to fetchObserveVersionsAsync', async () => {
const command = createCommand(['--environment', 'production']);
await command.runAsync();
expect(mockFetchObserveVersionsAsync.mock.calls[0][5]).toBe('production');
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
@@ -164,7 +176,7 @@ describe(ObserveVersions, () => {
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
mockFetchObserveVersionsAsync.mockResolvedValue([
{
platform: AppPlatform.Ios,
platform: AppObservePlatform.Ios,
appVersions: [
{
appVersion: '1.0.0',
@@ -0,0 +1,162 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import { AppObserveErrorSeverity } from '../../graphql/generated';
import Log from '../../log';
import {
fetchObserveErrorGroupsAsync,
fetchObserveErrorOccurrencesAsync,
} from '../../observe/fetchErrors';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
import {
buildObserveErrorGroupsJson,
buildObserveErrorGroupsTable,
buildObserveErrorOccurrencesJson,
buildObserveErrorOccurrencesTable,
} from '../../observe/formatErrors';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const SEVERITY_BY_FLAG: Record<string, AppObserveErrorSeverity> = {
fatal: AppObserveErrorSeverity.Fatal,
error: AppObserveErrorSeverity.Error,
};
const DEFAULT_OCCURRENCES_LIMIT = 10;
export default class ObserveErrors extends EasCommand {
static override description =
'display error and exception issue groups (grouped by fingerprint) for the app';
static override flags = {
...ObservePlatformFlag,
fingerprint: Flags.string({
description:
'Show individual occurrences (with stack traces) for this error group fingerprint instead of the grouped summary',
required: false,
}),
severity: Flags.option({
description: 'Filter by severity (ignored when --fingerprint is set)',
options: Object.keys(SEVERITY_BY_FLAG),
required: false,
})(),
...ObserveAfterFlag,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_OCCURRENCES_LIMIT,
limit: 100,
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveErrors);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveErrors,
loggedInOnlyContextDefinition: ObserveErrors.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive,
});
if (!flags.fingerprint && (flags.after || flags.limit)) {
throw new Error(
'--after or --limit can only be used in combination with the --fingerprint flag.'
);
}
if (json) {
enableJsonOutput();
}
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
if (flags.fingerprint) {
const { occurrences, pageInfo } = await withObservePlanGateHandlingAsync(() =>
fetchObserveErrorOccurrencesAsync(graphqlClient, projectId, {
fingerprint: flags.fingerprint as string,
startTime,
endTime,
platforms: observePlatformsFromFlag(flags.platform),
appVersion: flags['app-version'],
buildNumber: flags['build-number'],
updateId: flags['update-id'],
environment: flags.environment,
limit: flags.limit ?? DEFAULT_OCCURRENCES_LIMIT,
...(flags.after && { after: flags.after }),
})
);
if (json) {
printJsonOnlyOutput(buildObserveErrorOccurrencesJson(occurrences, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(
buildObserveErrorOccurrencesTable(occurrences, pageInfo, {
fingerprint: flags.fingerprint,
daysBack,
startTime,
endTime,
})
);
}
return;
}
const { groups, isTruncated } = await withObservePlanGateHandlingAsync(() =>
fetchObserveErrorGroupsAsync(graphqlClient, projectId, {
startTime,
endTime,
platforms: observePlatformsFromFlag(flags.platform),
appVersion: flags['app-version'],
buildNumber: flags['build-number'],
updateId: flags['update-id'],
environment: flags.environment,
...(flags.severity && { severity: SEVERITY_BY_FLAG[flags.severity] }),
})
);
if (json) {
printJsonOnlyOutput(buildObserveErrorGroupsJson(groups, isTruncated));
} else {
Log.addNewLineIfNone();
Log.log(buildObserveErrorGroupsTable(groups, { daysBack, startTime, endTime }));
if (isTruncated) {
Log.warn('Result is truncated; not all error groups are shown.');
}
}
}
}
@@ -0,0 +1,174 @@
import { Args } from '@oclif/core';
import { validate as isUuid } from 'uuid';
import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { GraphqlError } from '../../graphql/client';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import Log from '../../log';
import {
buildObserveCustomEventDetail,
buildObserveCustomEventJson,
} from '../../observe/formatCustomEvents';
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../observe/formatErrors';
import { buildObserveEventDetail, buildObserveEventJson } from '../../observe/formatEvents';
import { ObserveProjectIdFlag } from '../../observe/flags';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ObserveEvent extends EasCommand {
static override description =
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
static override args = {
id: Args.string({
description: 'ID of the event to display',
required: true,
}),
};
static override flags = {
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvent);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveEvent,
loggedInOnlyContextDefinition: ObserveEvent.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive,
});
if (json) {
enableJsonOutput();
}
const id = args.id;
// A log event ID (user event or error) is a UUID; a metric event ID is
// base64url-encoded JSON. Route to the matching query so we never issue the
// query that is guaranteed to fail for this ID, and reject anything that is
// neither up front.
if (isUuid(id)) {
const log = await fetchObserveEventAsync(id, () =>
ObserveQuery.logByIdAsync(graphqlClient, { appId: projectId, id })
);
if (!log) {
throw eventNotFoundError(id);
}
if (log.__typename === 'AppObserveError') {
if (json) {
printJsonOnlyOutput({ type: 'error', event: buildObserveErrorJson(log) });
} else {
Log.addNewLineIfNone();
Log.log(buildObserveErrorDetail(log));
}
} else if (json) {
printJsonOnlyOutput({ type: 'log', event: buildObserveCustomEventJson(log) });
} else {
Log.addNewLineIfNone();
Log.log(buildObserveCustomEventDetail(log));
}
return;
}
if (parsesAsBase64(id)) {
const event = await fetchObserveEventAsync(id, () =>
ObserveQuery.metricByIdAsync(graphqlClient, { appId: projectId, id })
);
if (!event) {
throw eventNotFoundError(id);
}
if (json) {
printJsonOnlyOutput({ type: 'metric', event: buildObserveEventJson(event) });
} else {
Log.addNewLineIfNone();
Log.log(buildObserveEventDetail(event));
}
return;
}
throw new EasCommandError(
`"${id}" is not a valid Observe event ID. IDs come from \`eas observe:events\` or \`eas observe:session\`.`
);
}
}
/**
* A metric event ID is canonical base64url (of JSON). Round-tripping rejects
* strings that only coincidentally contain base64 characters, so a value that
* is neither a UUID nor this is treated as an invalid ID.
*/
function parsesAsBase64(id: string): boolean {
if (id.length === 0) {
return false;
}
return Buffer.from(id, 'base64url').toString('base64url') === id;
}
function eventNotFoundError(id: string): EasCommandError {
return new EasCommandError(
`No Observe event found with ID "${id}". IDs come from \`eas observe:events\` or \`eas observe:session\`, and events age out of retention.`
);
}
/**
* Runs an Observe event lookup, translating plan-gate rejections to their
* upgrade message and any other server error into an actionable message that
* preserves the underlying error and request ID for support.
*/
async function fetchObserveEventAsync<T>(id: string, fn: () => Promise<T>): Promise<T> {
try {
return await withObservePlanGateHandlingAsync(fn);
} catch (error) {
if (error instanceof EasCommandError) {
throw error;
}
throw new EasCommandError(
`Could not retrieve Observe event with ID "${id}". ` +
'The ID may be invalid or the event may not exist (events also age out of retention). ' +
'Verify it was copied in full from `eas observe:events` or `eas observe:session`.' +
`\n\n${describeObserveServerError(error)}`
);
}
}
/**
* Extract a human-readable description from a GraphQL/server error, including
* the request ID(s) so a support request can reference them. Falls back to the
* error's own message for non-GraphQL errors.
*/
function describeObserveServerError(error: unknown): string {
if (error instanceof GraphqlError && error.graphQLErrors.length > 0) {
return error.graphQLErrors
.map(graphQLError => {
const message = graphQLError.message.replace('[GraphQL] ', '');
const requestId = graphQLError.extensions?.requestId;
return requestId ? `${message} (Request ID: ${String(requestId)})` : message;
})
.join('\n');
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
@@ -12,6 +12,8 @@ import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
@@ -26,7 +28,7 @@ import {
buildObserveCustomEventsTable,
} from '../../observe/formatCustomEvents';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appObservePlatformFromFlag } from '../../observe/platforms';
import { observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -53,7 +55,9 @@ export default class ObserveEvents extends EasCommand {
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveEnvironmentFlag,
'session-id': Flags.string({
description:
'Filter by session ID. When no event name is given, lists the events in the session instead of the event-name summary.',
@@ -100,7 +104,7 @@ export default class ObserveEvents extends EasCommand {
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platform = appObservePlatformFromFlag(flags.platform);
const platforms = observePlatformsFromFlag(flags.platform);
// A session ID narrows to a single session, so show that session's events
// (like --all-events) instead of the account-wide name+count summary, which
@@ -111,7 +115,8 @@ export default class ObserveEvents extends EasCommand {
appId: projectId,
startTime,
endTime,
platform,
platforms,
environment: flags.environment,
})
);
@@ -138,10 +143,12 @@ export default class ObserveEvents extends EasCommand {
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
platforms,
appVersion: flags['app-version'],
buildNumber: flags['build-number'],
updateId: flags['update-id'],
sessionId: flags['session-id'],
environment: flags.environment,
})
);
@@ -150,7 +157,8 @@ export default class ObserveEvents extends EasCommand {
appId: projectId,
startTime,
endTime,
platform,
platforms,
environment: flags.environment,
});
if (json) {
@@ -8,6 +8,7 @@ import {
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
@@ -20,7 +21,7 @@ import {
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -62,6 +63,7 @@ export default class ObserveMetricsSummary extends EasCommand {
options: DEFAULT_STATS_JSON,
})(),
...ObserveTimeRangeFlags,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -97,7 +99,7 @@ export default class ObserveMetricsSummary extends EasCommand {
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const targets = observePlatformTargetsFromFlag(flags.platform);
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
await withObservePlanGateHandlingAsync(() =>
@@ -105,9 +107,10 @@ export default class ObserveMetricsSummary extends EasCommand {
graphqlClient,
projectId,
metricNames,
platforms,
targets,
startTime,
endTime
endTime,
flags.environment
)
);
@@ -17,6 +17,8 @@ import {
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
@@ -25,7 +27,7 @@ import {
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
import { observePlatformTargetsFromFlag, observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { selectAsync } from '../../prompts';
@@ -59,7 +61,9 @@ export default class ObserveMetrics extends EasCommand {
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -108,8 +112,8 @@ export default class ObserveMetrics extends EasCommand {
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platform = appObservePlatformFromFlag(flags.platform);
const platforms = appPlatformsFromFlag(flags.platform);
const platforms = observePlatformsFromFlag(flags.platform);
const targets = observePlatformTargetsFromFlag(flags.platform);
const [{ events, pageInfo }, totalEventCount] = await withObservePlanGateHandlingAsync(() =>
Promise.all([
@@ -120,17 +124,20 @@ export default class ObserveMetrics extends EasCommand {
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
platforms,
appVersion: flags['app-version'],
buildNumber: flags['build-number'],
updateId: flags['update-id'],
environment: flags.environment,
}),
fetchTotalEventCountAsync(
graphqlClient,
projectId,
metricName,
platforms,
targets,
startTime,
endTime
endTime,
flags.environment
),
])
);
@@ -11,6 +11,8 @@ import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigation
import {
ObserveAfterFlag,
ObserveAppVersionFlag,
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
@@ -25,7 +27,7 @@ import {
} from '../../observe/formatNavigationRoutes';
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -61,15 +63,14 @@ export default class ObserveRoutes extends EasCommand {
}),
...ObserveTimeRangeFlags,
...ObserveAppVersionFlag,
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
'build-number': Flags.string({
description: 'Filter by app build number',
}),
'route-name': Flags.string({
description:
'Filter by route name (can be specified multiple times to include several routes)',
multiple: true,
}),
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -112,19 +113,20 @@ export default class ObserveRoutes extends EasCommand {
: undefined;
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const targets = observePlatformTargetsFromFlag(flags.platform);
const { routes, pageInfoByPlatform } = await withObservePlanGateHandlingAsync(() =>
fetchObserveNavigationRoutesAsync(graphqlClient, projectId, {
startTime,
endTime,
platforms,
targets,
limit: flags.limit ?? DEFAULT_ROUTES_LIMIT,
...(flags.after && { after: flags.after }),
appVersion: flags['app-version'],
updateId: flags['update-id'],
buildNumber: flags['build-number'],
routeNames,
environment: flags.environment,
})
);
@@ -16,7 +16,11 @@ import {
fetchSessionMetricCandidatesAsync,
verifyObserveSessionAccessAsync,
} from '../../observe/fetchSessions';
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
import {
ObserveEnvironmentFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import {
buildObserveSessionEventsJson,
@@ -66,6 +70,7 @@ export default class ObserveSession extends EasCommand {
'Metric or log event name to pick candidate sessions by (e.g. tti, cold_launch, login_pressed). If omitted in interactive mode, you will be prompted.',
}),
...ObserveTimeRangeFlags,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -102,10 +107,11 @@ export default class ObserveSession extends EasCommand {
flags.sort !== undefined ||
flags.days !== undefined ||
flags.start !== undefined ||
flags.end !== undefined;
flags.end !== undefined ||
flags.environment !== undefined;
if (pickerFlagsProvided) {
throw new EasCommandError(
'The picker flags (--event-name, --sort, --days, --start, --end) describe how to find a session and cannot be combined with a session ID argument.'
'The picker flags (--event-name, --sort, --days, --start, --end, --environment) describe how to find a session and cannot be combined with a session ID argument.'
);
}
sessionId = args.sessionId;
@@ -127,6 +133,7 @@ export default class ObserveSession extends EasCommand {
eventNameFlag: flags['event-name'],
sort: flags.sort,
timeRangeFlags: { days: flags.days, start: flags.start, end: flags.end },
environment: flags.environment,
});
}
@@ -172,18 +179,20 @@ async function pickSessionIdInteractivelyAsync({
eventNameFlag,
sort,
timeRangeFlags,
environment,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
eventNameFlag: string | undefined;
sort: string | undefined;
timeRangeFlags: { days: number | undefined; start: string | undefined; end: string | undefined };
environment: string | undefined;
}): Promise<string> {
const { startTime, endTime } = resolveTimeRange(timeRangeFlags);
const eventNameChoice: EventNameChoice = eventNameFlag
? { name: eventNameFlag, isMetric: isKnownMetricName(eventNameFlag) }
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime });
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime, environment });
let sortValue: string;
if (sort) {
@@ -208,6 +217,7 @@ async function pickSessionIdInteractivelyAsync({
startTime,
endTime,
limit: PICKER_CANDIDATE_LIMIT,
environment,
})
).map(event => ({ sessionId: event.sessionId, title: formatMetricCandidateTitle(event) }))
: (
@@ -217,6 +227,7 @@ async function pickSessionIdInteractivelyAsync({
startTime,
endTime,
limit: PICKER_CANDIDATE_LIMIT,
environment,
})
).map(event => ({ sessionId: event.sessionId, title: formatLogCandidateTitle(event) }));
@@ -254,16 +265,19 @@ async function promptForEventNameAsync({
projectId,
startTime,
endTime,
environment,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
startTime: string;
endTime: string;
environment: string | undefined;
}): Promise<EventNameChoice> {
const { names: customEventNames } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
endTime,
environment,
});
const metricChoices: ExpoChoice<EventNameChoice>[] = Object.entries(METRIC_SHORT_NAMES).map(
@@ -273,12 +287,10 @@ async function promptForEventNameAsync({
})
);
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(
({ eventName, count }) => ({
title: `${eventName} (${count} log event${count === 1 ? '' : 's'})`,
value: { name: eventName, isMetric: false },
})
);
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(({ name, count }) => ({
title: `${name} (${count} log event${count === 1 ? '' : 's'})`,
value: { name, isMetric: false },
}));
const choices = [...metricChoices, ...logChoices];
if (choices.length === 0) {
@@ -6,12 +6,13 @@ import {
import Log from '../../log';
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
import {
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
import { appPlatformsFromFlag } from '../../observe/platforms';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -22,6 +23,7 @@ export default class ObserveVersions extends EasCommand {
static override flags = {
...ObservePlatformFlag,
...ObserveTimeRangeFlags,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -53,14 +55,15 @@ export default class ObserveVersions extends EasCommand {
const { startTime, endTime } = resolveTimeRange(flags);
const platforms = appPlatformsFromFlag(flags.platform);
const targets = observePlatformTargetsFromFlag(flags.platform);
const results = await fetchObserveVersionsAsync(
graphqlClient,
projectId,
platforms,
targets,
startTime,
endTime
endTime,
flags.environment
);
if (json) {
@@ -0,0 +1,364 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppFragment,
AppPlatform,
BuildFragment,
BuildStatus,
DistributionType,
SubmissionStatus,
UpdateFragment,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { ProjectStatusSubmission, SubmissionQuery } from '../../../graphql/queries/SubmissionQuery';
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
import Log from '../../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ProjectStatus from '../status';
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../graphql/queries/BuildQuery');
jest.mock('../../../graphql/queries/SubmissionQuery');
jest.mock('../../../graphql/queries/UpdateQuery');
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockAppByIdAsync = jest.mocked(AppQuery.byIdAsync);
const mockWorkflowRunsAsync = jest.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync);
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
const mockProjectStatusSubmissionsAsync = jest.mocked(SubmissionQuery.forProjectStatusAsync);
const mockViewUpdateGroupsAsync = jest.mocked(UpdateQuery.viewUpdateGroupsOnAppAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const projectId = 'test-project-id';
function makeApp(): AppFragment {
return {
id: projectId,
name: 'my-app',
fullName: '@jester/my-app',
slug: 'my-app',
ownerAccount: { id: 'account-1', name: 'jester' },
} as unknown as AppFragment;
}
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-1',
platform: AppPlatform.Ios,
status: BuildStatus.Finished,
distribution: DistributionType.Store,
buildProfile: 'production',
appVersion: '1.0.0',
appBuildVersion: '42',
runtime: { id: 'runtime-1', version: '1.0.0' },
updateChannel: { id: 'channel-1', name: 'production' },
gitCommitHash: 'abcdef1234567890',
gitCommitMessage: 'Ship it\nlonger body',
createdAt: '2026-07-01T00:00:00.000Z',
completedAt: '2026-07-01T00:10:00.000Z',
initiatingActor: { id: 'actor-1', displayName: 'jester' },
app: {
id: projectId,
name: 'my-app',
slug: 'my-app',
ownerAccount: { id: 'account-1', name: 'jester' },
},
...overrides,
} as unknown as BuildFragment;
}
function makeWorkflowRun(overrides: Partial<WorkflowRunFragment> = {}): WorkflowRunFragment {
return {
id: 'run-1',
status: WorkflowRunStatus.Success,
gitCommitMessage: 'Automate all the things',
gitCommitHash: 'fedcba0987654321',
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2026-07-02T00:00:00.000Z',
updatedAt: '2026-07-02T00:05:00.000Z',
errors: [],
workflow: { id: 'wf-1', name: 'Publish', fileName: 'publish.yml' },
...overrides,
} as unknown as WorkflowRunFragment;
}
function makeSubmission(overrides: Partial<ProjectStatusSubmission> = {}): ProjectStatusSubmission {
return {
id: 'submission-1',
platform: AppPlatform.Android,
status: SubmissionStatus.Finished,
createdAt: '2026-07-02T12:00:00.000Z',
completedAt: '2026-07-02T12:05:00.000Z',
initiatingActor: { id: 'actor-1', displayName: 'jester' },
submittedBuild: { id: 'build-1' },
androidConfig: {
applicationIdentifier: 'com.example.app',
track: 'production',
releaseStatus: null,
rollout: 50,
},
error: null,
...overrides,
} as unknown as ProjectStatusSubmission;
}
function makeUpdateGroup(): UpdateFragment[] {
return [
{
id: 'update-ios',
group: 'group-1',
message: 'Fix crash on launch',
createdAt: '2026-07-03T00:00:00.000Z',
runtime: { id: 'runtime-1', version: '1.0.0' },
platform: 'ios',
isRollBackToEmbedded: false,
gitCommitHash: '1234567890abcdef',
rolloutPercentage: 25,
environment: 'production',
actor: { __typename: 'User', id: 'actor-1', username: 'jester' },
branch: { id: 'branch-1', name: 'production' },
},
{
id: 'update-android',
group: 'group-1',
message: 'Fix crash on launch',
createdAt: '2026-07-03T00:00:00.000Z',
runtime: { id: 'runtime-1', version: '1.0.0' },
platform: 'android',
isRollBackToEmbedded: false,
gitCommitHash: '1234567890abcdef',
rolloutPercentage: 25,
environment: 'production',
actor: { __typename: 'User', id: 'actor-1', username: 'jester' },
branch: { id: 'branch-1', name: 'production' },
},
] as unknown as UpdateFragment[];
}
describe(ProjectStatus, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockAppByIdAsync.mockResolvedValue(makeApp());
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
mockWorkflowRunsAsync.mockResolvedValue([makeWorkflowRun()]);
mockProjectStatusSubmissionsAsync.mockResolvedValue([makeSubmission()]);
mockViewUpdateGroupsAsync.mockResolvedValue([makeUpdateGroup()]);
});
function createCommand(argv: string[]): ProjectStatus {
const command = new ProjectStatus(argv, mockConfig);
// @ts-expect-error getContextAsync is stubbed for the test
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('splits production and development build queries by filter', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
limit: 4,
offset: 0,
filter: { developmentClient: false, distribution: DistributionType.Store },
});
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
limit: 4,
offset: 0,
filter: { developmentClient: true },
});
});
it('respects the --limit flag across all sections', async () => {
const command = createCommand(['--limit', '5']);
await command.runAsync();
expect(mockWorkflowRunsAsync).toHaveBeenCalledWith(graphqlClient, projectId, undefined, 6);
expect(mockProjectStatusSubmissionsAsync).toHaveBeenCalledWith(graphqlClient, projectId, {
limit: 6,
offset: 0,
});
expect(mockViewUpdateGroupsAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
limit: 6,
offset: 0,
});
});
it('renders human-readable text output by default without touching JSON output', async () => {
const command = createCommand([]);
await expect(command.runAsync()).resolves.not.toThrow();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('renders build statuses unknown to this CLI version', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([
makeBuild({ status: 'WAITING_FOR_CAPACITY' as BuildStatus }),
]);
const command = createCommand([]);
await command.runAsync();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('waiting for capacity'));
});
it('renders workflow run statuses unknown to this CLI version', async () => {
mockWorkflowRunsAsync.mockResolvedValue([
makeWorkflowRun({ status: 'PENDING_CANCEL' as WorkflowRunStatus }),
]);
const command = createCommand([]);
await command.runAsync();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('pending cancel'));
});
it('handles a project with no activity in any section', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([]);
mockWorkflowRunsAsync.mockResolvedValue([]);
mockProjectStatusSubmissionsAsync.mockResolvedValue([]);
mockViewUpdateGroupsAsync.mockResolvedValue([]);
const command = createCommand([]);
await expect(command.runAsync()).resolves.not.toThrow();
});
it('prints an aggregated JSON snapshot with --json', async () => {
const command = createCommand(['--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledTimes(1);
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(output.generatedAt).toEqual(expect.any(String));
expect(output.limit).toBe(3);
expect(output.hasMore).toEqual({
productionBuilds: false,
developmentBuilds: false,
workflowRuns: false,
submissions: false,
updates: false,
});
expect(output.project).toMatchObject({
id: projectId,
fullName: '@jester/my-app',
account: 'jester',
url: 'https://expo.dev/accounts/jester/projects/my-app',
});
expect(output.productionBuilds).toHaveLength(1);
expect(output.productionBuilds[0]).toMatchObject({
id: 'build-1',
status: BuildStatus.Finished,
gitCommitMessage: 'Ship it',
url: 'https://expo.dev/accounts/jester/projects/my-app/builds/build-1',
});
expect(output.developmentBuilds).toHaveLength(1);
expect(output.workflowRuns[0]).toMatchObject({
id: 'run-1',
workflowName: 'Publish',
errors: [],
url: 'https://expo.dev/accounts/jester/projects/my-app/workflows/run-1',
});
expect(output.submissions[0]).toMatchObject({
id: 'submission-1',
androidTrack: 'production',
appIdentifier: 'com.example.app',
buildId: 'build-1',
rollout: 50,
createdAt: '2026-07-02T12:00:00.000Z',
url: 'https://expo.dev/accounts/jester/projects/my-app/submissions/submission-1',
});
expect(output.updates[0]).toMatchObject({
group: 'group-1',
branch: 'production',
platforms: ['android', 'ios'],
message: 'Fix crash on launch',
rolloutPercentage: 25,
environment: 'production',
initiatingActor: 'jester',
url: 'https://expo.dev/accounts/jester/projects/my-app/updates/group-1',
});
});
it('includes structured activity errors', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([
makeBuild({
status: BuildStatus.Errored,
error: {
errorCode: 'BUILD_FAILED',
message: 'Gradle failed',
docsUrl: 'https://docs.expo.dev/build-reference/troubleshooting/',
},
}),
]);
mockWorkflowRunsAsync.mockResolvedValue([
makeWorkflowRun({
status: WorkflowRunStatus.Failure,
errors: [{ title: 'Deploy failed', message: 'Deployment did not complete' }],
}),
]);
mockProjectStatusSubmissionsAsync.mockResolvedValue([
makeSubmission({
status: SubmissionStatus.Errored,
error: { errorCode: 'SUBMIT_FAILED', message: 'Store upload failed' },
}),
]);
await createCommand(['--json']).runAsync();
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(output.productionBuilds[0].error).toEqual({
code: 'BUILD_FAILED',
message: 'Gradle failed',
docsUrl: 'https://docs.expo.dev/build-reference/troubleshooting/',
});
expect(output.workflowRuns[0].errors).toEqual([
{ title: 'Deploy failed', message: 'Deployment did not complete' },
]);
expect(output.submissions[0].error).toEqual({
code: 'SUBMIT_FAILED',
message: 'Store upload failed',
});
});
it('reports when more results are available and returns only the requested limit', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeBuild()));
mockWorkflowRunsAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeWorkflowRun()));
mockProjectStatusSubmissionsAsync.mockResolvedValue(
Array.from({ length: 4 }, () => makeSubmission())
);
mockViewUpdateGroupsAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeUpdateGroup()));
await createCommand(['--json']).runAsync();
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(output.hasMore).toEqual({
productionBuilds: true,
developmentBuilds: true,
workflowRuns: true,
submissions: true,
updates: true,
});
expect(output.productionBuilds).toHaveLength(3);
expect(output.developmentBuilds).toHaveLength(3);
expect(output.workflowRuns).toHaveLength(3);
expect(output.submissions).toHaveLength(3);
expect(output.updates).toHaveLength(3);
});
});
@@ -0,0 +1,65 @@
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import {
PROJECT_STATUS_DEFAULT_LIMIT,
getProjectStatusAsync,
printProjectStatusAsText,
} from '../../project/projectStatus';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ProjectStatus extends EasCommand {
static override description =
'show a snapshot of the project: recent builds, dev builds, workflow runs, submissions, and updates';
static override aliases = ['status'];
static override examples = [
'$ eas status \t # Show a concise project snapshot',
'$ eas status --json \t # Output a machine-readable snapshot for agents and automation',
'$ eas status --json --limit 10 \t # Include more activity from each section',
];
static override flags = {
limit: getLimitFlagWithCustomValues({
defaultTo: PROJECT_STATUS_DEFAULT_LIMIT,
limit: 25,
description: `The number of items to show in each section. Defaults to ${PROJECT_STATUS_DEFAULT_LIMIT} and is capped at 25.`,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ProjectStatus);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ProjectStatus, { nonInteractive });
const status = await getProjectStatusAsync(graphqlClient, {
projectId,
limit: flags.limit ?? PROJECT_STATUS_DEFAULT_LIMIT,
});
if (jsonFlag) {
printJsonOnlyOutput(status);
return;
}
printProjectStatusAsText(status);
}
}
@@ -509,7 +509,7 @@ describe(Simulator, () => {
);
});
it('forwards --device to the create mutation as deviceIdentifier', async () => {
it('forwards --device in the iOS create options', async () => {
const { command } = createCommand([
'--platform',
'ios',
@@ -521,7 +521,23 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro' } })
);
});
it('forwards --device in the Android create options', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--device',
'pixel_9',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ android: { deviceIdentifier: 'pixel_9' } })
);
});
@@ -537,7 +553,7 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro' } })
);
});
@@ -545,10 +561,7 @@ describe(Simulator, () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--device', ' ']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: undefined })
);
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('ios');
});
it('omits resourceClass when --resource-class is not set', async () => {
@@ -18,6 +18,7 @@ import {
import {
deviceRunSessionTypeToFlagValue,
formatRemoteSessionInstructions,
sanitizeRemoteConfigForJson,
} from '../../simulator/utils';
import { formatBytes } from '../../utils/files';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
@@ -96,7 +97,9 @@ export default class SimulatorGet extends EasCommand {
finishedAt: session.finishedAt ?? undefined,
updatedAt: session.updatedAt,
deviceRunSessionUrl,
remoteConfig: session.remoteConfig,
remoteConfig: session.remoteConfig
? sanitizeRemoteConfigForJson(session.remoteConfig)
: session.remoteConfig,
artifacts: session.artifacts,
});
return;
@@ -38,6 +38,7 @@ import {
formatRemoteSessionInstructions,
formatSimulatorUnavailableMessage,
getRemoteSessionEnvironmentVariables,
sanitizeRemoteConfigForJson,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
@@ -249,7 +250,11 @@ export default class Simulator extends EasCommand {
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
deviceIdentifier,
...(deviceIdentifier
? platform === AppPlatform.Ios
? { ios: { deviceIdentifier } }
: { android: { deviceIdentifier } }
: {}),
...(buildId ? { buildId } : {}),
...(applicationArchiveUrlFromFlag
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
@@ -381,7 +386,7 @@ export default class Simulator extends EasCommand {
name,
type: flags.type,
deviceRunSessionUrl,
remoteConfig,
remoteConfig: sanitizeRemoteConfigForJson(remoteConfig),
});
return;
}
@@ -105,7 +105,8 @@ export const distributionCertificateSchema: CredentialSchema<DistributionCertifi
export type MinimalAscApiKey = {
keyP8: string;
keyId: string;
issuerId: string;
// Absent for individual API keys, which are valid only for submissions.
issuerId?: string;
teamId?: string;
teamName?: string;
roles?: UserRole[];
@@ -115,7 +116,7 @@ export type MinimalAscApiKey = {
export interface AscApiKeyPath {
keyP8Path: string;
keyId: string;
issuerId: string;
issuerId?: string;
}
export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>> = {
@@ -129,7 +130,7 @@ export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>
],
};
export const ascApiKeyIssuerIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'issuerId'>> = {
export const ascApiKeyIssuerIdSchema: CredentialSchema<{ issuerId: string }> = {
name: 'App Store Connect API Key',
questions: [
{
@@ -246,7 +246,11 @@ function displayAscApiKey(
if (name) {
fields.push({ label: 'Name', value: name });
}
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
if (issuerIdentifier) {
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
} else {
fields.push({ label: 'Key Type', value: 'Individual (submissions only)' });
}
if (roles) {
fields.push({ label: 'Roles', value: roles.join(',') });
}
+1804 -865
View File
File diff suppressed because it is too large. Load diff
@@ -9,7 +9,7 @@ import {
GenerateRealtimeLogsCentrifugoSubscriptionTokenMutationVariables,
RealtimeLogsCentrifugoConnectionToken,
RealtimeLogsCentrifugoSubscriptionToken,
RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs,
RealtimeLogsMutation_GenerateCentrifugoSubscriptionTokenArgs,
} from '../generated';
export const RealtimeLogsMutation = {
@@ -39,7 +39,7 @@ export const RealtimeLogsMutation = {
},
async generateCentrifugoSubscriptionTokenAsync(
graphqlClient: ExpoGraphqlClient,
{ target, thread }: RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs
{ target, thread }: RealtimeLogsMutation_GenerateCentrifugoSubscriptionTokenArgs
): Promise<RealtimeLogsCentrifugoSubscriptionToken> {
const data = await withErrorHandlingAsync(
graphqlClient
@@ -12,7 +12,7 @@ export const AppStoreConnectApiKeyQuery = {
graphqlClient: ExpoGraphqlClient,
ascApiKeyId: string
): Promise<{
issuerIdentifier: string;
issuerIdentifier: string | undefined;
keyIdentifier: string;
keyP8: string;
}> {
@@ -42,7 +42,7 @@ export const AppStoreConnectApiKeyQuery = {
const key = data.appStoreConnectApiKey.byId;
return {
issuerIdentifier: key.issuerIdentifier,
issuerIdentifier: key.issuerIdentifier ?? undefined,
keyIdentifier: key.keyIdentifier,
keyP8: key.keyP8,
};
@@ -98,6 +98,7 @@ export const ChannelQuery = {
updateChannelByName(name: $channelName) {
id
isPaused
isProtected
name
updatedAt
createdAt
@@ -144,6 +145,7 @@ export const ChannelQuery = {
updateChannels(offset: $offset, limit: $limit) {
id
isPaused
isProtected
name
updatedAt
createdAt
@@ -87,22 +87,27 @@ export const DeviceRunSessionQuery = {
agentDeviceRemoteSessionUrl
agentDeviceRemoteSessionToken
webPreviewUrl
webPreviewToken
}
... on ArgentRunSessionRemoteConfig {
toolsUrl
toolsAuthToken
webPreviewUrl
webPreviewToken
}
... on AppiumRunSessionRemoteConfig {
appiumUrl
capabilities
webPreviewUrl
webPreviewToken
}
... on ServeSimRunSessionRemoteConfig {
previewUrl
previewToken
}
... on WebPreviewOnlyRunSessionRemoteConfig {
previewUrl: webPreviewUrl
previewToken: webPreviewToken
}
}
turtleJobRun {
@@ -4,51 +4,40 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { withErrorHandlingAsync } from '../client';
import {
AppObserveAppVersion,
AppObserveCustomEvent,
AppObserveCustomEventListFilter,
AppObserveCustomEventListOrderBy,
AppObserveCustomEventName,
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObserveError,
AppObserveErrorGroup,
AppObserveErrorOccurrencesFilter,
AppObserveErrorOccurrencesOrderBy,
AppObserveErrorsGroupsInput,
AppObserveLogsOrderBy,
AppObserveMetric,
AppObserveMetricsListFilter,
AppObserveMetricsListOrderBy,
AppObserveNavigationFilter,
AppObserveNavigationOrderBy,
AppObserveNavigationRoute,
AppObserveNavigationRoutesFilter,
AppObserveNavigationRoutesOrderBy,
AppObservePlatform,
AppObserveReleasesInput,
AppObserveTimeSeriesInput,
AppObserveTimeSeriesStatistics,
AppObserveUserEvent,
AppObserveUserEventListFilter,
AppObserveUserEventListOrderBy,
AppObserveUserEventName,
PageInfo,
} from '../generated';
import { print } from 'graphql';
import {
AppObserveAppVersionFragmentNode,
AppObserveCustomEventFragmentNode,
AppObserveEventFragmentNode,
AppObserveTimeSeriesFragmentNode,
AppObserveErrorFragmentNode,
AppObserveErrorGroupFragmentNode,
AppObserveErrorOccurrenceFragmentNode,
AppObserveMetricFragmentNode,
AppObserveUserEventFragmentNode,
} from '../types/Observe';
export type AppObserveTimeSeriesResult = {
appVersionMarkers: AppObserveAppVersion[];
eventCount: number;
statistics: AppObserveTimeSeriesStatistics;
};
type AppObserveTimeSeriesQuery = {
app: {
byId: {
id: string;
observe: {
timeSeries: AppObserveTimeSeriesResult;
};
};
};
};
type AppObserveTimeSeriesQueryVariables = {
appId: string;
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
};
/** A `session.logs` node is a user event or an error (the `AppObserveLog` interface). */
export type AppObserveSessionLog =
| ({ __typename: 'AppObserveUserEvent' } & AppObserveUserEvent)
| ({ __typename: 'AppObserveError' } & AppObserveError);
type AppObserveAppVersionsQuery = {
app: {
@@ -66,76 +55,84 @@ type AppObserveAppVersionsQueryVariables = {
input: AppObserveReleasesInput;
};
type AppObserveEventsQuery = {
type AppObserveMetricsListQuery = {
app: {
byId: {
id: string;
observe: {
events: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveEvent;
}>;
metrics: {
list: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveMetric;
}>;
};
};
};
};
};
};
type AppObserveEventsQueryVariables = {
type AppObserveMetricsListQueryVariables = {
appId: string;
filter?: AppObserveEventsFilter;
filter?: AppObserveMetricsListFilter;
first?: number;
after?: string;
orderBy?: AppObserveEventsOrderBy;
orderBy?: AppObserveMetricsListOrderBy;
};
type AppObserveCustomEventListQuery = {
type AppObserveUserEventListQuery = {
app: {
byId: {
id: string;
observe: {
customEventList: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveCustomEvent;
}>;
userEvents: {
list: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveUserEvent;
}>;
};
};
};
};
};
};
type AppObserveCustomEventListQueryVariables = {
type AppObserveUserEventListQueryVariables = {
appId: string;
filter?: AppObserveCustomEventListFilter;
filter?: AppObserveUserEventListFilter;
first?: number;
after?: string;
orderBy?: AppObserveCustomEventListOrderBy;
orderBy?: AppObserveUserEventListOrderBy;
};
type AppObserveCustomEventNamesQuery = {
type AppObserveUserEventNamesQuery = {
app: {
byId: {
id: string;
observe: {
customEventNames: {
isTruncated: boolean;
names: AppObserveCustomEventName[];
userEvents: {
names: {
isTruncated: boolean;
names: AppObserveUserEventName[];
};
};
};
};
};
};
type AppObserveCustomEventNamesQueryVariables = {
type AppObserveUserEventNamesQueryVariables = {
appId: string;
startTime: string;
endTime: string;
platform?: AppObservePlatform;
environment?: string;
input: {
startTime: string;
endTime: string;
platforms?: AppObservePlatform[];
environment?: string;
};
};
type AppObserveNavigationRoutesQuery = {
@@ -143,12 +140,14 @@ type AppObserveNavigationRoutesQuery = {
byId: {
id: string;
observe: {
navigationRoutes: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveNavigationRoute;
}>;
navigation: {
routes: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveNavigationRoute;
}>;
};
};
};
};
@@ -157,86 +156,139 @@ type AppObserveNavigationRoutesQuery = {
type AppObserveNavigationRoutesQueryVariables = {
appId: string;
filter: AppObserveNavigationRoutesFilter;
filter: AppObserveNavigationFilter;
first?: number;
after?: string;
orderBy?: AppObserveNavigationRoutesOrderBy;
orderBy?: AppObserveNavigationOrderBy;
};
type AppObserveErrorGroupsQuery = {
app: {
byId: {
id: string;
observe: {
errors: {
groups: {
isTruncated: boolean;
groups: AppObserveErrorGroup[];
};
};
};
};
};
};
type AppObserveErrorGroupsQueryVariables = {
appId: string;
input: AppObserveErrorsGroupsInput;
};
type AppObserveErrorOccurrencesQuery = {
app: {
byId: {
id: string;
observe: {
errors: {
occurrences: {
pageInfo: PageInfo;
edges: Array<{ cursor: string; node: AppObserveError }>;
};
};
};
};
};
};
type AppObserveErrorOccurrencesQueryVariables = {
appId: string;
filter: AppObserveErrorOccurrencesFilter;
first?: number;
after?: string;
orderBy?: AppObserveErrorOccurrencesOrderBy;
};
type AppObserveSessionEventsQuery = {
app: {
byId: {
id: string;
observe: {
session: {
id: string;
metrics: {
pageInfo: PageInfo;
edges: Array<{ node: AppObserveMetric }>;
};
logs: {
pageInfo: PageInfo;
edges: Array<{ node: AppObserveSessionLog }>;
};
};
};
};
};
};
type AppObserveSessionEventsQueryVariables = {
appId: string;
id: string;
first?: number;
metricsOrderBy?: AppObserveMetricsListOrderBy;
logsOrderBy?: AppObserveLogsOrderBy;
};
type AppObserveMetricByIdQuery = {
app: {
byId: {
id: string;
observe: {
metrics: {
metric: AppObserveMetric | null;
};
};
};
};
};
type AppObserveLogByIdQuery = {
app: {
byId: {
id: string;
observe: {
log: AppObserveSessionLog | null;
};
};
};
};
type AppObserveByIdQueryVariables = {
appId: string;
id: string;
};
export const ObserveQuery = {
async timeSeriesAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
metricName,
platform,
startTime,
endTime,
}: {
appId: string;
metricName: string;
platform: AppObservePlatform;
startTime: string;
endTime: string;
}
): Promise<AppObserveTimeSeriesResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
gql`
query AppObserveTimeSeries(
$appId: String!
$input: AppObserveTimeSeriesInput!
) {
app {
byId(appId: $appId) {
id
observe {
timeSeries(input: $input) {
...AppObserveTimeSeriesFragment
}
}
}
}
}
${print(AppObserveAppVersionFragmentNode)}
${print(AppObserveTimeSeriesFragmentNode)}
`,
{
appId,
input: { metricName, platform, startTime, endTime },
}
)
.toPromise()
);
return data.app.byId.observe.timeSeries;
},
async appVersionsAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
platform,
platforms,
startTime,
endTime,
metricNames,
environment,
}: {
appId: string;
platform: AppObservePlatform;
platforms: AppObservePlatform[];
startTime: string;
endTime: string;
metricNames?: string[];
environment?: string;
}
): Promise<AppObserveAppVersion[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveAppVersionsQuery, AppObserveAppVersionsQueryVariables>(
gql`
query AppObserveAppVersions(
$appId: String!
$input: AppObserveReleasesInput!
) {
query AppObserveAppVersions($appId: String!, $input: AppObserveReleasesInput!) {
app {
byId(appId: $appId) {
id
@@ -252,7 +304,13 @@ export const ObserveQuery = {
`,
{
appId,
input: { platform, startTime, endTime, ...(metricNames && { metricNames }) },
input: {
platforms,
startTime,
endTime,
...(metricNames && { metricNames }),
...(environment && { environment }),
},
}
)
.toPromise()
@@ -263,39 +321,36 @@ export const ObserveQuery = {
async eventsAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveEventsQueryVariables
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
variables: AppObserveMetricsListQueryVariables
): Promise<{ events: AppObserveMetric[]; pageInfo: PageInfo }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
.query<AppObserveMetricsListQuery, AppObserveMetricsListQueryVariables>(
gql`
query AppObserveEvents(
query AppObserveMetricsList(
$appId: String!
$filter: AppObserveEventsFilter
$filter: AppObserveMetricsListFilter
$first: Int
$after: String
$orderBy: AppObserveEventsOrderBy
$orderBy: AppObserveMetricsListOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
events(
filter: $filter
first: $first
after: $after
orderBy: $orderBy
) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
...AppObserveEventFragment
metrics {
list(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
...AppObserveMetricFragment
}
}
}
}
@@ -303,14 +358,14 @@ export const ObserveQuery = {
}
}
}
${print(AppObserveEventFragmentNode)}
${print(AppObserveMetricFragmentNode)}
`,
variables
)
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.events;
const { edges, pageInfo } = data.app.byId.observe.metrics.list;
return {
events: edges.map(edge => edge.node),
pageInfo,
@@ -319,39 +374,36 @@ export const ObserveQuery = {
async customEventListAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveCustomEventListQueryVariables
): Promise<{ events: AppObserveCustomEvent[]; pageInfo: PageInfo }> {
variables: AppObserveUserEventListQueryVariables
): Promise<{ events: AppObserveUserEvent[]; pageInfo: PageInfo }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveCustomEventListQuery, AppObserveCustomEventListQueryVariables>(
.query<AppObserveUserEventListQuery, AppObserveUserEventListQueryVariables>(
gql`
query AppObserveCustomEventList(
query AppObserveUserEventList(
$appId: String!
$filter: AppObserveCustomEventListFilter
$filter: AppObserveUserEventListFilter
$first: Int
$after: String
$orderBy: AppObserveCustomEventListOrderBy
$orderBy: AppObserveUserEventListOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
customEventList(
filter: $filter
first: $first
after: $after
orderBy: $orderBy
) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
...AppObserveCustomEventFragment
userEvents {
list(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
...AppObserveUserEventFragment
}
}
}
}
@@ -359,14 +411,14 @@ export const ObserveQuery = {
}
}
}
${print(AppObserveCustomEventFragmentNode)}
${print(AppObserveUserEventFragmentNode)}
`,
variables
)
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.customEventList;
const { edges, pageInfo } = data.app.byId.observe.userEvents.list;
return {
events: edges.map(edge => edge.node),
pageInfo,
@@ -379,41 +431,32 @@ export const ObserveQuery = {
appId,
startTime,
endTime,
platform,
platforms,
environment,
}: {
appId: string;
startTime: string;
endTime: string;
platform?: AppObservePlatform;
platforms?: AppObservePlatform[];
environment?: string;
}
): Promise<{ names: AppObserveCustomEventName[]; isTruncated: boolean }> {
): Promise<{ names: AppObserveUserEventName[]; isTruncated: boolean }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveCustomEventNamesQuery, AppObserveCustomEventNamesQueryVariables>(
.query<AppObserveUserEventNamesQuery, AppObserveUserEventNamesQueryVariables>(
gql`
query AppObserveCustomEventNames(
$appId: String!
$startTime: DateTime!
$endTime: DateTime!
$platform: AppObservePlatform
$environment: String
) {
query AppObserveUserEventNames($appId: String!, $input: AppObserveUserEventNamesInput!) {
app {
byId(appId: $appId) {
id
observe {
customEventNames(
startTime: $startTime
endTime: $endTime
platform: $platform
environment: $environment
) {
isTruncated
names {
eventName
count
userEvents {
names(input: $input) {
isTruncated
names {
name
count
}
}
}
}
@@ -423,16 +466,18 @@ export const ObserveQuery = {
`,
{
appId,
startTime,
endTime,
...(platform && { platform }),
...(environment && { environment }),
input: {
startTime,
endTime,
...(platforms?.length && { platforms }),
...(environment && { environment }),
},
}
)
.toPromise()
);
return data.app.byId.observe.customEventNames;
return data.app.byId.observe.userEvents.names;
},
async navigationRoutesAsync(
@@ -445,39 +490,41 @@ export const ObserveQuery = {
gql`
query AppObserveNavigationRoutes(
$appId: String!
$filter: AppObserveNavigationRoutesFilter!
$filter: AppObserveNavigationFilter!
$first: Int
$after: String
$orderBy: AppObserveNavigationRoutesOrderBy
$orderBy: AppObserveNavigationOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
navigationRoutes(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
routeName
coldTtr {
count
median
p90
}
warmTtr {
count
median
p90
}
tti {
count
median
p90
navigation {
routes(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
routeName
coldTtr {
count
median
p90
}
warmTtr {
count
median
p90
}
tti {
count
median
p90
}
}
}
}
@@ -492,10 +539,255 @@ export const ObserveQuery = {
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.navigationRoutes;
const { edges, pageInfo } = data.app.byId.observe.navigation.routes;
return {
routes: edges.map(edge => edge.node),
pageInfo,
};
},
async errorGroupsAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, input }: { appId: string; input: AppObserveErrorsGroupsInput }
): Promise<{ groups: AppObserveErrorGroup[]; isTruncated: boolean }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveErrorGroupsQuery, AppObserveErrorGroupsQueryVariables>(
gql`
query AppObserveErrorGroups($appId: String!, $input: AppObserveErrorsGroupsInput!) {
app {
byId(appId: $appId) {
id
observe {
errors {
groups(input: $input) {
isTruncated
groups {
...AppObserveErrorGroupFragment
}
}
}
}
}
}
}
${print(AppObserveErrorGroupFragmentNode)}
`,
{ appId, input }
)
.toPromise()
);
return data.app.byId.observe.errors.groups;
},
async errorOccurrencesAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveErrorOccurrencesQueryVariables
): Promise<{ occurrences: AppObserveError[]; pageInfo: PageInfo }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveErrorOccurrencesQuery, AppObserveErrorOccurrencesQueryVariables>(
gql`
query AppObserveErrorOccurrences(
$appId: String!
$filter: AppObserveErrorOccurrencesFilter
$first: Int
$after: String
$orderBy: AppObserveErrorOccurrencesOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
errors {
occurrences(
filter: $filter
first: $first
after: $after
orderBy: $orderBy
) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
...AppObserveErrorOccurrenceFragment
}
}
}
}
}
}
}
}
${print(AppObserveErrorOccurrenceFragmentNode)}
`,
variables
)
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.errors.occurrences;
return {
occurrences: edges.map(edge => edge.node),
pageInfo,
};
},
async sessionEventsAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveSessionEventsQueryVariables
): Promise<{
metrics: AppObserveMetric[];
logs: AppObserveSessionLog[];
metricsPageInfo: PageInfo;
logsPageInfo: PageInfo;
}> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveSessionEventsQuery, AppObserveSessionEventsQueryVariables>(
gql`
query AppObserveSessionEvents(
$appId: String!
$id: ID!
$first: Int
$metricsOrderBy: AppObserveMetricsListOrderBy
$logsOrderBy: AppObserveLogsOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
session(id: $id) {
id
metrics(first: $first, orderBy: $metricsOrderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
node {
id
...AppObserveMetricFragment
}
}
}
logs(first: $first, orderBy: $logsOrderBy) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
node {
id
__typename
... on AppObserveUserEvent {
...AppObserveUserEventFragment
}
... on AppObserveError {
...AppObserveErrorFragment
}
}
}
}
}
}
}
}
}
${print(AppObserveMetricFragmentNode)}
${print(AppObserveUserEventFragmentNode)}
${print(AppObserveErrorFragmentNode)}
`,
variables
)
.toPromise()
);
const { metrics, logs } = data.app.byId.observe.session;
return {
metrics: metrics.edges.map(edge => edge.node),
logs: logs.edges.map(edge => edge.node),
metricsPageInfo: metrics.pageInfo,
logsPageInfo: logs.pageInfo,
};
},
async metricByIdAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, id }: AppObserveByIdQueryVariables
): Promise<AppObserveMetric | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveMetricByIdQuery, AppObserveByIdQueryVariables>(
gql`
query AppObserveMetricById($appId: String!, $id: ID!) {
app {
byId(appId: $appId) {
id
observe {
metrics {
metric(id: $id) {
id
...AppObserveMetricFragment
}
}
}
}
}
}
${print(AppObserveMetricFragmentNode)}
`,
{ appId, id }
)
.toPromise()
);
return data.app.byId.observe.metrics.metric ?? null;
},
async logByIdAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, id }: AppObserveByIdQueryVariables
): Promise<AppObserveSessionLog | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveLogByIdQuery, AppObserveByIdQueryVariables>(
gql`
query AppObserveLogById($appId: String!, $id: ID!) {
app {
byId(appId: $appId) {
id
observe {
log(id: $id) {
id
__typename
... on AppObserveUserEvent {
...AppObserveUserEventFragment
}
... on AppObserveError {
...AppObserveErrorFragment
}
}
}
}
}
}
${print(AppObserveUserEventFragmentNode)}
${print(AppObserveErrorFragmentNode)}
`,
{ appId, id }
)
.toPromise()
);
return data.app.byId.observe.log ?? null;
},
};
@@ -7,6 +7,8 @@ import {
AppPlatform,
GetAllSubmissionsForAppQuery,
GetAllSubmissionsForAppQueryVariables,
GetProjectStatusSubmissionsQuery,
GetProjectStatusSubmissionsQueryVariables,
SubmissionByIdWithSubmittedBuildQuery,
SubmissionByIdWithSubmittedBuildQueryVariables,
SubmissionFragment,
@@ -25,6 +27,9 @@ type Filters = {
limit?: number;
};
export type ProjectStatusSubmission =
GetProjectStatusSubmissionsQuery['app']['byId']['submissions'][number];
export const SubmissionQuery = {
async byIdAsync(
graphqlClient: ExpoGraphqlClient,
@@ -125,4 +130,57 @@ export const SubmissionQuery = {
return data.app?.byId.submissions ?? [];
},
async forProjectStatusAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
{ limit, offset }: { limit: number; offset: number }
): Promise<ProjectStatusSubmission[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetProjectStatusSubmissionsQuery, GetProjectStatusSubmissionsQueryVariables>(
gql`
query GetProjectStatusSubmissions($appId: String!, $offset: Int!, $limit: Int!) {
app {
byId(appId: $appId) {
id
submissions(filter: {}, offset: $offset, limit: $limit) {
id
status
platform
createdAt
completedAt
initiatingActor {
id
displayName
}
submittedBuild {
id
}
androidConfig {
applicationIdentifier
track
releaseStatus
rollout
}
iosConfig {
ascAppIdentifier
}
error {
errorCode
message
}
}
}
}
}
`,
{ appId, offset, limit },
{ additionalTypenames: ['Submission'] }
)
.toPromise()
);
return data.app?.byId.submissions ?? [];
},
};
@@ -0,0 +1,69 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { ChannelQuery } from '../ChannelQuery';
function makeGraphqlClient(data: unknown): {
graphqlClient: ExpoGraphqlClient;
query: jest.Mock;
} {
const query = jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
});
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
}
describe(ChannelQuery.viewUpdateChannelAsync.name, () => {
it('requests and returns channel protection state', async () => {
const channel = {
id: 'channel-id',
name: 'production',
isPaused: false,
isProtected: true,
updatedAt: '2026-08-31T00:00:00.000Z',
createdAt: '2026-08-31T00:00:00.000Z',
branchMapping: '{"version":0,"data":[]}',
updateBranches: [],
};
const { graphqlClient, query } = makeGraphqlClient({
app: { byId: { id: 'app-id', updateChannelByName: channel } },
});
await expect(
ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: 'app-id',
channelName: 'production',
})
).resolves.toEqual(channel);
expect(query.mock.calls[0][0].loc.source.body).toContain('isProtected');
});
});
describe(ChannelQuery.viewUpdateChannelsOnAppAsync.name, () => {
it('requests and returns protection state for every channel', async () => {
const channels = [
{
id: 'channel-id',
name: 'production',
isPaused: false,
isProtected: true,
updatedAt: '2026-08-31T00:00:00.000Z',
createdAt: '2026-08-31T00:00:00.000Z',
branchMapping: '{"version":0,"data":[]}',
updateBranches: [],
},
];
const { graphqlClient, query } = makeGraphqlClient({
app: { byId: { id: 'app-id', updateChannels: channels } },
});
await expect(
ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
appId: 'app-id',
limit: 10,
offset: 0,
})
).resolves.toEqual(channels);
expect(query.mock.calls[0][0].loc.source.body).toContain('isProtected');
});
});
+86 -25
View File
@@ -1,27 +1,9 @@
import gql from 'graphql-tag';
export const AppObserveTimeSeriesFragmentNode = gql`
fragment AppObserveTimeSeriesFragment on AppObserveTimeSeries {
appVersionMarkers {
...AppObserveAppVersionFragment
}
eventCount
statistics {
min
max
median
average
p80
p90
p99
}
}
`;
export const AppObserveCustomEventFragmentNode = gql`
fragment AppObserveCustomEventFragment on AppObserveCustomEvent {
export const AppObserveUserEventFragmentNode = gql`
fragment AppObserveUserEventFragment on AppObserveUserEvent {
id
eventName
name
timestamp
sessionId
severityNumber
@@ -44,11 +26,73 @@ export const AppObserveCustomEventFragmentNode = gql`
}
`;
export const AppObserveEventFragmentNode = gql`
fragment AppObserveEventFragment on AppObserveEvent {
export const AppObserveErrorFragmentNode = gql`
fragment AppObserveErrorFragment on AppObserveError {
id
metricName
metricValue
timestamp
sessionId
severityNumber
severityText
type
message
source
fingerprint
isFatal
properties {
key
value
type
}
appVersion
appBuildNumber
appUpdateId
appEasBuildId
deviceOs
deviceOsVersion
deviceModel
environment
easClientId
countryCode
}
`;
export const AppObserveErrorOccurrenceFragmentNode = gql`
fragment AppObserveErrorOccurrenceFragment on AppObserveError {
id
timestamp
sessionId
severityNumber
severityText
type
message
source
fingerprint
isFatal
stacktrace
body
properties {
key
value
type
}
appVersion
appBuildNumber
appUpdateId
appEasBuildId
deviceOs
deviceOsVersion
deviceModel
environment
easClientId
countryCode
}
`;
export const AppObserveMetricFragmentNode = gql`
fragment AppObserveMetricFragment on AppObserveMetric {
id
name
value
timestamp
appVersion
appBuildNumber
@@ -64,6 +108,23 @@ export const AppObserveEventFragmentNode = gql`
}
`;
export const AppObserveErrorGroupFragmentNode = gql`
fragment AppObserveErrorGroupFragment on AppObserveErrorGroup {
fingerprint
exceptionType
exceptionMessage
errorSource
severity
isFatal
eventCount
uniqueUserCount
affectedSessionCount
firstSeenAt
lastSeenAt
platforms
}
`;
export const AppObserveAppVersionFragmentNode = gql`
fragment AppObserveAppVersionFragment on AppObserveAppVersion {
appVersion
@@ -5,5 +5,6 @@ export const UpdateChannelBasicInfoFragmentNode = gql`
id
name
branchMapping
isProtected
}
`;
@@ -1,14 +1,14 @@
import { AppObserveCustomEvent, AppObservePlatform } from '../../graphql/generated';
import { AppObservePlatform, AppObserveUserEvent } from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents';
jest.mock('../../graphql/queries/ObserveQuery');
function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
function makeCustomEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
return {
__typename: 'AppObserveCustomEvent' as const,
__typename: 'AppObserveUserEvent' as const,
id: 'evt-1',
eventName: 'my_event',
name: 'my_event',
timestamp: '2025-01-01T00:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '1',
@@ -18,7 +18,7 @@ function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObs
easClientId: 'client-1',
properties: [],
...overrides,
} as AppObserveCustomEvent;
} as AppObserveUserEvent;
}
describe('fetchObserveCustomEventsAsync', () => {
@@ -57,13 +57,13 @@ describe('fetchObserveCustomEventsAsync', () => {
});
const filter = mockCustomEventListAsync.mock.calls[0][1].filter;
expect(filter?.eventName).toBe('my_event');
expect(filter?.name).toBe('my_event');
});
it('forwards platform, appVersion, and sessionId filters when provided', async () => {
await fetchObserveCustomEventsAsync(mockGraphqlClient, 'project-123', {
limit: 10,
platform: AppObservePlatform.Ios,
platforms: [AppObservePlatform.Ios],
appVersion: '2.1.0',
sessionId: 'session-xyz',
startTime: '2025-01-01T00:00:00.000Z',
@@ -71,7 +71,7 @@ describe('fetchObserveCustomEventsAsync', () => {
});
const filter = mockCustomEventListAsync.mock.calls[0][1].filter;
expect(filter?.platform).toBe(AppObservePlatform.Ios);
expect(filter?.platforms).toEqual([AppObservePlatform.Ios]);
expect(filter?.appVersion).toBe('2.1.0');
expect(filter?.sessionId).toBe('session-xyz');
});
@@ -107,7 +107,7 @@ describe('fetchObserveCustomEventsAsync', () => {
});
const filter = mockCustomEventListAsync.mock.calls[0][1].filter;
expect(filter).not.toHaveProperty('eventName');
expect(filter).not.toHaveProperty('name');
expect(filter).not.toHaveProperty('platform');
expect(filter).not.toHaveProperty('appVersion');
expect(filter).not.toHaveProperty('appUpdateId');
@@ -2,10 +2,9 @@ import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObserveMetricsListOrderByField,
AppObserveOrderDirection,
AppObservePlatform,
AppPlatform,
} from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import {
@@ -15,35 +14,40 @@ import {
resolveOrderBy,
} from '../fetchEvents';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
import { ObservePlatformTarget } from '../platforms';
jest.mock('../../graphql/queries/ObserveQuery');
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
describe(resolveOrderBy, () => {
it('maps "slowest" to METRIC_VALUE DESC', () => {
expect(resolveOrderBy(EventsOrderPreset.Slowest)).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
});
});
it('maps "fastest" to METRIC_VALUE ASC', () => {
expect(resolveOrderBy(EventsOrderPreset.Fastest)).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Asc,
});
});
it('maps "newest" to TIMESTAMP DESC', () => {
expect(resolveOrderBy(EventsOrderPreset.Newest)).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Desc,
});
});
it('maps "oldest" to TIMESTAMP ASC', () => {
expect(resolveOrderBy(EventsOrderPreset.Oldest)).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
});
});
});
@@ -65,8 +69,8 @@ describe(fetchObserveEventsAsync, () => {
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
@@ -77,14 +81,14 @@ describe(fetchObserveEventsAsync, () => {
expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
appId: 'app-123',
filter: {
metricName: 'expo.app_startup.tti',
name: '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,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
});
});
@@ -98,20 +102,20 @@ describe(fetchObserveEventsAsync, () => {
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
limit: 5,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platform: AppObservePlatform.Ios,
platforms: [AppObservePlatform.Ios],
});
expect(mockEventsAsync).toHaveBeenCalledWith(
mockGraphqlClient,
expect.objectContaining({
filter: expect.objectContaining({
platform: AppObservePlatform.Ios,
platforms: [AppObservePlatform.Ios],
}),
})
);
@@ -126,8 +130,8 @@ describe(fetchObserveEventsAsync, () => {
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
@@ -154,8 +158,8 @@ describe(fetchObserveEventsAsync, () => {
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
@@ -182,8 +186,8 @@ describe(fetchObserveEventsAsync, () => {
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
@@ -199,10 +203,10 @@ describe(fetchObserveEventsAsync, () => {
it('returns events and pageInfo from the query result', async () => {
const mockEvents = [
{
__typename: 'AppObserveEvent' as const,
__typename: 'AppObserveMetric' as const,
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -226,8 +230,8 @@ describe(fetchObserveEventsAsync, () => {
const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
@@ -235,7 +239,7 @@ describe(fetchObserveEventsAsync, () => {
});
expect(result.events).toHaveLength(1);
expect(result.events[0].metricValue).toBe(1.23);
expect(result.events[0].value).toBe(1.23);
expect(result.pageInfo.hasNextPage).toBe(true);
});
@@ -248,8 +252,8 @@ describe(fetchObserveEventsAsync, () => {
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
limit: 10,
after: 'cursor-abc',
@@ -272,8 +276,8 @@ describe(fetchObserveEventsAsync, () => {
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
metricName: 'expo.app_startup.tti',
orderBy: {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
},
limit: 10,
startTime: '2025-01-01T00:00:00.000Z',
@@ -316,7 +320,7 @@ describe(fetchTotalEventCountAsync, () => {
mockGraphqlClient,
'project-123',
'expo.navigation.tti',
[AppPlatform.Ios, AppPlatform.Android],
[target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
)
@@ -330,7 +334,7 @@ describe(fetchTotalEventCountAsync, () => {
mockGraphqlClient,
'project-123',
'expo.app_startup.tti',
[AppPlatform.Ios],
[target(AppObservePlatform.Ios)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
@@ -1,15 +1,20 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { AppObserveAppVersion, AppObservePlatform, AppPlatform } from '../../graphql/generated';
import { AppObserveAppVersion, AppObservePlatform } from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { makeMetricsKey } from '../formatMetrics';
import { fetchObserveMetricsAsync } from '../fetchMetrics';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
import { ObservePlatformTarget } from '../platforms';
jest.mock('../../graphql/queries/ObserveQuery');
jest.mock('../../log');
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
function makeAppVersion(
appVersion: string,
metrics: Array<{
@@ -90,7 +95,7 @@ describe('fetchObserveMetricsAsync', () => {
mockGraphqlClient,
'project-123',
['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
[AppPlatform.Ios],
[target(AppObservePlatform.Ios)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
@@ -98,13 +103,13 @@ describe('fetchObserveMetricsAsync', () => {
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(1);
expect(mockAppVersionsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
appId: 'project-123',
platform: AppObservePlatform.Ios,
platforms: [AppObservePlatform.Ios],
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
metricNames: ['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
});
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
expect(metricsMap.has(key)).toBe(true);
const metricsForVersion = metricsMap.get(key)!;
@@ -137,21 +142,21 @@ describe('fetchObserveMetricsAsync', () => {
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Ios, AppPlatform.Android],
[target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(2);
const platforms = mockAppVersionsAsync.mock.calls.map(call => call[1].platform);
const platforms = mockAppVersionsAsync.mock.calls.flatMap(call => call[1].platforms);
expect(platforms).toContain(AppObservePlatform.Ios);
expect(platforms).toContain(AppObservePlatform.Android);
});
it('handles partial failures gracefully', async () => {
mockAppVersionsAsync.mockImplementation(async (_client, { platform }) => {
if (platform === AppObservePlatform.Android) {
mockAppVersionsAsync.mockImplementation(async (_client, { platforms }) => {
if (platforms.includes(AppObservePlatform.Android)) {
throw new Error('Network error');
}
return [
@@ -177,12 +182,12 @@ describe('fetchObserveMetricsAsync', () => {
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Ios, AppPlatform.Android],
[target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
const key = makeMetricsKey('2.0.0', AppObservePlatform.Ios);
expect(metricsMap.has(key)).toBe(true);
expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({
min: 0.1,
@@ -203,7 +208,7 @@ describe('fetchObserveMetricsAsync', () => {
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Ios],
[target(AppObservePlatform.Ios)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
@@ -234,14 +239,14 @@ describe('fetchObserveMetricsAsync', () => {
mockGraphqlClient,
'project-123',
['expo.navigation.tti'],
[AppPlatform.Ios, AppPlatform.Android],
[target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
)
).rejects.toBe(gateError);
});
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
it('uses the target key in metricsMap keys', async () => {
mockAppVersionsAsync.mockResolvedValue([
makeAppVersion('3.0.0', [
{
@@ -264,7 +269,7 @@ describe('fetchObserveMetricsAsync', () => {
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Android],
[target(AppObservePlatform.Android)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
@@ -295,7 +300,7 @@ describe('fetchObserveMetricsAsync', () => {
mockGraphqlClient,
'project-123',
['expo.app_startup.tti'],
[AppPlatform.Ios],
[target(AppObservePlatform.Ios)],
'2025-01-01T00:00:00.000Z',
'2025-03-01T00:00:00.000Z'
);
@@ -6,15 +6,19 @@ import {
AppObserveNavigationRoute,
AppObserveNavigationRoutesOrderByField,
AppObservePlatform,
AppPlatform,
} from '../../graphql/generated';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveNavigationRoutesAsync } from '../fetchNavigationRoutes';
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
import { ObservePlatformTarget } from '../platforms';
jest.mock('../../graphql/queries/ObserveQuery');
jest.mock('../../log');
function target(platform: AppObservePlatform): ObservePlatformTarget {
return { key: platform, platforms: [platform] };
}
function makeRoute(routeName: string): AppObserveNavigationRoute {
return {
__typename: 'AppObserveNavigationRoute' as const,
@@ -41,7 +45,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
targets: [target(AppObservePlatform.Ios)],
limit: 25,
appVersion: '2.1.0',
updateId: 'update-xyz',
@@ -53,7 +57,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
expect(call.appId).toBe('project-123');
expect(call.first).toBe(25);
expect(call.filter).toEqual({
platform: AppObservePlatform.Ios,
platforms: [AppObservePlatform.Ios],
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
appVersion: '2.1.0',
@@ -66,7 +70,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
targets: [target(AppObservePlatform.Ios)],
limit: 50,
});
@@ -81,7 +85,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
targets: [target(AppObservePlatform.Ios)],
limit: 50,
});
@@ -96,7 +100,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
targets: [target(AppObservePlatform.Ios)],
limit: 50,
routeNames: ['/home', '/profile'],
});
@@ -111,7 +115,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
targets: [target(AppObservePlatform.Ios)],
limit: 50,
routeNames: [],
});
@@ -123,7 +127,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios],
targets: [target(AppObservePlatform.Ios)],
limit: 50,
after: 'cursor-abc',
});
@@ -133,7 +137,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
it('fans out across multiple platforms and tags each route with its platform', async () => {
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
if (vars.filter.platform === AppObservePlatform.Android) {
if (vars.filter.platforms?.includes(AppObservePlatform.Android)) {
return {
routes: [makeRoute('/android-home')],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
@@ -151,7 +155,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
{
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios, AppPlatform.Android],
targets: [target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
limit: 50,
}
);
@@ -159,15 +163,15 @@ describe('fetchObserveNavigationRoutesAsync', () => {
expect(mockNavigationRoutesAsync).toHaveBeenCalledTimes(2);
expect(routes).toHaveLength(2);
const byName = new Map(routes.map(r => [r.route.routeName, r.platform]));
expect(byName.get('/android-home')).toBe(AppPlatform.Android);
expect(byName.get('/ios-home')).toBe(AppPlatform.Ios);
expect(pageInfoByPlatform.get(AppPlatform.Ios)?.hasNextPage).toBe(true);
expect(pageInfoByPlatform.get(AppPlatform.Android)?.hasNextPage).toBe(false);
expect(byName.get('/android-home')).toBe(AppObservePlatform.Android);
expect(byName.get('/ios-home')).toBe(AppObservePlatform.Ios);
expect(pageInfoByPlatform.get(AppObservePlatform.Ios)?.hasNextPage).toBe(true);
expect(pageInfoByPlatform.get(AppObservePlatform.Android)?.hasNextPage).toBe(false);
});
it('handles partial failures gracefully', async () => {
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
if (vars.filter.platform === AppObservePlatform.Android) {
if (vars.filter.platforms?.includes(AppObservePlatform.Android)) {
throw new Error('Network error');
}
return {
@@ -182,14 +186,14 @@ describe('fetchObserveNavigationRoutesAsync', () => {
{
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios, AppPlatform.Android],
targets: [target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
limit: 50,
}
);
expect(routes).toHaveLength(1);
expect(routes[0].platform).toBe(AppPlatform.Ios);
expect(pageInfoByPlatform.has(AppPlatform.Android)).toBe(false);
expect(routes[0].platform).toBe(AppObservePlatform.Ios);
expect(pageInfoByPlatform.has(AppObservePlatform.Android)).toBe(false);
});
it('rethrows plan-gate errors instead of swallowing them as a partial failure', async () => {
@@ -214,7 +218,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-03-01T00:00:00.000Z',
platforms: [AppPlatform.Ios, AppPlatform.Android],
targets: [target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
limit: 50,
})
).rejects.toBe(gateError);
@@ -1,10 +1,13 @@
import {
AppObserveCustomEvent,
AppObserveCustomEventListOrderByField,
AppObserveEvent,
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObserveError,
AppObserveLogsOrderByField,
AppObserveMetric,
AppObserveMetricsListOrderByField,
AppObserveOrderDirection,
AppObserveUserEvent,
AppObserveUserEventListOrderByField,
} from '../../graphql/generated';
import { AppObserveSessionLog, ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents';
import { fetchObserveEventsAsync } from '../fetchEvents';
import {
@@ -21,16 +24,20 @@ jest.mock('../fetchEvents', () => {
fetchObserveEventsAsync: jest.fn(),
};
});
jest.mock('../../graphql/queries/ObserveQuery');
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
const mockSessionEventsAsync = jest.mocked(ObserveQuery.sessionEventsAsync);
function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
const noNextPage = { hasNextPage: false, hasPreviousPage: false };
function makeMetricEvent(overrides: Partial<AppObserveMetric> = {}): AppObserveMetric {
return {
__typename: 'AppObserveEvent' as const,
__typename: 'AppObserveMetric' as const,
id: 'evt-m-1',
metricName: 'expo.app_startup.tti',
metricValue: 0.5,
name: 'expo.app_startup.tti',
value: 0.5,
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -43,14 +50,14 @@ function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEv
easClientId: 'client-1',
customParams: null,
...overrides,
} as AppObserveEvent;
} as AppObserveMetric;
}
function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
function makeUserEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
return {
__typename: 'AppObserveCustomEvent' as const,
__typename: 'AppObserveUserEvent' as const,
id: 'evt-c-1',
eventName: 'login_pressed',
name: 'login_pressed',
timestamp: '2025-01-15T10:01:00.000Z',
sessionId: 'session-1',
severityNumber: null,
@@ -67,7 +74,36 @@ function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObs
countryCode: 'US',
properties: [],
...overrides,
} as AppObserveCustomEvent;
} as AppObserveUserEvent;
}
function makeUserLog(overrides: Partial<AppObserveUserEvent> = {}): AppObserveSessionLog {
return makeUserEvent(overrides) as AppObserveSessionLog;
}
function makeErrorLog(overrides: Partial<AppObserveError> = {}): AppObserveSessionLog {
return {
__typename: 'AppObserveError' as const,
id: 'err-1',
type: 'TypeError',
message: 'undefined is not a function',
timestamp: '2025-01-15T10:02:00.000Z',
sessionId: 'session-1',
severityText: 'fatal',
severityNumber: null,
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
appEasBuildId: null,
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
environment: 'production',
easClientId: 'client-1',
countryCode: 'US',
properties: [],
...overrides,
} as AppObserveSessionLog;
}
const baseOptions = {
@@ -77,45 +113,47 @@ const baseOptions = {
describe('fetchObserveSessionEventsAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
mockSessionEventsAsync.mockResolvedValue({
metrics: [],
logs: [],
metricsPageInfo: noNextPage,
logsPageInfo: noNextPage,
});
});
it('forwards sessionId to both event sources and orders metric events oldest-first', async () => {
it('queries the session timeline oldest-first for both metrics and logs', async () => {
await fetchObserveSessionEventsAsync({} as any, 'project-1', {
...baseOptions,
sessionId: 'session-1',
});
expect(mockFetchObserveEventsAsync.mock.calls[0][2].sessionId).toBe('session-1');
expect(mockFetchObserveEventsAsync.mock.calls[0][2].orderBy).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
});
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].sessionId).toBe('session-1');
expect(mockSessionEventsAsync).toHaveBeenCalledWith(
{},
expect.objectContaining({
appId: 'project-1',
id: 'session-1',
first: 100,
metricsOrderBy: {
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
},
logsOrderBy: {
field: AppObserveLogsOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
},
})
);
});
it('returns combined entries sorted chronologically, tagged with their source', async () => {
mockFetchObserveEventsAsync.mockResolvedValue({
events: [makeMetricEvent({ timestamp: '2025-01-15T10:05:00.000Z' })],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
makeCustomEvent({ timestamp: '2025-01-15T10:01:00.000Z' }),
makeCustomEvent({
id: 'evt-c-2',
timestamp: '2025-01-15T10:10:00.000Z',
eventName: 'logout',
}),
mockSessionEventsAsync.mockResolvedValue({
metrics: [makeMetricEvent({ timestamp: '2025-01-15T10:05:00.000Z' })],
logs: [
makeUserLog({ timestamp: '2025-01-15T10:01:00.000Z' }),
makeUserLog({ id: 'evt-c-2', timestamp: '2025-01-15T10:10:00.000Z', name: 'logout' }),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
metricsPageInfo: noNextPage,
logsPageInfo: noNextPage,
});
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
@@ -129,32 +167,41 @@ describe('fetchObserveSessionEventsAsync', () => {
'2025-01-15T10:10:00.000Z',
]);
expect(result.entries.map(e => e.source)).toEqual(['log', 'metric', 'log']);
expect(result.entries.map(e => e.id)).toEqual(['evt-c-1', 'evt-m-1', 'evt-c-2']);
});
it('includes error logs as log entries named by their exception type, surfacing message and properties', async () => {
mockSessionEventsAsync.mockResolvedValue({
metrics: [],
logs: [
makeErrorLog({
properties: [{ key: 'attr', value: 'v', type: 'STRING' }],
} as Partial<AppObserveError>),
],
metricsPageInfo: noNextPage,
logsPageInfo: noNextPage,
});
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
...baseOptions,
sessionId: 'session-1',
});
expect(result.entries).toHaveLength(1);
expect(result.entries[0].source).toBe('log');
expect(result.entries[0].name).toBe('TypeError');
expect(result.entries[0].properties).toEqual([
{ key: 'message', value: 'undefined is not a function', type: 'STRING' },
{ key: 'attr', value: 'v', type: 'STRING' },
]);
});
it('derives session metadata from the entries (first/last timestamps, device, app version)', async () => {
mockFetchObserveEventsAsync.mockResolvedValue({
events: [
makeMetricEvent({
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
deviceModel: 'iPhone 15',
appUpdateId: 'update-xyz',
countryCode: 'US',
}),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
makeCustomEvent({
timestamp: '2025-01-15T10:05:00.000Z',
appUpdateId: 'update-xyz',
}),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
mockSessionEventsAsync.mockResolvedValue({
metrics: [makeMetricEvent({ timestamp: '2025-01-15T10:00:00.000Z' })],
logs: [makeUserLog({ timestamp: '2025-01-15T10:05:00.000Z', appUpdateId: 'update-xyz' })],
metricsPageInfo: noNextPage,
logsPageInfo: noNextPage,
});
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
@@ -184,13 +231,11 @@ describe('fetchObserveSessionEventsAsync', () => {
});
it('reports hasMore* flags from the underlying page info', async () => {
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cm' },
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
mockSessionEventsAsync.mockResolvedValue({
metrics: [],
logs: [],
metricsPageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cm' },
logsPageInfo: noNextPage,
});
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
@@ -208,7 +253,7 @@ describe('fetchSessionMetricCandidatesAsync', () => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
pageInfo: noNextPage,
});
});
@@ -227,8 +272,8 @@ describe('fetchSessionMetricCandidatesAsync', () => {
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
expect(options.orderBy).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
});
});
@@ -239,7 +284,7 @@ describe('fetchSessionMetricCandidatesAsync', () => {
makeMetricEvent({ id: 'evt-2', sessionId: null }),
makeMetricEvent({ id: 'evt-3', sessionId: 'session-b' }),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
pageInfo: noNextPage,
});
const result = await fetchSessionMetricCandidatesAsync({} as any, 'project-1', {
@@ -259,7 +304,7 @@ describe('fetchSessionLogCandidatesAsync', () => {
jest.clearAllMocks();
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
pageInfo: noNextPage,
});
});
@@ -289,8 +334,8 @@ describe('fetchSessionLogCandidatesAsync', () => {
});
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].orderBy).toEqual({
field: AppObserveCustomEventListOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveUserEventListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Desc,
});
});
@@ -304,19 +349,19 @@ describe('fetchSessionLogCandidatesAsync', () => {
});
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].orderBy).toEqual({
field: AppObserveCustomEventListOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveUserEventListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
});
});
it('preserves the server-provided order (no client-side re-sorting)', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
makeCustomEvent({ id: 'c', timestamp: '2025-01-15T10:10:00.000Z' }),
makeCustomEvent({ id: 'b', timestamp: '2025-01-15T10:05:00.000Z' }),
makeCustomEvent({ id: 'a', timestamp: '2025-01-15T10:00:00.000Z' }),
makeUserEvent({ id: 'c', timestamp: '2025-01-15T10:10:00.000Z' }),
makeUserEvent({ id: 'b', timestamp: '2025-01-15T10:05:00.000Z' }),
makeUserEvent({ id: 'a', timestamp: '2025-01-15T10:00:00.000Z' }),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
pageInfo: noNextPage,
});
const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
@@ -332,10 +377,10 @@ describe('fetchSessionLogCandidatesAsync', () => {
it('filters out events without a sessionId', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
makeCustomEvent({ id: 'a', sessionId: 'session-a' }),
makeCustomEvent({ id: 'b', sessionId: null }),
makeUserEvent({ id: 'a', sessionId: 'session-a' }),
makeUserEvent({ id: 'b', sessionId: null }),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
pageInfo: noNextPage,
});
const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
@@ -1,4 +1,4 @@
import { AppObserveCustomEvent, PageInfo } from '../../graphql/generated';
import { AppObserveUserEvent, PageInfo } from '../../graphql/generated';
import {
buildObserveCustomEventNamesTable,
buildObserveCustomEventsEmptyWithSuggestionsJson,
@@ -7,11 +7,11 @@ import {
buildObserveCustomEventsTable,
} from '../formatCustomEvents';
function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
function makeCustomEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
return {
__typename: 'AppObserveCustomEvent' as const,
__typename: 'AppObserveUserEvent' as const,
id: 'evt-1',
eventName: 'my_event',
name: 'my_event',
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -21,7 +21,7 @@ function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObs
easClientId: 'client-1',
properties: [],
...overrides,
} as AppObserveCustomEvent;
} as AppObserveUserEvent;
}
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
@@ -35,7 +35,7 @@ describe(buildObserveCustomEventsTable, () => {
it('formats events into aligned columns', () => {
const events = [
makeCustomEvent({
eventName: 'login',
name: 'login',
appVersion: '1.2.0',
appBuildNumber: '42',
deviceOs: 'iOS',
@@ -46,7 +46,7 @@ describe(buildObserveCustomEventsTable, () => {
}),
makeCustomEvent({
id: 'evt-2',
eventName: 'checkout',
name: 'checkout',
appVersion: '1.1.0',
appBuildNumber: '38',
deviceOs: 'Android',
@@ -73,7 +73,7 @@ Jan 14, 2025, 08:15:00.000 AM checkout 1.1.0 (38) Android 14 Pixel 8 PL
});
it('shows event name in summary header when an event name option is provided', () => {
const events = [makeCustomEvent({ eventName: 'login' })];
const events = [makeCustomEvent({ name: 'login' })];
const output = buildObserveCustomEventsTable(events, noNextPage, {
eventName: 'login',
daysBack: 30,
@@ -137,7 +137,7 @@ Jan 14, 2025, 08:15:00.000 AM checkout 1.1.0 (38) Android 14 Pixel 8 PL
});
it('omits the Event column when an event name option is provided (single-event view)', () => {
const events = [makeCustomEvent({ eventName: 'login' })];
const events = [makeCustomEvent({ name: 'login' })];
const output = buildObserveCustomEventsTable(events, noNextPage, {
eventName: 'login',
});
@@ -212,7 +212,7 @@ describe(buildObserveCustomEventsJson, () => {
const events = [
makeCustomEvent({
id: 'evt-1',
eventName: 'login',
name: 'login',
timestamp: '2025-01-15T10:30:00.000Z',
sessionId: 'session-1',
severityText: 'INFO',
@@ -239,7 +239,7 @@ describe(buildObserveCustomEventsJson, () => {
events: [
{
id: 'evt-1',
eventName: 'login',
name: 'login',
timestamp: '2025-01-15T10:30:00.000Z',
sessionId: 'session-1',
severityText: 'INFO',
@@ -299,8 +299,8 @@ describe(buildObserveCustomEventNamesTable, () => {
it('shows event names with counts', () => {
const output = buildObserveCustomEventNamesTable([
{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 },
{ __typename: 'AppObserveCustomEventName', eventName: 'bar', count: 5 },
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
]);
expect(output).toContain('foo');
expect(output).toContain('10');
@@ -310,7 +310,7 @@ describe(buildObserveCustomEventNamesTable, () => {
it('appends a truncation notice when isTruncated is true', () => {
const output = buildObserveCustomEventNamesTable(
[{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 }],
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
{ isTruncated: true }
);
expect(output).toContain('Result is truncated');
@@ -320,8 +320,8 @@ describe(buildObserveCustomEventNamesTable, () => {
describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('shows the filtered event name and the available event names', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable('login', [
{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 },
{ __typename: 'AppObserveCustomEventName', eventName: 'bar', count: 5 },
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
]);
expect(output).toContain('No events found matching "login"');
@@ -342,7 +342,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('appends a truncation notice when isTruncated is set', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
'login',
[{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 }],
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
{ isTruncated: true }
);
@@ -352,7 +352,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('includes the time range description in the message when provided', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
'login',
[{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 }],
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
{ daysBack: 7 }
);
@@ -365,8 +365,8 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsJson, () => {
const result = buildObserveCustomEventsEmptyWithSuggestionsJson(
'login',
[
{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 },
{ __typename: 'AppObserveCustomEventName', eventName: 'bar', count: 5 },
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
],
false
);
@@ -375,8 +375,8 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsJson, () => {
filteredEventName: 'login',
events: [],
availableEventNames: [
{ eventName: 'foo', count: 10 },
{ eventName: 'bar', count: 5 },
{ name: 'foo', count: 10 },
{ name: 'bar', count: 5 },
],
availableEventNamesIsTruncated: false,
});
@@ -0,0 +1,129 @@
import { AppObserveError, AppObserveErrorGroup, PageInfo } from '../../graphql/generated';
import {
buildObserveErrorGroupsJson,
buildObserveErrorGroupsTable,
buildObserveErrorOccurrencesJson,
buildObserveErrorOccurrencesTable,
} from '../formatErrors';
function makeErrorGroup(overrides: Partial<AppObserveErrorGroup> = {}): AppObserveErrorGroup {
return {
__typename: 'AppObserveErrorGroup' as const,
fingerprint: 'fp-1',
exceptionType: 'TypeError',
exceptionMessage: 'undefined is not a function',
errorSource: 'js',
severity: 'FATAL',
isFatal: true,
eventCount: 42,
uniqueUserCount: 10,
affectedSessionCount: 8,
firstSeenAt: '2025-01-01T00:00:00.000Z',
lastSeenAt: '2025-01-15T00:00:00.000Z',
platforms: ['IOS'],
...overrides,
} as AppObserveErrorGroup;
}
function makeErrorOccurrence(overrides: Partial<AppObserveError> = {}): AppObserveError {
return {
__typename: 'AppObserveError' as const,
id: 'err-1',
type: 'TypeError',
message: 'undefined is not a function',
source: 'js',
fingerprint: 'fp-1',
severityText: 'fatal',
severityNumber: null,
isFatal: true,
stacktrace: 'at foo (app.js:1:1)\nat bar (app.js:2:2)',
body: null,
timestamp: '2025-01-15T10:00:00.000Z',
sessionId: 'session-1',
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
appEasBuildId: null,
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
environment: 'production',
easClientId: 'client-1',
countryCode: 'US',
properties: [],
...overrides,
} as AppObserveError;
}
const noNextPage = {
__typename: 'PageInfo' as const,
hasNextPage: false,
hasPreviousPage: false,
endCursor: null,
} as PageInfo;
describe(buildObserveErrorGroupsTable, () => {
it('renders a placeholder when there are no error groups', () => {
expect(buildObserveErrorGroupsTable([])).toContain('No errors found');
});
it('renders the fingerprint, exception type, message, and counts', () => {
const output = buildObserveErrorGroupsTable([makeErrorGroup()]);
expect(output).toContain('fp-1');
expect(output).toContain('TypeError');
expect(output).toContain('undefined is not a function');
expect(output).toContain('42');
expect(output).toContain('--fingerprint');
});
});
describe(buildObserveErrorOccurrencesTable, () => {
it('renders a placeholder when there are no occurrences', () => {
const output = buildObserveErrorOccurrencesTable([], noNextPage, { fingerprint: 'fp-1' });
expect(output).toContain('No occurrences found');
expect(output).toContain('fp-1');
});
it('renders occurrence details including the stack trace', () => {
const output = buildObserveErrorOccurrencesTable([makeErrorOccurrence()], noNextPage, {
fingerprint: 'fp-1',
});
expect(output).toContain('Stack trace');
expect(output).toContain('at foo (app.js:1:1)');
expect(output).toContain('TypeError');
});
});
describe(buildObserveErrorOccurrencesJson, () => {
it('includes the stack trace and properties in the JSON', () => {
const result = buildObserveErrorOccurrencesJson([makeErrorOccurrence()], noNextPage);
expect(result.occurrences[0].stacktrace).toBe('at foo (app.js:1:1)\nat bar (app.js:2:2)');
expect(result.occurrences[0].id).toBe('err-1');
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
});
});
describe(buildObserveErrorGroupsJson, () => {
it('maps error-group fields into stable JSON keys', () => {
const result = buildObserveErrorGroupsJson([makeErrorGroup()], false);
expect(result).toEqual({
isTruncated: false,
errors: [
{
fingerprint: 'fp-1',
type: 'TypeError',
message: 'undefined is not a function',
source: 'js',
severity: 'FATAL',
isFatal: true,
eventCount: 42,
uniqueUserCount: 10,
affectedSessionCount: 8,
firstSeenAt: '2025-01-01T00:00:00.000Z',
lastSeenAt: '2025-01-15T00:00:00.000Z',
platforms: ['IOS'],
},
],
});
});
});
@@ -1,12 +1,12 @@
import { AppObserveEvent, PageInfo } from '../../graphql/generated';
import { AppObserveMetric, PageInfo } from '../../graphql/generated';
import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents';
function createMockEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
function createMockEvent(overrides: Partial<AppObserveMetric> = {}): AppObserveMetric {
return {
__typename: 'AppObserveEvent',
__typename: 'AppObserveMetric',
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -35,8 +35,8 @@ describe(buildObserveEventsTable, () => {
it('formats events into aligned columns', () => {
const events = [
createMockEvent({
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
appVersion: '1.2.0',
appBuildNumber: '42',
deviceOs: 'iOS',
@@ -47,8 +47,8 @@ describe(buildObserveEventsTable, () => {
}),
createMockEvent({
id: 'evt-2',
metricName: 'expo.app_startup.tti',
metricValue: 0.85,
name: 'expo.app_startup.tti',
value: 0.85,
appVersion: '1.1.0',
appBuildNumber: '38',
deviceOs: 'Android',
@@ -76,7 +76,7 @@ describe(buildObserveEventsTable, () => {
});
it('shows metric name in summary header when options are provided', () => {
const events = [createMockEvent({ metricName: 'expo.app_startup.tti' })];
const events = [createMockEvent({ name: 'expo.app_startup.tti' })];
const output = buildObserveEventsTable(events, noNextPage, {
metricName: 'expo.app_startup.tti',
daysBack: 30,
@@ -126,8 +126,8 @@ describe(buildObserveEventsJson, () => {
const events = [
createMockEvent({
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
@@ -146,8 +146,8 @@ describe(buildObserveEventsJson, () => {
events: [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
@@ -1,4 +1,4 @@
import { AppPlatform } from '../../graphql/generated';
import { AppObservePlatform } from '../../graphql/generated';
import {
type MetricValues,
ObserveMetricsMap,
@@ -40,7 +40,7 @@ function makeMetricValueWithDefaults(overrides: Partial<MetricValues>): MetricVa
describe(buildObserveMetricsTable, () => {
it('formats metrics grouped by version with metric columns', () => {
const metricsMap: ObserveMetricsMap = new Map();
const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios);
const iosKey = makeMetricsKey('1.2.0', AppObservePlatform.Ios);
metricsMap.set(
iosKey,
new Map([
@@ -52,7 +52,7 @@ describe(buildObserveMetricsTable, () => {
])
);
const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android);
const androidKey = makeMetricsKey('1.1.0', AppObservePlatform.Android);
metricsMap.set(
androidKey,
new Map([
@@ -84,7 +84,7 @@ describe(buildObserveMetricsTable, () => {
it('shows - for versions with no matching metric data', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
const key = makeMetricsKey('2.0.0', AppObservePlatform.Ios);
metricsMap.set(key, new Map());
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
@@ -108,7 +108,7 @@ describe(buildObserveMetricsTable, () => {
describe(buildObserveMetricsJson, () => {
it('produces JSON with all stats per metric', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
metricsMap.set(
key,
new Map([
@@ -125,7 +125,7 @@ describe(buildObserveMetricsJson, () => {
expect(result.versions).toHaveLength(1);
expect(result.versions[0]).toEqual({
appVersion: '1.0.0',
platform: AppPlatform.Ios,
platform: AppObservePlatform.Ios,
buildNumbers: [],
updateIds: [],
metrics: {
@@ -145,7 +145,7 @@ describe(buildObserveMetricsJson, () => {
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);
const key = makeMetricsKey('3.0.0', AppObservePlatform.Android);
metricsMap.set(key, new Map());
const result = buildObserveMetricsJson(
@@ -171,8 +171,8 @@ describe(buildObserveMetricsJson, () => {
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');
expect(makeMetricsKey('1.0.0', AppObservePlatform.Ios)).toBe('1.0.0:IOS');
expect(makeMetricsKey('2.0.0', AppObservePlatform.Android)).toBe('2.0.0:ANDROID');
});
});
@@ -224,7 +224,7 @@ describe('DEFAULT_STATS_JSON', () => {
describe('custom stats parameter', () => {
it('table renders only selected stats', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
metricsMap.set(
key,
new Map([
@@ -259,7 +259,7 @@ describe('custom stats parameter', () => {
it("table formats eventCount as integer without 's' suffix", () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
metricsMap.set(
key,
new Map([
@@ -288,7 +288,7 @@ describe('custom stats parameter', () => {
it('JSON includes only selected stats', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
metricsMap.set(
key,
new Map([
@@ -322,7 +322,7 @@ describe('custom stats parameter', () => {
it('JSON uses default stats when not specified', () => {
const metricsMap: ObserveMetricsMap = new Map();
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
metricsMap.set(
key,
new Map([
@@ -1,4 +1,4 @@
import { AppPlatform } from '../../graphql/generated';
import { AppObservePlatform } from '../../graphql/generated';
import { NavigationRouteWithPlatform } from '../fetchNavigationRoutes';
import {
buildObserveNavigationRoutesJson,
@@ -9,7 +9,7 @@ import {
function makeRoute(
routeName: string,
platform: AppPlatform,
platform: AppObservePlatform,
overrides?: Partial<{
coldTtr: { count: number; median: number | null; p90: number | null };
warmTtr: { count: number; median: number | null; p90: number | null };
@@ -87,11 +87,11 @@ describe(buildObserveNavigationRoutesTable, () => {
it('formats routes grouped by platform with merged (med + count) cells', () => {
const routes = [
makeRoute('/home', AppPlatform.Ios),
makeRoute('/profile', AppPlatform.Ios, {
makeRoute('/home', AppObservePlatform.Ios),
makeRoute('/profile', AppObservePlatform.Ios, {
coldTtr: { count: 5, median: 0.7, p90: 1.0 },
}),
makeRoute('/home', AppPlatform.Android),
makeRoute('/home', AppObservePlatform.Android),
];
const output = buildObserveNavigationRoutesTable(
@@ -114,7 +114,7 @@ describe(buildObserveNavigationRoutesTable, () => {
});
it('renders separate columns when count is omitted from stats', () => {
const routes = [makeRoute('/home', AppPlatform.Ios)];
const routes = [makeRoute('/home', AppObservePlatform.Ios)];
const output = buildObserveNavigationRoutesTable(
routes,
['expo.navigation.cold_ttr'],
@@ -129,7 +129,7 @@ describe(buildObserveNavigationRoutesTable, () => {
});
it('renders count-only column when only count stat is requested', () => {
const routes = [makeRoute('/home', AppPlatform.Ios)];
const routes = [makeRoute('/home', AppObservePlatform.Ios)];
const output = buildObserveNavigationRoutesTable(
routes,
['expo.navigation.cold_ttr'],
@@ -142,9 +142,9 @@ describe(buildObserveNavigationRoutesTable, () => {
});
it('shows a next-page hint per platform when hasNextPage is true', () => {
const routes = [makeRoute('/home', AppPlatform.Ios)];
const routes = [makeRoute('/home', AppObservePlatform.Ios)];
const pageInfoByPlatform = new Map([
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
[AppObservePlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
]);
const output = buildObserveNavigationRoutesTable(
@@ -160,9 +160,9 @@ describe(buildObserveNavigationRoutesTable, () => {
describe(buildObserveNavigationRoutesJson, () => {
it('maps routes to the requested metrics and stats, including pageInfoByPlatform', () => {
const routes = [makeRoute('/home', AppPlatform.Ios)];
const routes = [makeRoute('/home', AppObservePlatform.Ios)];
const pageInfoByPlatform = new Map([
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
[AppObservePlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
]);
const result = buildObserveNavigationRoutesJson(
@@ -175,7 +175,7 @@ describe(buildObserveNavigationRoutesJson, () => {
expect(result.routes).toEqual([
{
routeName: '/home',
platform: AppPlatform.Ios,
platform: AppObservePlatform.Ios,
metrics: {
'expo.navigation.cold_ttr': { median: 0.5, p90: 0.9, count: 10 },
'expo.navigation.tti': { median: 0.45, p90: 0.7, count: 12 },
@@ -183,13 +183,13 @@ describe(buildObserveNavigationRoutesJson, () => {
},
]);
expect(result.pageInfoByPlatform).toEqual({
[AppPlatform.Ios]: { hasNextPage: true, endCursor: 'cursor-ios' },
[AppObservePlatform.Ios]: { hasNextPage: true, endCursor: 'cursor-ios' },
});
});
it('returns null for stats when the underlying value is null', () => {
const routes = [
makeRoute('/home', AppPlatform.Ios, {
makeRoute('/home', AppObservePlatform.Ios, {
coldTtr: { count: 0, median: null, p90: null },
}),
];
@@ -1,4 +1,4 @@
import { AppObserveCustomEvent, AppObserveEvent } from '../../graphql/generated';
import { AppObserveMetric, AppObserveUserEvent } from '../../graphql/generated';
import { SessionEventEntry, SessionMetadata } from '../fetchSessions';
import {
buildObserveSessionEventsJson,
@@ -11,6 +11,7 @@ import {
function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
return {
source: 'metric',
id: 'evt-m-1',
timestamp: '2025-01-15T10:00:00.000Z',
sessionId: 'session-1',
appVersion: '1.0.0',
@@ -21,8 +22,8 @@ function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEve
deviceOsVersion: '17.0',
countryCode: 'US',
easClientId: 'client-1',
metricName: 'expo.app_startup.tti',
metricValue: 0.8,
name: 'expo.app_startup.tti',
value: 0.8,
customParams: null,
...overrides,
};
@@ -31,6 +32,7 @@ function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEve
function makeLogEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
return {
source: 'log',
id: 'evt-c-1',
timestamp: '2025-01-15T10:01:00.000Z',
sessionId: 'session-1',
appVersion: '1.0.0',
@@ -41,7 +43,7 @@ function makeLogEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventE
deviceOsVersion: '17.0',
countryCode: 'US',
easClientId: 'client-1',
eventName: 'login_pressed',
name: 'login_pressed',
severityText: null,
severityNumber: null,
properties: [],
@@ -71,8 +73,8 @@ describe(buildObserveSessionEventsTable, () => {
it('appends the routeName to navigation metric rows', () => {
const entries = [
makeMetricEntry({
metricName: 'expo.navigation.tti',
metricValue: 0.32,
name: 'expo.navigation.tti',
value: 0.32,
routeName: '/home',
}),
];
@@ -150,7 +152,7 @@ describe(buildObserveSessionEventsTable, () => {
it('leaves the Value column as "-" for log entries (metric rows keep their value)', () => {
const entries = [
makeMetricEntry({ metricValue: 0.8 }),
makeMetricEntry({ value: 0.8 }),
makeLogEntry({
properties: [{ key: 'user_id', value: 'abc', type: 'STRING' }],
}),
@@ -240,6 +242,7 @@ describe(buildObserveSessionEventsJson, () => {
hasMoreMetricEvents: false,
hasMoreLogEvents: true,
});
expect(result.entries[0].id).toBe('evt-m-1');
});
it('returns null metadata when there were no entries', () => {
@@ -259,12 +262,12 @@ describe(shortSessionId, () => {
});
});
function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
function makeMetricEvent(overrides: Partial<AppObserveMetric> = {}): AppObserveMetric {
return {
__typename: 'AppObserveEvent' as const,
__typename: 'AppObserveMetric' as const,
id: 'evt-m-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
name: 'expo.app_startup.tti',
value: 1.23,
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
@@ -278,14 +281,14 @@ function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEv
customParams: null,
routeName: null,
...overrides,
} as AppObserveEvent;
} as AppObserveMetric;
}
function makeCustomEventEvt(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
function makeCustomEventEvt(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
return {
__typename: 'AppObserveCustomEvent' as const,
__typename: 'AppObserveUserEvent' as const,
id: 'evt-c-1',
eventName: 'login_pressed',
name: 'login_pressed',
timestamp: '2025-01-15T10:00:00.000Z',
sessionId: 'session-abcdefgh1234567890',
severityNumber: null,
@@ -302,7 +305,7 @@ function makeCustomEventEvt(overrides: Partial<AppObserveCustomEvent> = {}): App
countryCode: 'US',
properties: [],
...overrides,
} as AppObserveCustomEvent;
} as AppObserveUserEvent;
}
describe(formatMetricCandidateTitle, () => {
@@ -1,9 +1,9 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveCustomEvent,
AppObserveCustomEventListFilter,
AppObserveCustomEventListOrderBy,
AppObservePlatform,
AppObserveUserEvent,
AppObserveUserEventListFilter,
AppObserveUserEventListOrderBy,
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
@@ -14,15 +14,17 @@ interface FetchCustomEventsOptions {
after?: string;
startTime?: string;
endTime?: string;
platform?: AppObservePlatform;
platforms?: AppObservePlatform[];
appVersion?: string;
buildNumber?: string;
updateId?: string;
sessionId?: string;
orderBy?: AppObserveCustomEventListOrderBy;
environment?: string;
orderBy?: AppObserveUserEventListOrderBy;
}
interface FetchCustomEventsResult {
events: AppObserveCustomEvent[];
events: AppObserveUserEvent[];
pageInfo: PageInfo;
}
@@ -31,14 +33,16 @@ export async function fetchObserveCustomEventsAsync(
appId: string,
options: FetchCustomEventsOptions
): Promise<FetchCustomEventsResult> {
const filter: AppObserveCustomEventListFilter = {
const filter: AppObserveUserEventListFilter = {
...(options.startTime && { startTime: options.startTime }),
...(options.endTime && { endTime: options.endTime }),
...(options.eventName && { eventName: options.eventName }),
...(options.platform && { platform: options.platform }),
...(options.eventName && { name: options.eventName }),
...(options.platforms?.length && { platforms: options.platforms }),
...(options.appVersion && { appVersion: options.appVersion }),
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
...(options.updateId && { appUpdateId: options.updateId }),
...(options.sessionId && { sessionId: options.sessionId }),
...(options.environment && { environment: options.environment }),
};
return await ObserveQuery.customEventListAsync(graphqlClient, {
@@ -0,0 +1,81 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveError,
AppObserveErrorGroup,
AppObserveErrorOccurrencesOrderByField,
AppObserveErrorSeverity,
AppObserveOrderDirection,
AppObservePlatform,
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
export interface FetchObserveErrorGroupsOptions {
startTime: string;
endTime: string;
platforms?: AppObservePlatform[];
appVersion?: string;
buildNumber?: string;
updateId?: string;
environment?: string;
severity?: AppObserveErrorSeverity;
}
export async function fetchObserveErrorGroupsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options: FetchObserveErrorGroupsOptions
): Promise<{ groups: AppObserveErrorGroup[]; isTruncated: boolean }> {
return await ObserveQuery.errorGroupsAsync(graphqlClient, {
appId,
input: {
startTime: options.startTime,
endTime: options.endTime,
...(options.platforms?.length && { platforms: options.platforms }),
...(options.appVersion && { appVersion: options.appVersion }),
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
...(options.updateId && { appUpdateId: options.updateId }),
...(options.environment && { environment: options.environment }),
...(options.severity && { severity: options.severity }),
},
});
}
export interface FetchObserveErrorOccurrencesOptions {
fingerprint: string;
startTime: string;
endTime: string;
platforms?: AppObservePlatform[];
appVersion?: string;
buildNumber?: string;
updateId?: string;
environment?: string;
limit: number;
after?: string;
}
export async function fetchObserveErrorOccurrencesAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options: FetchObserveErrorOccurrencesOptions
): Promise<{ occurrences: AppObserveError[]; pageInfo: PageInfo }> {
return await ObserveQuery.errorOccurrencesAsync(graphqlClient, {
appId,
filter: {
fingerprint: options.fingerprint,
startTime: options.startTime,
endTime: options.endTime,
...(options.platforms?.length && { platforms: options.platforms }),
...(options.appVersion && { appVersion: options.appVersion }),
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
...(options.updateId && { appUpdateId: options.updateId }),
...(options.environment && { environment: options.environment }),
},
first: options.limit,
...(options.after && { after: options.after }),
orderBy: {
field: AppObserveErrorOccurrencesOrderByField.Timestamp,
direction: AppObserveOrderDirection.Desc,
},
});
}
+31 -30
View File
@@ -1,16 +1,16 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObserveMetric,
AppObserveMetricsListFilter,
AppObserveMetricsListOrderBy,
AppObserveMetricsListOrderByField,
AppObserveOrderDirection,
AppObservePlatform,
AppPlatform,
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import { isObservePlanGateError } from './planGating';
import { ObservePlatformTarget } from './platforms';
export enum EventsOrderPreset {
Slowest = 'SLOWEST',
@@ -19,47 +19,49 @@ export enum EventsOrderPreset {
Oldest = 'OLDEST',
}
export function resolveOrderBy(input: string): AppObserveEventsOrderBy {
export function resolveOrderBy(input: string): AppObserveMetricsListOrderBy {
const preset = input.toUpperCase() as EventsOrderPreset;
switch (preset) {
case EventsOrderPreset.Slowest:
return {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Desc,
};
case EventsOrderPreset.Fastest:
return {
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveMetricsListOrderByField.Value,
direction: AppObserveOrderDirection.Asc,
};
case EventsOrderPreset.Newest:
return {
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Desc,
};
case EventsOrderPreset.Oldest:
return {
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
};
}
}
interface FetchObserveEventsOptions {
metricName?: string;
orderBy: AppObserveEventsOrderBy;
orderBy: AppObserveMetricsListOrderBy;
limit: number;
after?: string;
startTime?: string;
endTime?: string;
platform?: AppObservePlatform;
platforms?: AppObservePlatform[];
appVersion?: string;
buildNumber?: string;
updateId?: string;
sessionId?: string;
environment?: string;
}
interface FetchObserveEventsResult {
events: AppObserveEvent[];
events: AppObserveMetric[];
pageInfo: PageInfo;
}
@@ -68,14 +70,16 @@ export async function fetchObserveEventsAsync(
appId: string,
options: FetchObserveEventsOptions
): Promise<FetchObserveEventsResult> {
const filter: AppObserveEventsFilter = {
const filter: AppObserveMetricsListFilter = {
...(options.startTime && { startTime: options.startTime }),
...(options.endTime && { endTime: options.endTime }),
...(options.metricName && { metricName: options.metricName }),
...(options.platform && { platform: options.platform }),
...(options.metricName && { name: options.metricName }),
...(options.platforms?.length && { platforms: options.platforms }),
...(options.appVersion && { appVersion: options.appVersion }),
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
...(options.updateId && { appUpdateId: options.updateId }),
...(options.sessionId && { sessionId: options.sessionId }),
...(options.environment && { environment: options.environment }),
};
return await ObserveQuery.eventsAsync(graphqlClient, {
@@ -87,27 +91,24 @@ export async function fetchObserveEventsAsync(
});
}
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
[AppPlatform.Android]: AppObservePlatform.Android,
[AppPlatform.Ios]: AppObservePlatform.Ios,
};
export async function fetchTotalEventCountAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
metricName: string,
platforms: AppPlatform[],
targets: ObservePlatformTarget[],
startTime: string,
endTime: string
endTime: string,
environment?: string
): Promise<number> {
const queries = platforms.map(async appPlatform => {
const queries = targets.map(async target => {
try {
const versions = await ObserveQuery.appVersionsAsync(graphqlClient, {
appId,
platform: appPlatformToObservePlatform[appPlatform],
platforms: target.platforms,
startTime,
endTime,
metricNames: [metricName],
environment,
});
return versions.reduce((sum, v) => {
const metric = v.metrics.find(m => m.metricName === metricName);
+16 -12
View File
@@ -1,6 +1,5 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { AppPlatform } from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import Log from '../log';
import {
@@ -11,7 +10,7 @@ import {
makeMetricsKey,
} from './formatMetrics';
import { isObservePlanGateError } from './planGating';
import { appPlatformToObservePlatform } from './platforms';
import { ObservePlatformTarget, observePlatformDisplayNames } from './platforms';
export function validateDateFlag(value: string, flagName: string): void {
const parsed = new Date(value);
@@ -33,28 +32,33 @@ export async function fetchObserveMetricsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
metricNames: string[],
platforms: AppPlatform[],
targets: ObservePlatformTarget[],
startTime: string,
endTime: string
endTime: string,
environment?: string
): Promise<FetchObserveMetricsResult> {
const queries = platforms.map(async appPlatform => {
const observePlatform = appPlatformToObservePlatform[appPlatform];
const queries = targets.map(async target => {
try {
const appVersions = await ObserveQuery.appVersionsAsync(graphqlClient, {
appId,
platform: observePlatform,
platforms: target.platforms,
startTime,
endTime,
metricNames,
environment,
});
return { appPlatform, appVersions };
return { target, appVersions };
} catch (error: any) {
// A plan gate is an account-wide rejection, not a per-platform failure —
// let it propagate so the command surfaces the upgrade prompt.
if (isObservePlanGateError(error)) {
throw error;
}
Log.warn(`Failed to fetch observe data on ${observePlatform}: ${error.message}`);
Log.warn(
`Failed to fetch observe data on ${observePlatformDisplayNames[target.key]}: ${
error.message
}`
);
return null;
}
});
@@ -70,10 +74,10 @@ export async function fetchObserveMetricsAsync(
if (!result) {
continue;
}
const { appPlatform, appVersions } = result;
const { target, appVersions } = result;
for (const version of appVersions) {
const key = makeMetricsKey(version.appVersion, appPlatform);
const key = makeMetricsKey(version.appVersion, target.key);
if (!metricsMap.has(key)) {
metricsMap.set(key, new Map());
}
@@ -103,7 +107,7 @@ export async function fetchObserveMetricsAsync(
};
metricsMap.get(key)!.set(metric.metricName, values);
const eventCountKey = `${metric.metricName}:${appPlatform}`;
const eventCountKey = `${metric.metricName}:${target.key}`;
totalEventCounts.set(
eventCountKey,
(totalEventCounts.get(eventCountKey) ?? 0) + metric.eventCount
@@ -1,38 +1,42 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveEventsOrderByDirection,
AppObserveNavigationOrderBy,
AppObserveNavigationRoute,
AppObserveNavigationRoutesOrderBy,
AppObserveNavigationRoutesOrderByField,
AppPlatform,
AppObserveOrderDirection,
PageInfo,
} from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import Log from '../log';
import { isObservePlanGateError } from './planGating';
import { appPlatformToObservePlatform } from './platforms';
import {
ObservePlatformKey,
ObservePlatformTarget,
observePlatformDisplayNames,
} from './platforms';
export interface NavigationRouteWithPlatform {
platform: AppPlatform;
platform: ObservePlatformKey;
route: AppObserveNavigationRoute;
}
export interface FetchNavigationRoutesOptions {
startTime: string;
endTime: string;
platforms: AppPlatform[];
targets: ObservePlatformTarget[];
limit: number;
after?: string;
appVersion?: string;
updateId?: string;
buildNumber?: string;
routeNames?: string[];
orderBy?: AppObserveNavigationRoutesOrderBy;
environment?: string;
orderBy?: AppObserveNavigationOrderBy;
}
export interface FetchNavigationRoutesResult {
routes: NavigationRouteWithPlatform[];
pageInfoByPlatform: Map<AppPlatform, PageInfo>;
pageInfoByPlatform: Map<ObservePlatformKey, PageInfo>;
}
export async function fetchObserveNavigationRoutesAsync(
@@ -40,37 +44,41 @@ export async function fetchObserveNavigationRoutesAsync(
appId: string,
options: FetchNavigationRoutesOptions
): Promise<FetchNavigationRoutesResult> {
const orderBy: AppObserveNavigationRoutesOrderBy = options.orderBy ?? {
const orderBy: AppObserveNavigationOrderBy = options.orderBy ?? {
field: AppObserveNavigationRoutesOrderByField.NavigationCount,
direction: AppObserveEventsOrderByDirection.Desc,
direction: AppObserveOrderDirection.Desc,
};
const queries = options.platforms.map(async appPlatform => {
const observePlatform = appPlatformToObservePlatform[appPlatform];
const queries = options.targets.map(async target => {
try {
const result = await ObserveQuery.navigationRoutesAsync(graphqlClient, {
appId,
filter: {
platform: observePlatform,
platforms: target.platforms,
startTime: options.startTime,
endTime: options.endTime,
...(options.appVersion && { appVersion: options.appVersion }),
...(options.updateId && { appUpdateId: options.updateId }),
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
...(options.routeNames?.length && { routeNames: options.routeNames }),
...(options.environment && { environment: options.environment }),
},
first: options.limit,
...(options.after && { after: options.after }),
orderBy,
});
return { appPlatform, ...result };
return { target, ...result };
} catch (error: any) {
// A plan gate is an account-wide rejection, not a per-platform failure —
// let it propagate so the command surfaces the upgrade prompt.
if (isObservePlanGateError(error)) {
throw error;
}
Log.warn(`Failed to fetch navigation routes on ${observePlatform}: ${error.message}`);
Log.warn(
`Failed to fetch navigation routes on ${observePlatformDisplayNames[target.key]}: ${
error.message
}`
);
return null;
}
});
@@ -78,15 +86,15 @@ export async function fetchObserveNavigationRoutesAsync(
const results = await Promise.all(queries);
const routes: NavigationRouteWithPlatform[] = [];
const pageInfoByPlatform = new Map<AppPlatform, PageInfo>();
const pageInfoByPlatform = new Map<ObservePlatformKey, PageInfo>();
for (const result of results) {
if (!result) {
continue;
}
pageInfoByPlatform.set(result.appPlatform, result.pageInfo);
pageInfoByPlatform.set(result.target.key, result.pageInfo);
for (const route of result.routes) {
routes.push({ platform: result.appPlatform, route });
routes.push({ platform: result.target.key, route });
}
}
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more