Compare commits

...
Author SHA1 Message Date
Krystof WoldrichandCodex 076585b7f0 [eas-cli] Clarify environment flag requirements and errors
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 16:01:52 +02:00
Krystof WoldrichandCodex dc6a5a7ec6 fix review comments
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +02:00
Krystof WoldrichandCodex d19a84d246 fix env:update variable-environment is not required in non interactive
mode

Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +02:00
Krystof WoldrichandCodex b4b05dc5fa Add env help changelog entry
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +02:00
Krystof WoldrichandCodex 3c3574ab79 Refine env non-interactive help
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +02:00
Krystof WoldrichandCodex 729a01fe64 Support extending grouped flag descriptions
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +02:00
Krystof WoldrichandCodex 72a511017e Revert "Require scope for env:get non-interactive mode"
This reverts commit 36c58a8b130b70f06607349dca417c8618eea199.

Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +02:00
Krystof WoldrichandCodex a58609e09f Require scope for env:get non-interactive mode
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +02:00
Krystof WoldrichandCodex 29f102c367 Clarify env command non-interactive usage
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +02:00
Krystof WoldrichandCodex 06563efbb9 Clarify env:create non-interactive usage
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:38:25 +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
105 changed files with 11442 additions and 3221 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: |
+21 -2
View File
@@ -6,6 +6,23 @@ 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
- [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))
### 🐛 Bug fixes
- [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] Improve `eas env:*` non-interactive required input help and error messages. ([#3820](https://github.com/expo/eas-cli/pull/3820) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Reject conflicting positional `ENVIRONMENT` and `--variable-environment` inputs in `eas env:update` and `eas env:delete`. ([#3820](https://github.com/expo/eas-cli/pull/3820) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🧹 Chores
## [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 +31,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 +66,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 +112,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"
}
@@ -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,7 +128,17 @@ 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]) =>
@@ -138,7 +149,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
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 +158,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);
@@ -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,11 +103,12 @@ 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(
@@ -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 });
}
}
@@ -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;
}
}
+58
View File
@@ -216,6 +216,7 @@ If you want to enforce the `eas-cli` version for your project, use the `"cli.ver
* [`eas project:info`](#eas-projectinfo)
* [`eas project:init`](#eas-projectinit)
* [`eas project:new [PATH]`](#eas-projectnew-path)
* [`eas project:status`](#eas-projectstatus)
* [`eas sim`](#eas-sim)
* [`eas sim:availability`](#eas-simavailability)
* [`eas sim:events`](#eas-simevents)
@@ -225,6 +226,7 @@ If you want to enforce the `eas-cli` version for your project, use the `"cli.ver
* [`eas sim:start`](#eas-simstart)
* [`eas sim:stop`](#eas-simstop)
* [`eas simulator:start`](#eas-simulatorstart)
* [`eas status`](#eas-status)
* [`eas submit`](#eas-submit)
* [`eas submit:cancel [SUBMISSION_ID]`](#eas-submitcancel-submission_id)
* [`eas submit:list`](#eas-submitlist)
@@ -2550,6 +2552,35 @@ ALIASES
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v23.2.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:status`
show a snapshot of the project: recent builds, dev builds, workflow runs, submissions, and updates
```
USAGE
$ eas project:status [--limit <value>] [--json] [--non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--limit=<value> The number of items to show in each section. Defaults to 3 and is capped at 25.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
show a snapshot of the project: recent builds, dev builds, workflow runs, submissions, and updates
ALIASES
$ eas status
EXAMPLES
$ eas status # Show a concise project snapshot
$ eas status --json # Output a machine-readable snapshot for agents and automation
$ eas status --json --limit 10 # Include more activity from each section
```
_See code: [packages/eas-cli/src/commands/project/status.ts](https://github.com/expo/eas-cli/blob/v22.0.0/packages/eas-cli/src/commands/project/status.ts)_
## `eas sim`
[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it
@@ -2867,6 +2898,33 @@ ALIASES
$ eas sim:start
```
## `eas status`
show a snapshot of the project: recent builds, dev builds, workflow runs, submissions, and updates
```
USAGE
$ eas status [--limit <value>] [--json] [--non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--limit=<value> The number of items to show in each section. Defaults to 3 and is capped at 25.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
show a snapshot of the project: recent builds, dev builds, workflow runs, submissions, and updates
ALIASES
$ eas status
EXAMPLES
$ eas status # Show a concise project snapshot
$ eas status --json # Output a machine-readable snapshot for agents and automation
$ eas status --json --limit 10 # Include more activity from each section
```
## `eas submit`
submit app binary to App Store and/or Play Store
+1
View File
@@ -13,6 +13,7 @@ const config: CodegenConfig = {
'src/graphql/generated.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
addUnderscoreToArgsType: true,
dedupeOperationSuffix: true,
},
hooks: {
+6296 -39
View File
File diff suppressed because it is too large. Load diff
@@ -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,4 +1,5 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { boolish } from 'getenv';
export function isNonInteractiveByDefault(): boolean {
@@ -26,6 +27,65 @@ export function resolveNonInteractiveAndJsonFlags(flags: {
return { json, nonInteractive };
}
export function prependFlagDescription<T extends Record<string, { description?: string }>>(
flags: T,
extraDescription: string
): T {
return Object.fromEntries(
Object.entries(flags).map(([name, flag]) => {
const description = flag.description ?? '';
return [
name,
{
...flag,
description: description ? `${extraDescription} ${description}` : extraDescription,
},
];
})
) as T;
}
export function markRequiredInNonInteractiveMode<
T extends Record<string, { description?: string }>,
>(flags: T): T {
return prependFlagDescription(flags, '(required in non-interactive mode)');
}
export function validateNonInteractiveRequiredInputs({
nonInteractive,
requiredInputs,
helpCommand,
}: {
nonInteractive: boolean;
requiredInputs: { if?: boolean; name: string; value: unknown }[];
helpCommand: string;
}): void {
if (!nonInteractive) {
return;
}
const missingInputs = requiredInputs
.filter(({ if: condition = true, value }) => {
if (!condition) {
return false;
}
if (Array.isArray(value)) {
return value.length === 0;
}
return !value;
})
.map(({ name }) => name);
if (missingInputs.length === 0) {
return;
}
throw new Error(
`Missing required inputs for non-interactive mode: ${missingInputs.join(', ')}.\n` +
`Run ${chalk.bold(helpCommand)} for usage and examples.`
);
}
export const EasEnvironmentFlagParameters = {
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
};
@@ -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 };
}
@@ -365,6 +365,19 @@ describe(EnvCreate, () => {
});
});
describe('in non-interactive mode', () => {
it('reports missing required inputs before loading command context', async () => {
const command = new EnvCreate(['--non-interactive'], mockConfig);
// @ts-expect-error
const getContextAsyncSpy = jest.spyOn(command, 'getContextAsync');
await expect(command.runAsync()).rejects.toThrow(
/Missing required inputs for non-interactive mode: --name, --value, --visibility, --environment\.[\s\S]*eas env:create --help/
);
expect(getContextAsyncSpy).not.toHaveBeenCalled();
});
});
it('accepts development environment when using positional argument', async () => {
const command = new EnvCreate(
[
@@ -106,7 +106,7 @@ describe(EnvDelete, () => {
);
});
it('cancels deletion when user does not confirm', async () => {
it('reports missing variable name in non-interactive mode before loading command context', async () => {
const mockVariables = [
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
];
@@ -117,9 +117,12 @@ describe(EnvDelete, () => {
const command = new EnvDelete(['--non-interactive'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrowErrorMatchingSnapshot();
const getContextAsyncSpy = jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
/Missing required inputs for non-interactive mode: --variable-name\.[\s\S]*eas env:delete --help/
);
expect(getContextAsyncSpy).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled();
});
@@ -158,4 +161,46 @@ describe(EnvDelete, () => {
environment: DefaultEnvironment.Development,
});
});
it('rejects positional environment and --variable-environment used together', async () => {
const mockVariable = {
id: 'var1',
name: 'TEST_VAR_1',
value: 'value1',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
};
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([mockVariable]);
jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValueOnce({ id: 'var1' });
const command = new EnvDelete(
[
'development',
'--variable-name',
'TEST_VAR_1',
'--variable-environment',
'production',
'--scope',
'project',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
const getContextAsyncSpy = jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId,
});
await expect(command.runAsync()).rejects.toThrow(
"You can't use the --variable-environment flag when the environment is passed as a positional argument. Run `eas env:delete --help` for more information."
);
expect(getContextAsyncSpy).not.toHaveBeenCalled();
});
});
@@ -66,6 +66,21 @@ describe(EnvList, () => {
);
});
it('reports missing environment in non-interactive mode', async () => {
const command = new EnvList(['--non-interactive'], mockConfig);
// @ts-expect-error
const getContextAsyncSpy = jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await expect(command.runAsync()).rejects.toThrow(
'The `--environment` flag must be set when running in `--non-interactive` mode.'
);
expect(getContextAsyncSpy).toHaveBeenCalled();
});
it('lists project environment variables in specified environments', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
@@ -281,7 +281,7 @@ describe(EnvPull, () => {
const command = new EnvPull(['--non-interactive'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
const getContextAsyncSpy = jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
@@ -290,6 +290,7 @@ describe(EnvPull, () => {
await expect(command.runAsync()).rejects.toThrow(
'The `--environment` flag must be set when running in `--non-interactive` mode.'
);
expect(getContextAsyncSpy).toHaveBeenCalled();
});
});
@@ -89,6 +89,52 @@ SECRET_KEY=super-secret-key`;
});
});
it('reports missing environment in non-interactive mode', async () => {
const command = new EnvPush(['--non-interactive', '--path', testEnvPath], mockConfig);
// @ts-expect-error
const getContextAsyncSpy = jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await expect(command.runAsync()).rejects.toThrow(
/The `--environment` flag must be set when running in `--non-interactive` mode/
);
expect(getContextAsyncSpy).toHaveBeenCalled();
});
it('allows non-interactive mode without --force when nothing needs confirmation', async () => {
const command = new EnvPush(
['--environment', 'development', '--path', testEnvPath, '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(
EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync
).toHaveBeenCalledWith(
graphqlClient,
expect.arrayContaining([
expect.objectContaining({
name: 'VARIABLE_NAME',
environments: [DefaultEnvironment.Development],
}),
]),
testProjectId
);
});
describe('--force option', () => {
it('automatically overrides existing variables without confirmation when --force is used', async () => {
const existingVariable = {
@@ -275,5 +321,79 @@ SECRET_KEY=super-secret-key`;
'Using --force flag: automatically overriding sensitive variables.'
);
});
it('throws before confirming overwrite in non-interactive mode when --force is not used', async () => {
const existingVariable = {
id: 'var1',
name: 'VARIABLE_NAME',
value: 'old variable value',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
};
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([existingVariable]);
const command = new EnvPush(
['--environment', 'development', '--path', testEnvPath, '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await expect(command.runAsync()).rejects.toThrow(
'Cannot confirm overwriting existing variables in non-interactive mode. Use --force to overwrite them.'
);
expect(confirmAsync).not.toHaveBeenCalled();
expect(
EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync
).not.toHaveBeenCalled();
});
it('throws before confirming sensitive overwrite in non-interactive mode when --force is not used', async () => {
const existingSensitiveVariable = {
id: 'var1',
name: 'SECRET_KEY',
value: 'super-secret-key',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Sensitive,
type: EnvironmentSecretType.String,
};
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
.mockResolvedValue([existingSensitiveVariable]);
const command = new EnvPush(
['--environment', 'development', '--path', testEnvPath, '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await expect(command.runAsync()).rejects.toThrow(
'Cannot confirm overwriting sensitive variables in non-interactive mode. Use --force to overwrite them.'
);
expect(confirmAsync).not.toHaveBeenCalled();
expect(
EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync
).not.toHaveBeenCalled();
});
});
});
@@ -253,6 +253,50 @@ describe(EnvUpdate, () => {
});
});
it('rejects positional environment and --variable-environment used together', async () => {
const mockVariable = {
id: 'var1',
name: 'TEST_VAR_1',
value: 'value1',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
};
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([mockVariable]);
jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValueOnce(mockVariable);
const command = new EnvUpdate(
[
'development',
'--variable-name',
'TEST_VAR_1',
'--variable-environment',
'production',
'--value',
'new-value',
'--scope',
'project',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
const getContextAsyncSpy = jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId,
});
await expect(command.runAsync()).rejects.toThrow(
"You can't use the --variable-environment flag when the environment is passed as a positional argument. Run `eas env:update --help` for more information."
);
expect(getContextAsyncSpy).not.toHaveBeenCalled();
});
it('processes file type variable in non-interactive mode with --type file and --value', async () => {
const testFilePath = '/path/to/test-file.json';
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EnvDelete cancels deletion when user does not confirm 1`] = `"Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with --variable-name VARIABLE_NAME flag to fix the issue"`;
+27 -5
View File
@@ -11,6 +11,8 @@ import {
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
EASVariableVisibilityFlag,
markRequiredInNonInteractiveMode,
validateNonInteractiveRequiredInputs,
} from '../../commandUtils/flags';
import {
EnvironmentSecretType,
@@ -61,6 +63,11 @@ export default class EnvCreate extends EasCommand {
'create an environment variable for the current project or account (deprecated, use eas env:set)';
static override hidden = true;
static override examples = [
'$ eas env:create --environment production --environment preview --name API_TOKEN --value "$API_TOKEN" --visibility sensitive',
'$ eas env:create --environment production --scope account --name SHARED_TOKEN --value "$SHARED_TOKEN" --visibility secret',
];
static override args = {
environment: Args.string({
description:
@@ -71,22 +78,24 @@ export default class EnvCreate extends EasCommand {
static override flags = {
name: Flags.string({
description: 'Name of the variable',
description: '(required in non-interactive mode) Name of the variable',
}),
value: Flags.string({
description: 'Text value or the variable',
description:
'(required in non-interactive mode) Text value of the variable, or a file path when --type=file',
}),
force: Flags.boolean({
description: 'Overwrite existing variable',
description:
'(required when overwriting in non-interactive mode) Overwrite existing variable',
default: false,
}),
type: Flags.option({
description: 'The type of variable',
options: ['string', 'file'] as const,
})(),
...EASVariableVisibilityFlag,
...markRequiredInNonInteractiveMode(EASVariableVisibilityFlag),
...EASEnvironmentVariableScopeFlag,
...EASMultiEnvironmentFlag,
...markRequiredInNonInteractiveMode(EASMultiEnvironmentFlag),
...EASNonInteractiveFlag,
};
@@ -103,6 +112,19 @@ export default class EnvCreate extends EasCommand {
const { args, flags } = await this.parse(EnvCreate);
const validatedFlags = this.sanitizeFlags(flags);
validateNonInteractiveRequiredInputs({
nonInteractive: validatedFlags['non-interactive'],
requiredInputs: [
{ name: '--name', value: validatedFlags.name },
{ name: '--value', value: validatedFlags.value },
{ name: '--visibility', value: validatedFlags.visibility },
{
name: '--environment',
value: args.environment ?? validatedFlags.environment,
},
],
helpCommand: 'eas env:create --help',
});
const {
projectId,
+16 -10
View File
@@ -1,6 +1,5 @@
import { Args, Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import {
@@ -8,6 +7,7 @@ import {
EASEnvironmentVariableScopeFlagValue,
EASNonInteractiveFlag,
EasEnvironmentFlagParameters,
validateNonInteractiveRequiredInputs,
} from '../../commandUtils/flags';
import { EnvironmentVariableScope } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
@@ -33,9 +33,14 @@ interface RawDeleteFlags {
export default class EnvDelete extends EasCommand {
static override description = 'delete an environment variable for the current project or account';
static override examples = [
'$ eas env:delete --variable-environment production --variable-name API_TOKEN',
'$ eas env:delete --scope account --variable-name SHARED_TOKEN',
];
static override flags = {
'variable-name': Flags.string({
description: 'Name of the variable to delete',
description: '(required in non-interactive mode) Name of the variable to delete',
}),
'variable-environment': Flags.string({
...EasEnvironmentFlagParameters,
@@ -148,14 +153,15 @@ export default class EnvDelete extends EasCommand {
flags: RawDeleteFlags,
{ environment }: { environment?: string }
): DeleteFlags {
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
`Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with ${chalk.bold(
'--variable-name VARIABLE_NAME'
)} flag to fix the issue`
);
}
validateNonInteractiveRequiredInputs({
nonInteractive: flags['non-interactive'],
requiredInputs: [{ name: '--variable-name', value: flags['variable-name'] }],
helpCommand: 'eas env:delete --help',
});
if (environment && flags['variable-environment']) {
throw new Error(
"You can't use the --variable-environment flag when the environment is passed as a positional argument. Run `eas env:delete --help` for more information."
);
}
const scope =
+2
View File
@@ -29,6 +29,8 @@ export default class EnvExec extends EasCommand {
static override description =
'execute a command with environment variables from the selected environment';
static override examples = ["$ eas env:exec production 'bun run build'"];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
+20 -14
View File
@@ -9,6 +9,7 @@ import {
EASNonInteractiveFlag,
EASVariableFormatFlag,
EasEnvironmentFlagParameters,
validateNonInteractiveRequiredInputs,
} from '../../commandUtils/flags';
import {
EnvironmentVariableFragment,
@@ -39,6 +40,11 @@ interface GetFlags {
export default class EnvGet extends EasCommand {
static override description = 'view an environment variable for the current project or account';
static override examples = [
'$ eas env:get --variable-environment production --variable-name API_URL',
'$ eas env:get --variable-environment production --variable-name API_TOKEN --format long',
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
@@ -54,11 +60,11 @@ export default class EnvGet extends EasCommand {
static override flags = {
'variable-name': Flags.string({
description: 'Name of the variable',
description: '(required) Name of the variable',
}),
'variable-environment': Flags.string({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable',
description: '(required in non-interactive mode) Current environment of the variable',
}),
...EASVariableFormatFlag,
...EASEnvironmentVariableScopeFlag,
@@ -134,20 +140,20 @@ export default class EnvGet extends EasCommand {
}
private sanitizeInputs(flags: RawGetFlags, { environment }: { environment?: string }): GetFlags {
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error('Variable name is required. Run the command with --variable-name flag.');
}
if (!flags.scope) {
throw new Error('Scope is required. Run the command with --scope flag.');
}
if (!(flags['variable-environment'] ?? environment)) {
throw new Error('Environment is required.');
}
}
validateNonInteractiveRequiredInputs({
nonInteractive: flags['non-interactive'],
requiredInputs: [
{ name: '--variable-name', value: flags['variable-name'] },
{
name: '--variable-environment',
value: environment ?? flags['variable-environment'],
},
],
helpCommand: 'eas env:get --help',
});
if (environment && flags['variable-environment']) {
throw new Error(
"You can't use both --variable-environment flag when environment is passed as an argument. Run `eas env:get --help` for more information."
"You can't use the --variable-environment flag when the environment is passed as a positional argument. Run `eas env:get --help` for more information."
);
}
+7 -3
View File
@@ -7,7 +7,9 @@ import {
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
EASVariableFormatFlag,
markRequiredInNonInteractiveMode,
} from '../../commandUtils/flags';
import { EnvironmentVariableScope } from '../../graphql/generated';
import {
@@ -72,7 +74,7 @@ interface RawListFlags {
environment: string[] | undefined;
'include-sensitive': boolean;
'include-file-content': boolean;
'non-interactive'?: boolean;
'non-interactive': boolean;
}
interface ListFlags {
@@ -87,6 +89,8 @@ interface ListFlags {
export default class EnvList extends EasCommand {
static override description = 'list environment variables for the current project or account';
static override examples = ['$ eas env:list --environment production'];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
@@ -101,9 +105,10 @@ export default class EnvList extends EasCommand {
description: 'Display files content in the output',
default: false,
}),
...EASMultiEnvironmentFlag,
...markRequiredInNonInteractiveMode(EASMultiEnvironmentFlag),
...EASVariableFormatFlag,
...EASEnvironmentVariableScopeFlag,
...EASNonInteractiveFlag,
};
static override args = {
@@ -186,7 +191,6 @@ export default class EnvList extends EasCommand {
return {
...flags,
'non-interactive': flags['non-interactive'] ?? false,
environment: environments,
scope:
flags.scope === 'account'
+11 -2
View File
@@ -4,7 +4,11 @@ import * as fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
EASEnvironmentFlag,
EASNonInteractiveFlag,
markRequiredInNonInteractiveMode,
} from '../../commandUtils/flags';
import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../../graphql/generated';
import {
EnvironmentVariableWithFileContent,
@@ -18,6 +22,11 @@ export default class EnvPull extends EasCommand {
static override description =
'pull environment variables for the selected environment to .env file';
static override examples = [
'$ eas env:pull --environment development',
'$ eas env:pull --environment production --path .env.production',
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
@@ -34,7 +43,7 @@ export default class EnvPull extends EasCommand {
static override flags = {
...EASNonInteractiveFlag,
...EASEnvironmentFlag,
...markRequiredInNonInteractiveMode(EASEnvironmentFlag),
path: Flags.string({
description: 'Path to the result `.env` file',
default: '.env.local',
+33 -6
View File
@@ -4,7 +4,11 @@ import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { EASMultiEnvironmentFlag } from '../../commandUtils/flags';
import {
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
markRequiredInNonInteractiveMode,
} from '../../commandUtils/flags';
import {
CreateEnvironmentVariableInput,
EnvironmentVariableFragment,
@@ -21,13 +25,17 @@ export default class EnvPush extends EasCommand {
static override description =
'push environment variables from .env file to the selected environment';
static override examples = [
'$ eas env:push --environment production --path .env.production --force',
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override flags = {
...EASMultiEnvironmentFlag,
...markRequiredInNonInteractiveMode(EASMultiEnvironmentFlag),
path: Flags.string({
description: 'Path to the input `.env` file',
default: '.env.local',
@@ -36,6 +44,7 @@ export default class EnvPush extends EasCommand {
description: 'Skip confirmation and automatically override existing variables',
default: false,
}),
...EASNonInteractiveFlag,
};
static override args = {
@@ -49,18 +58,23 @@ export default class EnvPush extends EasCommand {
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvPush);
let { environment: environments, path: envPath, force } = this.parseFlagsAndArgs(flags, args);
let {
environment: environments,
path: envPath,
force,
'non-interactive': nonInteractive,
} = this.parseFlagsAndArgs(flags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvPush, {
nonInteractive: false,
nonInteractive,
});
if (!environments) {
environments = await promptVariableEnvironmentAsync({
nonInteractive: false,
nonInteractive,
multiple: true,
canEnterCustomEnvironment: true,
graphqlClient,
@@ -132,6 +146,12 @@ export default class EnvPush extends EasCommand {
)} environment variables already exist in ${displayedEnvironment} environment. Do you want to override them all?`
: `The ${variableNames[0]} environment variable already exists in ${displayedEnvironment} environment. Do you want to override it?`;
if (nonInteractive) {
throw new Error(
'Cannot confirm overwriting existing variables in non-interactive mode. Use --force to overwrite them.'
);
}
const confirm = await confirmAsync({
message: confirmationMessage,
});
@@ -179,6 +199,12 @@ export default class EnvPush extends EasCommand {
const existingSensitiveVariablesNames = existingSensitiveVariables.map(
variable => `- ${variable.name}`
);
if (nonInteractive) {
throw new Error(
'Cannot confirm overwriting sensitive variables in non-interactive mode. Use --force to overwrite them.'
);
}
const confirm = await confirmAsync({
message: `You are about to overwrite sensitive variables.\n${existingSensitiveVariablesNames.join(
'\n'
@@ -212,9 +238,10 @@ export default class EnvPush extends EasCommand {
path: string;
environment: string[] | undefined;
force: boolean;
'non-interactive': boolean;
},
{ environment }: { environment?: string }
): { environment?: string[]; path: string; force: boolean } {
): { environment?: string[]; path: string; force: boolean; 'non-interactive': boolean } {
const environments = flags.environment ?? (environment ? [environment] : undefined);
return {
+25 -13
View File
@@ -13,6 +13,7 @@ import {
EASNonInteractiveFlag,
EASVariableVisibilityFlag,
EasEnvironmentFlagParameters,
validateNonInteractiveRequiredInputs,
} from '../../commandUtils/flags';
import {
EnvironmentSecretType,
@@ -63,13 +64,20 @@ interface UpdateFlags {
}
export default class EnvUpdate extends EasCommand {
static override description =
'update an environment variable on the current project or account (deprecated, use eas env:set)';
static override description = `update an environment variable on the current project or account (deprecated, use eas env:set)
In non-interactive mode, if --type is provided, --value is also required.`;
static override hidden = true;
static override examples = [
'$ eas env:update --variable-environment production --variable-name API_URL --value https://api.example.com',
'$ eas env:update --variable-environment development --variable-name GOOGLE_SERVICES_JSON --type file --value ./google-services.json --environment production',
];
static override flags = {
'variable-name': Flags.string({
description: 'Current name of the variable',
description: '(required in non-interactive mode) Current name of the variable',
}),
'variable-environment': Flags.string({
...EasEnvironmentFlagParameters,
@@ -79,7 +87,8 @@ export default class EnvUpdate extends EasCommand {
description: 'New name of the variable',
}),
value: Flags.string({
description: 'New value or the variable',
description:
'(required with --type in non-interactive mode) New value for the variable, or a file path when --type=file',
}),
type: Flags.option({
description: 'The type of variable',
@@ -217,15 +226,18 @@ export default class EnvUpdate extends EasCommand {
flags: RawUpdateFlags,
{ environment }: { environment?: string }
): UpdateFlags {
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
'Current name is required in non-interactive mode. Run the command with --variable-name flag.'
);
}
if (flags['type'] && !flags['value']) {
throw new Error('Value is required when type is set. Run the command with --value flag.');
}
validateNonInteractiveRequiredInputs({
nonInteractive: flags['non-interactive'],
requiredInputs: [
{ name: '--variable-name', value: flags['variable-name'] },
{ if: !!flags.type, name: '--value', value: flags.value },
],
helpCommand: 'eas env:update --help',
});
if (environment && flags['variable-environment']) {
throw new Error(
"You can't use the --variable-environment flag when the environment is passed as a positional argument. Run `eas env:update --help` for more information."
);
}
const scope =
@@ -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',
@@ -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 () => {
@@ -249,7 +249,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 }
+1493 -618
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
@@ -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
@@ -4,51 +4,34 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { withErrorHandlingAsync } from '../client';
import {
AppObserveAppVersion,
AppObserveCustomEvent,
AppObserveCustomEventListFilter,
AppObserveCustomEventListOrderBy,
AppObserveCustomEventName,
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObserveError,
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,
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 +49,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 +134,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 +150,65 @@ type AppObserveNavigationRoutesQuery = {
type AppObserveNavigationRoutesQueryVariables = {
appId: string;
filter: AppObserveNavigationRoutesFilter;
filter: AppObserveNavigationFilter;
first?: number;
after?: string;
orderBy?: AppObserveNavigationRoutesOrderBy;
orderBy?: AppObserveNavigationOrderBy;
};
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;
};
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 +224,13 @@ export const ObserveQuery = {
`,
{
appId,
input: { platform, startTime, endTime, ...(metricNames && { metricNames }) },
input: {
platforms,
startTime,
endTime,
...(metricNames && { metricNames }),
...(environment && { environment }),
},
}
)
.toPromise()
@@ -263,39 +241,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 +278,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 +294,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 +331,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 +351,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 +386,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 +410,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 +459,91 @@ 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 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,
};
},
};
@@ -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');
});
});
+37 -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,41 @@ 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 AppObserveMetricFragmentNode = gql`
fragment AppObserveMetricFragment on AppObserveMetric {
id
name
value
timestamp
appVersion
appBuildNumber
@@ -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', {
@@ -131,30 +169,38 @@ describe('fetchObserveSessionEventsAsync', () => {
expect(result.entries.map(e => e.source)).toEqual(['log', 'metric', 'log']);
});
it('derives session metadata from the entries (first/last timestamps, device, app version)', async () => {
mockFetchObserveEventsAsync.mockResolvedValue({
events: [
makeMetricEvent({
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
deviceModel: 'iPhone 15',
appUpdateId: 'update-xyz',
countryCode: 'US',
}),
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>),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
metricsPageInfo: noNextPage,
logsPageInfo: noNextPage,
});
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
makeCustomEvent({
timestamp: '2025-01-15T10:05:00.000Z',
appUpdateId: 'update-xyz',
}),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
...baseOptions,
sessionId: 'session-1',
});
expect(result.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 () => {
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 +230,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 +252,7 @@ describe('fetchSessionMetricCandidatesAsync', () => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
pageInfo: noNextPage,
});
});
@@ -227,8 +271,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 +283,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 +303,7 @@ describe('fetchSessionLogCandidatesAsync', () => {
jest.clearAllMocks();
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
pageInfo: noNextPage,
});
});
@@ -289,8 +333,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 +348,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 +376,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,
});
@@ -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,
@@ -21,8 +21,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,
};
@@ -41,7 +41,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 +71,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 +150,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' }],
}),
@@ -259,12 +259,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 +278,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 +302,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, {
+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 });
}
}
+79 -42
View File
@@ -1,11 +1,14 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
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, resolveOrderBy } from './fetchEvents';
@@ -13,6 +16,8 @@ export interface SessionEventEntry {
source: 'metric' | 'log';
timestamp: string;
sessionId: string;
// Metric name, user-event name, or error type.
name: string;
appVersion: string;
appBuildNumber: string;
appUpdateId: string | null;
@@ -22,23 +27,22 @@ export interface SessionEventEntry {
countryCode: string | null;
easClientId: string;
// metric-only fields
metricName?: string;
metricValue?: number;
value?: number;
customParams?: { [key: string]: any } | null;
routeName?: string | null;
// log-only fields
eventName?: string;
severityText?: string | null;
severityNumber?: number | null;
properties?: Array<{ key: string; value: string; type: string }>;
environment?: string | null;
}
function metricEventToEntry(event: AppObserveEvent): SessionEventEntry {
function metricEventToEntry(event: AppObserveMetric): SessionEventEntry {
return {
source: 'metric',
timestamp: event.timestamp,
sessionId: event.sessionId ?? '',
name: event.name,
appVersion: event.appVersion,
appBuildNumber: event.appBuildNumber,
appUpdateId: event.appUpdateId ?? null,
@@ -47,18 +51,18 @@ function metricEventToEntry(event: AppObserveEvent): SessionEventEntry {
deviceOsVersion: event.deviceOsVersion,
countryCode: event.countryCode ?? null,
easClientId: event.easClientId,
metricName: event.metricName,
metricValue: event.metricValue,
value: event.value,
customParams: event.customParams ?? null,
routeName: event.routeName ?? null,
};
}
function customEventToEntry(event: AppObserveCustomEvent): SessionEventEntry {
function userEventToEntry(event: AppObserveUserEvent): SessionEventEntry {
return {
source: 'log',
timestamp: event.timestamp,
sessionId: event.sessionId ?? '',
name: event.name,
appVersion: event.appVersion,
appBuildNumber: event.appBuildNumber,
appUpdateId: event.appUpdateId ?? null,
@@ -67,7 +71,6 @@ function customEventToEntry(event: AppObserveCustomEvent): SessionEventEntry {
deviceOsVersion: event.deviceOsVersion,
countryCode: event.countryCode ?? null,
easClientId: event.easClientId,
eventName: event.eventName,
severityText: event.severityText ?? null,
severityNumber: event.severityNumber ?? null,
properties: event.properties.map(p => ({ key: p.key, value: p.value, type: p.type })),
@@ -75,6 +78,35 @@ function customEventToEntry(event: AppObserveCustomEvent): SessionEventEntry {
};
}
function errorToEntry(event: AppObserveError): SessionEventEntry {
const properties = [
...(event.message ? [{ key: 'message', value: event.message, type: 'STRING' }] : []),
...event.properties.map(p => ({ key: p.key, value: p.value, type: p.type })),
];
return {
source: 'log',
timestamp: event.timestamp,
sessionId: event.sessionId ?? '',
name: event.type ?? 'exception',
appVersion: event.appVersion,
appBuildNumber: event.appBuildNumber,
appUpdateId: event.appUpdateId ?? null,
deviceModel: event.deviceModel,
deviceOs: event.deviceOs,
deviceOsVersion: event.deviceOsVersion,
countryCode: event.countryCode ?? null,
easClientId: event.easClientId,
severityText: event.severityText ?? null,
severityNumber: event.severityNumber ?? null,
properties,
environment: event.environment ?? null,
};
}
function sessionLogToEntry(log: AppObserveSessionLog): SessionEventEntry {
return log.__typename === 'AppObserveError' ? errorToEntry(log) : userEventToEntry(log);
}
export interface SessionMetadata {
appVersion: string;
appBuildNumber: string;
@@ -124,25 +156,26 @@ export async function fetchObserveSessionEventsAsync(
appId: string,
options: FetchSessionEventsOptions
): Promise<FetchSessionEventsResult> {
const [metricResult, logResult] = await Promise.all([
fetchObserveEventsAsync(graphqlClient, appId, {
orderBy: {
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
const { metrics, logs, metricsPageInfo, logsPageInfo } = await ObserveQuery.sessionEventsAsync(
graphqlClient,
{
appId,
id: options.sessionId,
first: options.limit,
metricsOrderBy: {
field: AppObserveMetricsListOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
},
limit: options.limit,
sessionId: options.sessionId,
}),
fetchObserveCustomEventsAsync(graphqlClient, appId, {
limit: options.limit,
sessionId: options.sessionId,
}),
]);
logsOrderBy: {
field: AppObserveLogsOrderByField.Timestamp,
direction: AppObserveOrderDirection.Asc,
},
}
);
const entries = [
...metricResult.events.map(metricEventToEntry),
...logResult.events.map(customEventToEntry),
].sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
const entries = [...metrics.map(metricEventToEntry), ...logs.map(sessionLogToEntry)].sort(
(a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0)
);
let metadata: SessionMetadata | null = null;
if (entries.length > 0) {
@@ -163,8 +196,8 @@ export async function fetchObserveSessionEventsAsync(
return {
entries,
metadata,
hasMoreMetricEvents: metricResult.pageInfo.hasNextPage,
hasMoreLogEvents: logResult.pageInfo.hasNextPage,
hasMoreMetricEvents: metricsPageInfo.hasNextPage,
hasMoreLogEvents: logsPageInfo.hasNextPage,
};
}
@@ -172,7 +205,7 @@ export async function fetchObserveSessionEventsAsync(
* A metric event that is guaranteed to belong to a session — used as a
* candidate when picking a session to inspect via `observe:session`.
*/
export type SessionMetricCandidate = AppObserveEvent & { sessionId: string };
export type SessionMetricCandidate = AppObserveMetric & { sessionId: string };
export interface FetchSessionMetricCandidatesOptions {
metricName: string;
@@ -181,11 +214,12 @@ export interface FetchSessionMetricCandidatesOptions {
startTime: string;
endTime: string;
limit: number;
environment?: string;
}
/**
* Fetch a page of metric events for the given metricName + window, ordered
* per `sort`, and filtered to events that have a sessionId. The events query
* per `sort`, and filtered to events that have a sessionId. The metrics query
* supports server-side ordering, so `sort` is passed straight through.
*/
export async function fetchSessionMetricCandidatesAsync(
@@ -199,15 +233,16 @@ export async function fetchSessionMetricCandidatesAsync(
limit: options.limit,
startTime: options.startTime,
endTime: options.endTime,
environment: options.environment,
});
return events.filter((e): e is SessionMetricCandidate => !!e.sessionId);
}
/**
* A custom log event that is guaranteed to belong to a session — used as a
* candidate when picking a session to inspect via `observe:session`.
* A user-defined log event that is guaranteed to belong to a session — used as
* a candidate when picking a session to inspect via `observe:session`.
*/
export type SessionLogCandidate = AppObserveCustomEvent & { sessionId: string };
export type SessionLogCandidate = AppObserveUserEvent & { sessionId: string };
export interface FetchSessionLogCandidatesOptions {
eventName: string;
@@ -216,11 +251,12 @@ export interface FetchSessionLogCandidatesOptions {
startTime: string;
endTime: string;
limit: number;
environment?: string;
}
/**
* Fetch a page of custom log events for the given eventName + window, ordered
* by timestamp server-side, and filtered to events that have a sessionId.
* Fetch a page of user-defined log events for the given eventName + window,
* ordered by timestamp server-side, and filtered to events that have a sessionId.
*/
export async function fetchSessionLogCandidatesAsync(
graphqlClient: ExpoGraphqlClient,
@@ -232,11 +268,12 @@ export async function fetchSessionLogCandidatesAsync(
limit: options.limit,
startTime: options.startTime,
endTime: options.endTime,
environment: options.environment,
orderBy: {
field: AppObserveCustomEventListOrderByField.Timestamp,
field: AppObserveUserEventListOrderByField.Timestamp,
direction: options.orderAscending
? AppObserveEventsOrderByDirection.Asc
: AppObserveEventsOrderByDirection.Desc,
? AppObserveOrderDirection.Asc
: AppObserveOrderDirection.Desc,
},
});
return events.filter((e): e is SessionLogCandidate => !!e.sessionId);
+19 -14
View File
@@ -1,37 +1,42 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { AppObserveAppVersion, AppObservePlatform, AppPlatform } from '../graphql/generated';
import { AppObserveAppVersion } from '../graphql/generated';
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
import Log from '../log';
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
[AppPlatform.Android]: AppObservePlatform.Android,
[AppPlatform.Ios]: AppObservePlatform.Ios,
};
import {
ObservePlatformKey,
ObservePlatformTarget,
observePlatformDisplayNames,
} from './platforms';
export interface AppVersionsResult {
platform: AppPlatform;
platform: ObservePlatformKey;
appVersions: AppObserveAppVersion[];
}
export async function fetchObserveVersionsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
platforms: AppPlatform[],
targets: ObservePlatformTarget[],
startTime: string,
endTime: string
endTime: string,
environment?: string
): Promise<AppVersionsResult[]> {
const queries = platforms.map(async (appPlatform): Promise<AppVersionsResult | null> => {
const observePlatform = appPlatformToObservePlatform[appPlatform];
const queries = targets.map(async (target): Promise<AppVersionsResult | null> => {
try {
const appVersions = await ObserveQuery.appVersionsAsync(graphqlClient, {
appId,
platform: observePlatform,
platforms: target.platforms,
startTime,
endTime,
environment,
});
return { platform: appPlatform, appVersions };
return { platform: target.key, appVersions };
} catch (error: any) {
Log.warn(`Failed to fetch app versions for ${observePlatform}: ${error.message}`);
Log.warn(
`Failed to fetch app versions for ${observePlatformDisplayNames[target.key]}: ${
error.message
}`
);
return null;
}
});
+13 -1
View File
@@ -10,11 +10,17 @@ export const ObserveProjectIdFlag = {
export const ObservePlatformFlag = {
platform: Flags.option({
description: 'Filter by platform',
description: 'Filter by platform ("apple" covers iOS, iPadOS, tvOS, and macOS)',
options: allowedPlatformFlagValues,
})(),
};
export const ObserveEnvironmentFlag = {
environment: Flags.string({
description: 'Filter by environment (e.g. production, development)',
}),
};
export const ObserveTimeRangeFlags = {
start: Flags.string({
description: 'Start of time range (ISO date)',
@@ -49,3 +55,9 @@ export const ObserveUpdateIdFlag = {
description: 'Filter by EAS update ID',
}),
};
export const ObserveBuildNumberFlag = {
'build-number': Flags.string({
description: 'Filter by app build number',
}),
};
@@ -1,10 +1,10 @@
import chalk from 'chalk';
import { AppObserveCustomEvent, AppObserveCustomEventName, PageInfo } from '../graphql/generated';
import { AppObserveUserEvent, AppObserveUserEventName, PageInfo } from '../graphql/generated';
import renderTextTable from '../utils/renderTextTable';
import { buildTimeRangeDescription, formatLogTimestamp } from './formatUtils';
function formatSeverity(event: AppObserveCustomEvent): string {
function formatSeverity(event: AppObserveUserEvent): string {
if (event.severityText) {
return event.severityText;
}
@@ -22,7 +22,7 @@ export interface ObserveCustomEventPropertyJson {
export interface ObserveCustomEventJson {
id: string;
eventName: string;
name: string;
timestamp: string;
sessionId: string | null;
severityNumber: number | null;
@@ -49,7 +49,7 @@ export interface BuildCustomEventsTableOptions {
}
export function buildObserveCustomEventsTable(
events: AppObserveCustomEvent[],
events: AppObserveUserEvent[],
pageInfo: PageInfo,
options?: BuildCustomEventsTableOptions
): string {
@@ -72,7 +72,7 @@ export function buildObserveCustomEventsTable(
const rows: string[][] = events.map(event => [
formatLogTimestamp(event.timestamp),
...(showEventName ? [event.eventName] : []),
...(showEventName ? [event.name] : []),
...(hasSeverity ? [formatSeverity(event)] : []),
`${event.appVersion} (${event.appBuildNumber})`,
`${event.deviceOs} ${event.deviceOsVersion}`,
@@ -102,7 +102,7 @@ export function buildObserveCustomEventsTable(
}
export function buildObserveCustomEventsJson(
events: AppObserveCustomEvent[],
events: AppObserveUserEvent[],
pageInfo: PageInfo
): {
events: ObserveCustomEventJson[];
@@ -111,7 +111,7 @@ export function buildObserveCustomEventsJson(
return {
events: events.map(event => ({
id: event.id,
eventName: event.eventName,
name: event.name,
timestamp: event.timestamp,
sessionId: event.sessionId ?? null,
severityNumber: event.severityNumber ?? null,
@@ -148,7 +148,7 @@ export interface BuildEmptyCustomEventsWithSuggestionsOptions {
export function buildObserveCustomEventsEmptyWithSuggestionsTable(
eventName: string,
names: AppObserveCustomEventName[],
names: AppObserveUserEventName[],
options?: BuildEmptyCustomEventsWithSuggestionsOptions
): string {
const lines: string[] = [];
@@ -163,7 +163,7 @@ export function buildObserveCustomEventsEmptyWithSuggestionsTable(
lines.push('', 'Available event names in this time range:', '');
const headers = ['Event Name', 'Count'];
const rows: string[][] = names.map(n => [n.eventName, n.count.toLocaleString()]);
const rows: string[][] = names.map(n => [n.name, n.count.toLocaleString()]);
lines.push(renderTextTable(headers, rows));
if (options?.isTruncated) {
@@ -175,18 +175,18 @@ export function buildObserveCustomEventsEmptyWithSuggestionsTable(
export function buildObserveCustomEventsEmptyWithSuggestionsJson(
eventName: string,
names: AppObserveCustomEventName[],
names: AppObserveUserEventName[],
isTruncated: boolean
): {
filteredEventName: string;
events: [];
availableEventNames: Array<{ eventName: string; count: number }>;
availableEventNames: Array<{ name: string; count: number }>;
availableEventNamesIsTruncated: boolean;
} {
return {
filteredEventName: eventName,
events: [],
availableEventNames: names.map(n => ({ eventName: n.eventName, count: n.count })),
availableEventNames: names.map(n => ({ name: n.name, count: n.count })),
availableEventNamesIsTruncated: isTruncated,
};
}
@@ -199,7 +199,7 @@ export interface BuildCustomEventNamesTableOptions {
}
export function buildObserveCustomEventNamesTable(
names: AppObserveCustomEventName[],
names: AppObserveUserEventName[],
options?: BuildCustomEventNamesTableOptions
): string {
if (names.length === 0) {
@@ -207,7 +207,7 @@ export function buildObserveCustomEventNamesTable(
}
const headers = ['Event Name', 'Count'];
const rows: string[][] = names.map(n => [n.eventName, n.count.toLocaleString()]);
const rows: string[][] = names.map(n => [n.name, n.count.toLocaleString()]);
const lines: string[] = [];
@@ -227,11 +227,11 @@ export function buildObserveCustomEventNamesTable(
}
export function buildObserveCustomEventNamesJson(
names: AppObserveCustomEventName[],
names: AppObserveUserEventName[],
isTruncated: boolean
): { names: Array<{ eventName: string; count: number }>; isTruncated: boolean } {
): { names: Array<{ name: string; count: number }>; isTruncated: boolean } {
return {
names: names.map(n => ({ eventName: n.eventName, count: n.count })),
names: names.map(n => ({ name: n.name, count: n.count })),
isTruncated,
};
}
+9 -9
View File
@@ -1,14 +1,14 @@
import chalk from 'chalk';
import { AppObserveEvent, PageInfo } from '../graphql/generated';
import { AppObserveMetric, PageInfo } from '../graphql/generated';
import renderTextTable from '../utils/renderTextTable';
import { buildTimeRangeDescription, formatTimestamp } from './formatUtils';
import { getMetricDisplayName } from './metricNames';
export interface ObserveEventJson {
id: string;
metricName: string;
metricValue: number;
name: string;
value: number;
appVersion: string;
appBuildNumber: string;
appUpdateId: string | null;
@@ -23,7 +23,7 @@ export interface ObserveEventJson {
routeName: string | null;
}
function resolveCustomParams(event: AppObserveEvent): { [key: string]: any } | null {
function resolveCustomParams(event: AppObserveMetric): { [key: string]: any } | null {
return event.customParams ?? null;
}
@@ -36,7 +36,7 @@ export interface BuildEventsTableOptions {
}
export function buildObserveEventsTable(
events: AppObserveEvent[],
events: AppObserveMetric[],
pageInfo: PageInfo,
options?: BuildEventsTableOptions
): string {
@@ -57,7 +57,7 @@ export function buildObserveEventsTable(
];
const rows: string[][] = events.map(event => [
`${event.metricValue.toFixed(2)}s`,
`${event.value.toFixed(2)}s`,
`${event.appVersion} (${event.appBuildNumber})`,
...(hasUpdates ? [event.appUpdateId ?? '-'] : []),
`${event.deviceOs} ${event.deviceOsVersion}`,
@@ -88,14 +88,14 @@ export function buildObserveEventsTable(
}
export function buildObserveEventsJson(
events: AppObserveEvent[],
events: AppObserveMetric[],
pageInfo: PageInfo
): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } {
return {
events: events.map(event => ({
id: event.id,
metricName: event.metricName,
metricValue: event.metricValue,
name: event.name,
value: event.value,
appVersion: event.appVersion,
appBuildNumber: event.appBuildNumber,
appUpdateId: event.appUpdateId ?? null,
+14 -9
View File
@@ -1,11 +1,10 @@
import chalk from 'chalk';
import { EasCommandError } from '../commandUtils/errors';
import { AppPlatform } from '../graphql/generated';
import { appPlatformDisplayNames } from '../platform';
import renderTextTable from '../utils/renderTextTable';
import { buildTimeRangeDescription } from './formatUtils';
import { getMetricDisplayName } from './metricNames';
import { ObservePlatformKey, observePlatformDisplayNames } from './platforms';
export type StatisticKey =
| 'min'
@@ -84,21 +83,27 @@ export interface MetricValues {
eventCount: number | null | undefined;
}
type ObserveMetricsKey = `${string}:${AppPlatform}`;
type ObserveMetricsKey = `${string}:${ObservePlatformKey}`;
export type ObserveMetricsMap = Map<ObserveMetricsKey, Map<string, MetricValues>>;
export type BuildNumbersMap = Map<ObserveMetricsKey, string[]>;
export type UpdateIdsMap = Map<ObserveMetricsKey, string[]>;
export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey {
export function makeMetricsKey(
appVersion: string,
platform: ObservePlatformKey
): ObserveMetricsKey {
return `${appVersion}:${platform}`;
}
function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } {
function parseMetricsKey(key: ObserveMetricsKey): {
appVersion: string;
platform: ObservePlatformKey;
} {
const lastColon = key.lastIndexOf(':');
return {
appVersion: key.slice(0, lastColon),
platform: key.slice(lastColon + 1) as AppPlatform,
platform: key.slice(lastColon + 1) as ObservePlatformKey,
};
}
@@ -106,7 +111,7 @@ export type MetricValuesJson = Partial<Record<StatisticKey, number | null>>;
export interface ObserveMetricsVersionResult {
appVersion: string;
platform: AppPlatform;
platform: ObservePlatformKey;
buildNumbers: string[];
updateIds: string[];
metrics: Record<string, MetricValuesJson>;
@@ -196,7 +201,7 @@ export function buildObserveMetricsTable(
const summaryLine = `${statsDesc} values${countSuffix}${timeDesc ? ` ${timeDesc}` : ''}`;
// Group results by platform
const byPlatform = new Map<AppPlatform, ObserveMetricsVersionResult[]>();
const byPlatform = new Map<ObservePlatformKey, ObserveMetricsVersionResult[]>();
for (const result of results) {
if (!byPlatform.has(result.platform)) {
byPlatform.set(result.platform, []);
@@ -226,7 +231,7 @@ export function buildObserveMetricsTable(
for (const [platform, platformResults] of byPlatform) {
sections.push('');
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
sections.push(chalk.bold(observePlatformDisplayNames[platform]));
const rows: string[][] = [];
for (const result of platformResults) {
@@ -1,10 +1,9 @@
import chalk from 'chalk';
import { EasCommandError } from '../commandUtils/errors';
import { AppPlatform } from '../graphql/generated';
import { appPlatformDisplayNames } from '../platform';
import renderTextTable from '../utils/renderTextTable';
import { NavigationRouteWithPlatform } from './fetchNavigationRoutes';
import { ObservePlatformKey, observePlatformDisplayNames } from './platforms';
import { buildTimeRangeDescription } from './formatUtils';
import { getMetricDisplayName } from './metricNames';
@@ -73,7 +72,7 @@ function formatMergedCell(
export interface NavigationRouteValuesJson {
routeName: string;
platform: AppPlatform;
platform: ObservePlatformKey;
metrics: Record<string, Partial<Record<NavigationStatKey, number | null>>>;
}
@@ -102,7 +101,7 @@ export function buildObserveNavigationRoutesJson(
routes: NavigationRouteWithPlatform[],
metricNames: string[],
stats: NavigationStatKey[],
pageInfoByPlatform: Map<AppPlatform, { hasNextPage: boolean; endCursor?: string | null }>
pageInfoByPlatform: Map<ObservePlatformKey, { hasNextPage: boolean; endCursor?: string | null }>
): ObserveNavigationRoutesJsonOutput {
const jsonRoutes: NavigationRouteValuesJson[] = routes.map(node => {
const metrics: Record<string, Partial<Record<NavigationStatKey, number | null>>> = {};
@@ -139,7 +138,7 @@ export interface BuildNavigationRoutesTableOptions {
daysBack?: number;
startTime?: string;
endTime?: string;
pageInfoByPlatform?: Map<AppPlatform, { hasNextPage: boolean; endCursor?: string | null }>;
pageInfoByPlatform?: Map<ObservePlatformKey, { hasNextPage: boolean; endCursor?: string | null }>;
}
export function buildObserveNavigationRoutesTable(
@@ -167,7 +166,7 @@ export function buildObserveNavigationRoutesTable(
const countSuffix = hasCount && displayStats.length > 0 ? ' (navigation count)' : '';
const summaryLine = `${statsDesc} values${countSuffix}${timeDesc ? ` ${timeDesc}` : ''}`;
const byPlatform = new Map<AppPlatform, NavigationRouteWithPlatform[]>();
const byPlatform = new Map<ObservePlatformKey, NavigationRouteWithPlatform[]>();
for (const node of routes) {
if (!byPlatform.has(node.platform)) {
byPlatform.set(node.platform, []);
@@ -204,7 +203,7 @@ export function buildObserveNavigationRoutesTable(
for (const [platform, platformRoutes] of byPlatform) {
sections.push('');
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
sections.push(chalk.bold(observePlatformDisplayNames[platform]));
const rows: string[][] = platformRoutes.map(node => {
const cells: string[] = [];
@@ -230,7 +229,7 @@ export function buildObserveNavigationRoutesTable(
const pageInfo = options?.pageInfoByPlatform?.get(platform);
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
sections.push(
`Next page (${appPlatformDisplayNames[platform]}): --after ${pageInfo.endCursor}`
`Next page (${observePlatformDisplayNames[platform]}): --after ${pageInfo.endCursor}`
);
}
}
+11 -11
View File
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import { AppObserveCustomEvent, AppObserveEvent } from '../graphql/generated';
import { AppObserveMetric, AppObserveUserEvent } from '../graphql/generated';
import { SessionEventEntry, SessionMetadata } from './fetchSessions';
import { formatLogTimestamp } from './formatUtils';
import { getMetricDisplayName } from './metricNames';
@@ -13,16 +13,16 @@ export interface BuildSessionEventsOptions {
}
function formatEntryName(entry: SessionEventEntry): string {
if (entry.source === 'metric' && entry.metricName) {
const name = getMetricDisplayName(entry.metricName);
if (entry.source === 'metric') {
const name = getMetricDisplayName(entry.name);
return entry.routeName ? `${name} · ${entry.routeName}` : name;
}
return entry.eventName ?? '-';
return entry.name;
}
function formatMetricEntryValue(entry: SessionEventEntry): string {
if (typeof entry.metricValue === 'number') {
return `${entry.metricValue.toFixed(2)}s`;
if (typeof entry.value === 'number') {
return `${entry.value.toFixed(2)}s`;
}
return '-';
}
@@ -159,9 +159,9 @@ export function shortSessionId(sessionId: string): string {
* One-line title for a metric event shown in the observe:session candidate
* picker, e.g. `Jan 15, 10:00:00.000 AM · Startup TTI 1.23s · 1.0.0 · iOS 17.0 · session abc…1234`.
*/
export function formatMetricCandidateTitle(event: AppObserveEvent): string {
const displayName = getMetricDisplayName(event.metricName);
const value = `${event.metricValue.toFixed(2)}s`;
export function formatMetricCandidateTitle(event: AppObserveMetric): string {
const displayName = getMetricDisplayName(event.name);
const value = `${event.value.toFixed(2)}s`;
const timestamp = formatLogTimestamp(event.timestamp);
const shortSession = shortSessionId(event.sessionId ?? '');
const device = `${event.deviceOs} ${event.deviceOsVersion}`;
@@ -172,11 +172,11 @@ export function formatMetricCandidateTitle(event: AppObserveEvent): string {
* One-line title for a custom log event shown in the observe:session
* candidate picker.
*/
export function formatLogCandidateTitle(event: AppObserveCustomEvent): string {
export function formatLogCandidateTitle(event: AppObserveUserEvent): string {
const timestamp = formatLogTimestamp(event.timestamp);
const severity =
event.severityText ?? (event.severityNumber != null ? String(event.severityNumber) : '-');
const shortSession = shortSessionId(event.sessionId ?? '');
const device = `${event.deviceOs} ${event.deviceOsVersion}`;
return `${timestamp} · ${event.eventName} · ${severity} · ${event.appVersion} · ${device} · session ${shortSession}`;
return `${timestamp} · ${event.name} · ${severity} · ${event.appVersion} · ${device} · session ${shortSession}`;
}
@@ -5,10 +5,10 @@ import {
AppObserveAppEasBuild,
AppObserveAppUpdate,
} from '../graphql/generated';
import { appPlatformDisplayNames } from '../platform';
import renderTextTable from '../utils/renderTextTable';
import { AppVersionsResult } from './fetchVersions';
import { formatDate } from './formatUtils';
import { observePlatformDisplayNames } from './platforms';
export interface AppVersionJson {
platform: string;
@@ -109,7 +109,7 @@ export function buildObserveVersionsTable(results: AppVersionsResult[]): string
if (sections.length > 0) {
sections.push('');
}
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
sections.push(chalk.bold(observePlatformDisplayNames[platform]));
const rows: string[][] = appVersions.map(version => [
version.appVersion,
+53 -42
View File
@@ -1,58 +1,69 @@
import { AppObservePlatform, AppPlatform } from '../graphql/generated';
import { AppObservePlatform } from '../graphql/generated';
/**
* Allowed values for the --platform flag in observe commands.
* Derived from the AppObservePlatform enum so new platforms added on
* the server are automatically picked up.
*/
export const allowedPlatformFlagValues = Object.values(AppObservePlatform).map(s =>
s.toLowerCase()
);
export const APPLE_PLATFORM_FLAG_VALUE = 'apple';
const defaultAppObservePlatform = AppObservePlatform.Ios;
const defaultAppPlatform = AppPlatform.Ios;
const APPLE_OBSERVE_PLATFORMS: AppObservePlatform[] = [
AppObservePlatform.Ios,
AppObservePlatform.Ipados,
AppObservePlatform.Tvos,
AppObservePlatform.Macos,
];
export const allowedPlatformFlagValues = [
...Object.values(AppObservePlatform).map(s => s.toLowerCase()),
APPLE_PLATFORM_FLAG_VALUE,
];
type PlatformFlagValue = (typeof allowedPlatformFlagValues)[number];
/**
* Resolve a single AppObservePlatform from a --platform flag value.
* Returns undefined when no flag was provided.
*/
export function appObservePlatformFromFlag(
export type ObservePlatformKey = AppObservePlatform | 'APPLE';
export const observePlatformDisplayNames: Record<ObservePlatformKey, string> = {
[AppObservePlatform.Android]: 'Android',
[AppObservePlatform.Ios]: 'iOS',
[AppObservePlatform.Ipados]: 'iPadOS',
[AppObservePlatform.Tvos]: 'tvOS',
[AppObservePlatform.Macos]: 'macOS',
APPLE: 'Apple',
};
export interface ObservePlatformTarget {
key: ObservePlatformKey;
platforms: AppObservePlatform[];
}
function singlePlatformFromFlag(flag: PlatformFlagValue): AppObservePlatform {
const platform = Object.values(AppObservePlatform).find(p => p.toLowerCase() === flag);
if (!platform) {
throw new Error(`Unknown platform flag value: "${flag}"`);
}
return platform;
}
export function observePlatformsFromFlag(
flag: PlatformFlagValue | undefined
): AppObservePlatform | undefined {
): AppObservePlatform[] | undefined {
if (!flag) {
return undefined;
}
switch (flag) {
case 'android':
return AppObservePlatform.Android;
case 'ios':
return AppObservePlatform.Ios;
if (flag === APPLE_PLATFORM_FLAG_VALUE) {
return APPLE_OBSERVE_PLATFORMS;
}
return defaultAppObservePlatform;
return [singlePlatformFromFlag(flag)];
}
/**
* Resolve a list of AppPlatform values from a --platform flag value.
* Returns the single matching platform when a flag is provided, or all
* known platforms when no flag is provided (so the caller queries every
* platform).
*/
export function appPlatformsFromFlag(flag: PlatformFlagValue | undefined): AppPlatform[] {
export function observePlatformTargetsFromFlag(
flag: PlatformFlagValue | undefined
): ObservePlatformTarget[] {
if (!flag) {
return [AppPlatform.Android, AppPlatform.Ios];
return [
{ key: AppObservePlatform.Android, platforms: [AppObservePlatform.Android] },
{ key: AppObservePlatform.Ios, platforms: [AppObservePlatform.Ios] },
];
}
switch (flag) {
case 'android':
return [AppPlatform.Android];
case 'ios':
return [AppPlatform.Ios];
if (flag === APPLE_PLATFORM_FLAG_VALUE) {
return [{ key: 'APPLE', platforms: APPLE_OBSERVE_PLATFORMS }];
}
return [defaultAppPlatform];
const platform = singlePlatformFromFlag(flag);
return [{ key: platform, platforms: [platform] }];
}
export const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
[AppPlatform.Android]: AppObservePlatform.Android,
[AppPlatform.Ios]: AppObservePlatform.Ios,
};
@@ -0,0 +1,474 @@
import chalk from 'chalk';
import {
getBuildLogsUrl,
getProjectDashboardUrl,
getSubmissionUrl,
getUpdateGroupUrl,
getWorkflowRunUrl,
} from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { WorkflowRunResult } from '../commandUtils/workflow/types';
import { processWorkflowRuns } from '../commandUtils/workflow/utils';
import {
AppPlatform,
BuildFragment,
BuildStatus,
DistributionType,
SubmissionAndroidReleaseStatus,
SubmissionStatus,
UpdateFragment,
WorkflowRunStatus,
} 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 { appPlatformDisplayNames } from '../platform';
import { getActorDisplayName } from '../user/User';
import { fromNow } from '../utils/date';
export const PROJECT_STATUS_DEFAULT_LIMIT = 3;
export interface ProjectStatusSummary {
generatedAt: string;
limit: number;
hasMore: {
productionBuilds: boolean;
developmentBuilds: boolean;
workflowRuns: boolean;
submissions: boolean;
updates: boolean;
};
project: {
id: string;
name: string;
fullName: string;
slug: string;
account: string;
url: string;
};
productionBuilds: BuildStatusSummary[];
developmentBuilds: BuildStatusSummary[];
workflowRuns: WorkflowRunStatusSummary[];
submissions: SubmissionStatusSummary[];
updates: UpdateStatusSummary[];
}
interface ActivityErrorSummary {
code: string | null;
message: string | null;
}
interface BuildStatusSummary {
id: string;
platform: AppPlatform;
status: BuildStatus;
distribution: DistributionType | null;
buildProfile: string | null;
appVersion: string | null;
appBuildVersion: string | null;
runtimeVersion: string | null;
sdkVersion: string | null;
channel: string | null;
gitCommitHash: string | null;
gitCommitMessage: string | null;
message: string | null;
initiatingActor: string | null;
createdAt: string;
completedAt: string | null;
url: string;
error: (ActivityErrorSummary & { docsUrl: string | null }) | null;
}
interface SubmissionStatusSummary {
id: string;
platform: AppPlatform;
status: SubmissionStatus;
appIdentifier: string | null;
androidTrack: string | null;
androidReleaseStatus: SubmissionAndroidReleaseStatus | null;
rollout: number | null;
buildId: string | null;
initiatingActor: string | null;
createdAt: string;
completedAt: string | null;
url: string;
error: ActivityErrorSummary | null;
}
interface WorkflowRunStatusSummary extends WorkflowRunResult {
errors: {
title: string | null;
message: string;
}[];
url: string;
}
interface UpdateStatusSummary {
group: string;
branch: string;
message: string | null;
runtimeVersion: string;
platforms: string[];
isRollBackToEmbedded: boolean;
gitCommitHash: string | null;
rolloutPercentage: number | null;
environment: unknown | null;
initiatingActor: string | null;
createdAt: string;
url: string;
}
export async function getProjectStatusAsync(
graphqlClient: ExpoGraphqlClient,
{ projectId, limit }: { projectId: string; limit: number }
): Promise<ProjectStatusSummary> {
const queryLimitForHasMore = limit + 1;
const [app, productionBuilds, developmentBuilds, workflowRuns, submissions, updateGroups] =
await Promise.all([
AppQuery.byIdAsync(graphqlClient, projectId),
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
limit: queryLimitForHasMore,
offset: 0,
filter: { developmentClient: false, distribution: DistributionType.Store },
}),
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
limit: queryLimitForHasMore,
offset: 0,
filter: { developmentClient: true },
}),
AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
undefined,
queryLimitForHasMore
),
SubmissionQuery.forProjectStatusAsync(graphqlClient, projectId, {
limit: queryLimitForHasMore,
offset: 0,
}),
UpdateQuery.viewUpdateGroupsOnAppAsync(graphqlClient, {
appId: projectId,
limit: queryLimitForHasMore,
offset: 0,
}),
]);
const accountName = app.ownerAccount.name;
const projectName = app.slug;
const limitedWorkflowRuns = workflowRuns.slice(0, limit);
return {
generatedAt: new Date().toISOString(),
limit,
hasMore: {
productionBuilds: productionBuilds.length > limit,
developmentBuilds: developmentBuilds.length > limit,
workflowRuns: workflowRuns.length > limit,
submissions: submissions.length > limit,
updates: updateGroups.length > limit,
},
project: {
id: app.id,
name: app.name,
fullName: app.fullName,
slug: app.slug,
account: accountName,
url: getProjectDashboardUrl(accountName, projectName),
},
productionBuilds: productionBuilds.slice(0, limit).map(toBuildSummary),
developmentBuilds: developmentBuilds.slice(0, limit).map(toBuildSummary),
workflowRuns: processWorkflowRuns(limitedWorkflowRuns).map((run, index) => ({
...run,
errors: limitedWorkflowRuns[index].errors.map(error => ({
title: error.title ?? null,
message: error.message,
})),
url: getWorkflowRunUrl(accountName, projectName, run.id),
})),
submissions: submissions
.slice(0, limit)
.map(submission => toSubmissionSummary(submission, accountName, projectName)),
updates: updateGroups
.slice(0, limit)
.map(updateGroup => toUpdateSummary(updateGroup, accountName, projectName)),
};
}
function toBuildSummary(build: BuildFragment): BuildStatusSummary {
return {
id: build.id,
platform: build.platform,
status: build.status,
distribution: build.distribution ?? null,
buildProfile: build.buildProfile ?? null,
appVersion: build.appVersion ?? null,
appBuildVersion: build.appBuildVersion ?? null,
runtimeVersion: build.runtime?.version ?? null,
sdkVersion: build.sdkVersion ?? null,
channel: build.updateChannel?.name ?? null,
gitCommitHash: build.gitCommitHash ?? null,
gitCommitMessage: build.gitCommitMessage?.split('\n')[0] ?? null,
message: build.message ?? null,
initiatingActor: build.initiatingActor?.displayName ?? null,
createdAt: build.createdAt,
completedAt: build.completedAt ?? null,
url: getBuildLogsUrl(build),
error: build.error
? {
code: build.error.errorCode,
message: build.error.message,
docsUrl: build.error.docsUrl ?? null,
}
: null,
};
}
function toSubmissionSummary(
submission: ProjectStatusSubmission,
accountName: string,
projectName: string
): SubmissionStatusSummary {
return {
id: submission.id,
platform: submission.platform,
status: submission.status,
appIdentifier:
submission.androidConfig?.applicationIdentifier ??
submission.iosConfig?.ascAppIdentifier ??
null,
androidTrack: submission.androidConfig?.track ?? null,
androidReleaseStatus: submission.androidConfig?.releaseStatus ?? null,
rollout: submission.androidConfig?.rollout ?? null,
buildId: submission.submittedBuild?.id ?? null,
initiatingActor: submission.initiatingActor?.displayName ?? null,
createdAt: submission.createdAt,
completedAt: submission.completedAt ?? null,
url: getSubmissionUrl(accountName, projectName, submission.id),
error: submission.error
? {
code: submission.error.errorCode ?? null,
message: submission.error.message ?? null,
}
: null,
};
}
function toUpdateSummary(
updateGroup: UpdateFragment[],
accountName: string,
projectName: string
): UpdateStatusSummary {
const representativeUpdate = updateGroup[0];
const platforms = [...new Set(updateGroup.map(update => update.platform))].sort();
return {
group: representativeUpdate.group,
branch: representativeUpdate.branch.name,
message: representativeUpdate.message ?? null,
runtimeVersion: representativeUpdate.runtime.version,
platforms,
isRollBackToEmbedded: representativeUpdate.isRollBackToEmbedded,
gitCommitHash: representativeUpdate.gitCommitHash ?? null,
rolloutPercentage: representativeUpdate.rolloutPercentage ?? null,
environment: representativeUpdate.environment ?? null,
initiatingActor: representativeUpdate.actor
? getActorDisplayName(representativeUpdate.actor)
: null,
createdAt: representativeUpdate.createdAt,
url: getUpdateGroupUrl(accountName, projectName, representativeUpdate.group),
};
}
export function printProjectStatusAsText(status: ProjectStatusSummary): void {
Log.addNewLineIfNone();
Log.log(chalk.bold(status.project.fullName));
Log.log(chalk.dim(status.project.url));
renderSection(
'Production builds',
status.productionBuilds,
renderBuild,
status.hasMore.productionBuilds
);
renderSection(
'Development builds',
status.developmentBuilds,
renderBuild,
status.hasMore.developmentBuilds
);
renderSection(
'Workflow runs',
status.workflowRuns,
renderWorkflowRun,
status.hasMore.workflowRuns
);
renderSection('Submissions', status.submissions, renderSubmission, status.hasMore.submissions);
renderSection('Updates', status.updates, renderUpdate, status.hasMore.updates);
}
function renderSection<T>(
title: string,
items: T[],
renderItem: (item: T) => string,
hasMore: boolean
): void {
Log.newLine();
Log.log(chalk.bold(title));
if (items.length === 0) {
Log.log(chalk.dim(' None'));
return;
}
for (const item of items) {
Log.log(renderItem(item));
}
if (hasMore) {
Log.log(chalk.dim(' More available; use --limit to show more.'));
}
}
function renderBuild(build: BuildStatusSummary): string {
const lines = [
` ${joinMeta([
colorBuildStatus(build.status),
appPlatformDisplayNames[build.platform],
build.buildProfile,
timeAgo(build.createdAt),
])}`,
];
const version = build.appVersion
? `${build.appVersion}${build.appBuildVersion ? ` (${build.appBuildVersion})` : ''}`
: null;
if (version) {
lines.push(` ${chalk.dim('version')} ${version}`);
}
if (build.gitCommitMessage) {
lines.push(` ${chalk.dim(shortHash(build.gitCommitHash))} ${build.gitCommitMessage}`);
}
if (build.error?.message) {
lines.push(` ${chalk.red(build.error.message)}`);
}
lines.push(` ${chalk.dim(build.url)}`);
return lines.join('\n');
}
function renderWorkflowRun(run: WorkflowRunStatusSummary): string {
const lines = [
` ${joinMeta([
colorWorkflowStatus(run.status),
run.workflowName ?? run.workflowFileName,
timeAgo(run.startedAt),
])}`,
];
if (run.gitCommitMessage) {
lines.push(` ${chalk.dim(shortHash(run.gitCommitHash))} ${run.gitCommitMessage}`);
}
for (const error of run.errors) {
lines.push(` ${chalk.red(error.message)}`);
}
lines.push(` ${chalk.dim(run.url)}`);
return lines.join('\n');
}
function renderSubmission(submission: SubmissionStatusSummary): string {
const lines = [
` ${joinMeta([
colorSubmissionStatus(submission.status),
appPlatformDisplayNames[submission.platform],
submission.androidTrack ? `track: ${submission.androidTrack}` : null,
timeAgo(submission.createdAt),
])}`,
];
if (submission.buildId) {
lines.push(` ${chalk.dim('build')} ${submission.buildId}`);
}
if (submission.error?.message) {
lines.push(` ${chalk.red(submission.error.message)}`);
}
lines.push(` ${chalk.dim(submission.url)}`);
return lines.join('\n');
}
function renderUpdate(update: UpdateStatusSummary): string {
const lines = [
` ${joinMeta([
chalk.cyan(update.branch),
update.platforms.join(', '),
`runtime: ${update.runtimeVersion}`,
timeAgo(update.createdAt),
])}`,
];
const message = update.isRollBackToEmbedded ? 'Roll back to embedded' : update.message;
if (message) {
lines.push(` ${chalk.dim(shortHash(update.gitCommitHash))} ${message}`);
}
lines.push(` ${chalk.dim(update.url)}`);
return lines.join('\n');
}
function joinMeta(parts: (string | null | undefined)[]): string {
return parts.filter((part): part is string => Boolean(part)).join(chalk.dim(' · '));
}
function timeAgo(isoDate: string | null): string | null {
if (!isoDate) {
return null;
}
return `${fromNow(new Date(isoDate))} ago`;
}
function shortHash(hash: string | null): string {
return hash ? hash.slice(0, 7) : '-------';
}
function colorBuildStatus(status: BuildStatus): string {
switch (status) {
case BuildStatus.Finished:
return chalk.green('finished');
case BuildStatus.Errored:
return chalk.red('errored');
case BuildStatus.Canceled:
case BuildStatus.PendingCancel:
return chalk.gray('canceled');
case BuildStatus.New:
case BuildStatus.InQueue:
case BuildStatus.InProgress:
return chalk.blue(status.toLowerCase().replace(/_/g, ' '));
default:
return String(status).toLowerCase().replace(/_/g, ' ');
}
}
function colorSubmissionStatus(status: SubmissionStatus): string {
switch (status) {
case SubmissionStatus.Finished:
return chalk.green('finished');
case SubmissionStatus.Errored:
return chalk.red('errored');
case SubmissionStatus.Canceled:
return chalk.gray('canceled');
default:
return chalk.blue(status.toLowerCase().replace(/_/g, ' '));
}
}
function colorWorkflowStatus(status: string): string {
switch (status) {
case WorkflowRunStatus.Success:
return chalk.green('success');
case WorkflowRunStatus.Failure:
return chalk.red('failure');
case WorkflowRunStatus.Canceled:
return chalk.gray('canceled');
case WorkflowRunStatus.ActionRequired:
return chalk.yellow('action required');
case WorkflowRunStatus.New:
case WorkflowRunStatus.InProgress:
return chalk.blue(status.toLowerCase().replace(/_/g, ' '));
default:
return status.toLowerCase().replace(/_/g, ' ');
}
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more