mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076585b7f0 | ||
|
|
dc6a5a7ec6 | ||
|
|
d19a84d246 | ||
|
|
b4b05dc5fa | ||
|
|
3c3574ab79 | ||
|
|
729a01fe64 | ||
|
|
72a511017e | ||
|
|
a58609e09f | ||
|
|
29f102c367 | ||
|
|
06563efbb9 | ||
|
|
59f9add53c | ||
|
|
1946562247 | ||
|
|
24a65ad80f | ||
|
|
37ea60c20c | ||
|
|
99cbcae249 | ||
|
|
a426042784 | ||
|
|
1cb06a6cb2 | ||
|
|
5236871b0b | ||
|
|
ceb20c9dd6 | ||
|
|
83fa42c179 | ||
|
|
7727b9c347 | ||
|
|
b015c0b226 | ||
|
|
882687818f | ||
|
|
37f5163872 | ||
|
|
df2c81d131 | ||
|
|
236398a823 | ||
|
|
eebe02eef2 | ||
|
|
4b1e5a76e2 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "23.1.0",
|
||||
"PRODUCTION": "23.1.0"
|
||||
"STAGING": "23.2.0",
|
||||
"PRODUCTION": "23.2.0"
|
||||
}
|
||||
+15
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ const config: CodegenConfig = {
|
||||
'src/graphql/generated.ts': {
|
||||
plugins: ['typescript', 'typescript-operations'],
|
||||
config: {
|
||||
addUnderscoreToArgsType: true,
|
||||
dedupeOperationSuffix: true,
|
||||
},
|
||||
hooks: {
|
||||
|
||||
Generated
+6296
-39
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=';
|
||||
|
||||
-3
@@ -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 [1m--variable-name VARIABLE_NAME[22m flag to fix the issue"`;
|
||||
+27
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
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');
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user