mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5409c03d58 | ||
|
|
a941930ea7 | ||
|
|
74572101ac | ||
|
|
7b050cae3f | ||
|
|
d193860668 | ||
|
|
1da0be8529 | ||
|
|
08b4346620 | ||
|
|
4d6d9b1fc4 | ||
|
|
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: |
|
||||
|
||||
+26
-2
@@ -6,6 +6,28 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
|
||||
@@ -14,8 +36,6 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
@@ -51,6 +71,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
|
||||
- [eas-cli] Add `eas project:status` (alias `eas status`) command that prints a snapshot of the project's recent builds, development builds, workflow runs, submissions, and updates, with agent-friendly JSON metadata, activity links, structured errors, submission-to-build correlation, and `--non-interactive` support. ([#4006](https://github.com/expo/eas-cli/pull/4006) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
@@ -96,6 +117,9 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--environment` flag to the `eas observe:*` commands. ([#4275](https://github.com/expo/eas-cli/pull/4275) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `apple` value to the `--platform` flag of the `eas observe:*` commands. ([#4276](https://github.com/expo/eas-cli/pull/4276) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--build-number` flag to `eas observe:metrics` and `eas observe:events`. ([#4278](https://github.com/expo/eas-cli/pull/4278) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "23.1.0",
|
||||
"PRODUCTION": "23.1.0"
|
||||
"STAGING": "23.2.0",
|
||||
"PRODUCTION": "23.2.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "23.2.0",
|
||||
"version": "24.0.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "23.2.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,17 +39,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "23.0.0",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/template-file": "23.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
|
||||
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
|
||||
|
||||
describe(CustomBuildContext, () => {
|
||||
it('copies the MCP server URL from the build context', () => {
|
||||
const ctx = new BuildContext(createTestIosJob(), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
});
|
||||
|
||||
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
|
||||
});
|
||||
|
||||
it('should not lose workflowInterpolationContext', () => {
|
||||
const contextUploadArtifact = jest.fn();
|
||||
const ctx = new BuildContext(
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.mcpServerUrl = options.mcpServerUrl;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
|
||||
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -83,6 +83,31 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
device_identifier: 'medium_phone',
|
||||
system_image_package: systemImagePackage,
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 262,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
|
||||
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'medium_phone',
|
||||
systemImagePackage,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
|
||||
+19
-5
@@ -9,6 +9,7 @@ import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
@@ -41,7 +42,7 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
ensureFfmpegInstalledAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
@@ -127,18 +128,31 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
} as never
|
||||
);
|
||||
|
||||
// (1) The event log flag is enabled, before the tool-server is launched.
|
||||
// (1) FFmpeg setup starts in the background before Argent setup.
|
||||
expect(ensureFfmpegInstalledOnceAsync).toHaveBeenCalledWith({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: { EXISTING: 'value' },
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(jest.mocked(ensureFfmpegInstalledOnceAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The event log flag is enabled, before the tool-server is launched.
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
const enableEventLogIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('tool-server-event-log')
|
||||
);
|
||||
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The tool-server and the collector are pinned to the exact same event log path.
|
||||
// (3) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
expect(serverEnv.EXISTING).toBe('value');
|
||||
@@ -147,7 +161,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
eventLogPath: EXPECTED_EVENT_LOG_PATH,
|
||||
});
|
||||
|
||||
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
|
||||
// (4) Collection starts (before we wait for the session to stop) and (5) is torn down
|
||||
// afterwards, exactly once.
|
||||
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bunx resolve it.')
|
||||
expect.stringContaining('Continuing and letting bun x resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,21 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_width',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_height',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_density',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'count',
|
||||
required: false,
|
||||
@@ -86,6 +101,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const lcdWidth = inputs.lcd_width.value as number | undefined;
|
||||
const lcdHeight = inputs.lcd_height.value as number | undefined;
|
||||
const lcdDensity = inputs.lcd_density.value as number | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
@@ -129,6 +147,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledAsync,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
@@ -103,32 +103,39 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Argent shells out to ffmpeg to record the screen. Installing it pulls a
|
||||
// large Homebrew dependency tree, so do not block session readiness on it:
|
||||
// the session comes up at its usual speed and only a recording started in
|
||||
// the first moments misses ffmpeg. Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
// recording started in the first moments may miss ffmpeg.
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
'bun',
|
||||
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
|
||||
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
|
||||
// Keep Argent itself in foreground mode under the detached bun process. This preserves
|
||||
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bunx',
|
||||
command: 'bun',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
@@ -293,7 +300,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting bunx resolve it.`
|
||||
`Continuing and letting bun x resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,19 +90,24 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
|
||||
const ascApiKey = z
|
||||
.object({
|
||||
issuer_id: z.string(),
|
||||
issuer_id: z.string().nullish(),
|
||||
key_id: z.string(),
|
||||
key: z.string(),
|
||||
})
|
||||
.parse(ascApiKeyJson);
|
||||
|
||||
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
|
||||
const token = await new jose.SignJWT({})
|
||||
const jwt = new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
|
||||
.setIssuer(ascApiKey.issuer_id)
|
||||
.setAudience('appstoreconnect-v1')
|
||||
.setExpirationTime('20m')
|
||||
.sign(privateKey);
|
||||
.setExpirationTime('20m');
|
||||
if (ascApiKey.issuer_id) {
|
||||
jwt.setIssuer(ascApiKey.issuer_id);
|
||||
} else {
|
||||
// Nullish issuer_id means an individual API key
|
||||
jwt.setSubject('user');
|
||||
}
|
||||
const token = await jwt.sign(privateKey);
|
||||
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
|
||||
|
||||
@@ -15,10 +15,11 @@ import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
ensureFfmpegInstalledAsync,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
metricsCorsOriginToServeSimArgs,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -198,7 +199,7 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
'1280',
|
||||
'960',
|
||||
'--video-bitrate',
|
||||
'6000000',
|
||||
'--video-fps',
|
||||
@@ -359,13 +360,20 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('starts expo-device-hub for Linux and cleans up the preview resources', async () => {
|
||||
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
|
||||
const packageVersion = '1.2.3';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(
|
||||
Promise.reject(new Error('ffmpeg is missing')) as ReturnType<typeof spawn>
|
||||
)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
@@ -376,7 +384,28 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
packageVersion,
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
expect(spawnCalls[1]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'update'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
expect(spawnCalls[2]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'install', '-y', 'ffmpeg'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
const expoDeviceHubCallIndex = spawnCalls.findIndex(([command]) => command === 'npx');
|
||||
expect(expoDeviceHubCallIndex).toBeGreaterThan(2);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[2]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[expoDeviceHubCallIndex]
|
||||
);
|
||||
const [command, args] = spawnCalls[expoDeviceHubCallIndex];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
@@ -416,6 +445,32 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
@@ -785,7 +840,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledAsync, () => {
|
||||
describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
|
||||
function spawnResolved(): ReturnType<typeof spawn> {
|
||||
@@ -804,7 +859,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
it('does not install when ffmpeg is on PATH', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -817,7 +872,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
it('installs ffmpeg with Homebrew on darwin when it is missing', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -838,7 +893,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -860,6 +915,33 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shares an in-flight ffmpeg setup between callers', async () => {
|
||||
let finishInstall: (() => void) | undefined;
|
||||
const pendingInstall = new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>); // apt-get install
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
|
||||
const firstSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const secondSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
finishInstall?.();
|
||||
await Promise.all([firstSetup, secondSetup]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
@@ -867,7 +949,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
@@ -886,7 +968,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledAsync({
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
@@ -907,7 +989,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledAsync({
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
|
||||
@@ -370,6 +370,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileSimctl: 'Ran a simctl command',
|
||||
mobileSiriCommand: 'Sent a Siri command',
|
||||
mobileStartActivity: 'Started an activity',
|
||||
mobileStartAutomationSession: 'Started an automation session',
|
||||
mobileStartLogsBroadcast: 'Started log streaming',
|
||||
mobileStartMediaProjectionRecording: 'Started media projection recording',
|
||||
mobileStartNetworkMonitor: 'Started network monitoring',
|
||||
@@ -379,6 +380,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileStartService: 'Started a service',
|
||||
mobileStartSystemMonitor: 'Started system monitoring',
|
||||
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
|
||||
mobileStopAutomationSession: 'Stopped an automation session',
|
||||
mobileStopLogsBroadcast: 'Stopped log streaming',
|
||||
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
|
||||
mobileStopNetworkMonitor: 'Stopped network monitoring',
|
||||
@@ -400,6 +402,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileUnscheduleAction: 'Canceled a scheduled action',
|
||||
mobileUpdateSafariPreferences: 'Updated Safari preferences',
|
||||
mobileViewPortRect: 'Read the viewport size',
|
||||
mobileViewportElementRect: 'Read an element position and size in the viewport',
|
||||
mobileViewportScreenshot: 'Took a viewport screenshot',
|
||||
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
|
||||
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
|
||||
|
||||
@@ -234,12 +234,14 @@ export type XCUITestAppiumCommand =
|
||||
| 'mobileShake'
|
||||
| 'mobileSimctl'
|
||||
| 'mobileSiriCommand'
|
||||
| 'mobileStartAutomationSession'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartNetworkMonitor'
|
||||
| 'mobileStartPerfRecord'
|
||||
| 'mobileStartScreenRecording'
|
||||
| 'mobileStartSystemMonitor'
|
||||
| 'mobileStartXctestScreenRecording'
|
||||
| 'mobileStopAutomationSession'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopNetworkMonitor'
|
||||
| 'mobileStopPerfRecord'
|
||||
@@ -374,6 +376,7 @@ export type UiAutomator2AppiumCommand =
|
||||
| 'mobileUnlock'
|
||||
| 'mobileUnscheduleAction'
|
||||
| 'mobileViewPortRect'
|
||||
| 'mobileViewportElementRect'
|
||||
| 'mobileViewportScreenshot'
|
||||
| 'networkSpeed'
|
||||
| 'openNotifications'
|
||||
|
||||
@@ -26,7 +26,7 @@ const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '1280';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
|
||||
@@ -297,10 +297,9 @@ async function sleepUntilAbortedAsync(
|
||||
}
|
||||
}
|
||||
|
||||
// Argent encodes screen recordings by piping simulator frames into `ffmpeg`,
|
||||
// which it resolves from PATH. The tool-server inherits this step's env, so
|
||||
// spawning ffmpeg resolves against the same PATH argent will search: it rejects
|
||||
// with ENOENT when the binary is absent, and running it also proves it works.
|
||||
// Device-session tools resolve `ffmpeg` from PATH. Spawning it with the step's
|
||||
// environment rejects with ENOENT when the binary is absent, and running it also
|
||||
// proves that the installed binary works.
|
||||
async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
return (await asyncResult(spawn('ffmpeg', ['-version'], { env }))).ok;
|
||||
}
|
||||
@@ -333,14 +332,15 @@ async function installFfmpegWithAptAsync({
|
||||
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
|
||||
}
|
||||
|
||||
let ffmpegSetupPromise: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Install ffmpeg when the runtime does not already provide it, so argent's
|
||||
* `screen-recording-start` tool can encode a video. The worker images do not
|
||||
* ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found
|
||||
* on PATH" — on macOS (iOS simulators) and Linux (Android emulators) alike.
|
||||
* Install ffmpeg when the runtime does not already provide it. Device-session
|
||||
* tools use it for video encoding on macOS (iOS simulators) and Linux (Android
|
||||
* emulators) alike, but the worker images do not ship it yet.
|
||||
*
|
||||
* Best-effort by design: screen recording is one optional argent tool, so a
|
||||
* failure here is logged and the session continues without it.
|
||||
* Best-effort by design: a failure here is logged and the session continues
|
||||
* without FFmpeg-dependent features.
|
||||
*
|
||||
* The whole body is wrapped because the caller runs this in the background with
|
||||
* `void`. There is no unhandledRejection handler in the worker, so a rejection
|
||||
@@ -348,7 +348,7 @@ async function installFfmpegWithAptAsync({
|
||||
* `spawn` is not an async function and can throw synchronously, which
|
||||
* `asyncResult` cannot catch — it only wraps an already-created promise.
|
||||
*/
|
||||
export async function ensureFfmpegInstalledAsync({
|
||||
async function ensureFfmpegInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
@@ -367,7 +367,7 @@ export async function ensureFfmpegInstalledAsync({
|
||||
logger.info(
|
||||
`ffmpeg is not installed, installing it with ${
|
||||
isDarwin ? 'Homebrew' : 'apt'
|
||||
} for argent screen recording.`
|
||||
} for the device session.`
|
||||
);
|
||||
if (isDarwin) {
|
||||
await installFfmpegWithHomebrewAsync({ env, logger });
|
||||
@@ -377,16 +377,41 @@ export async function ensureFfmpegInstalledAsync({
|
||||
logger.info('Installed ffmpeg.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install ffmpeg for argent screen recording', error, {
|
||||
Sentry.capture('Could not install ffmpeg for the device session', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not install ffmpeg. Argent screen recording will not work in this session.'
|
||||
'Could not install ffmpeg. FFmpeg-dependent features may not work in this session.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (ffmpegSetupPromise) {
|
||||
await ffmpegSetupPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
ffmpegSetupPromise = setupPromise;
|
||||
try {
|
||||
await setupPromise;
|
||||
} finally {
|
||||
if (ffmpegSetupPromise === setupPromise) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
@@ -838,12 +863,14 @@ export async function startServeSimWithTunnelAsync(
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
@@ -851,6 +878,9 @@ export async function startExpoDeviceHubWithTunnelAsync(
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
@@ -880,7 +910,7 @@ export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, options);
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, options);
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,12 +99,18 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier,
|
||||
lcdWidth,
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
deviceIdentifier: AndroidDeviceName | null;
|
||||
lcdWidth: number | null;
|
||||
lcdHeight: number | null;
|
||||
lcdDensity: number | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -176,6 +182,13 @@ export namespace AndroidEmulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) {
|
||||
logger.info(
|
||||
`Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.`
|
||||
);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`;
|
||||
}
|
||||
|
||||
const shouldAdjustHeapSize =
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0';
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -95,6 +98,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -158,6 +164,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -209,6 +218,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: envForAttempt,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -290,6 +302,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
@@ -361,6 +376,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -48,6 +52,120 @@ describe('AndroidEmulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
].join('\n')
|
||||
);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
const write = jest.fn();
|
||||
const end = jest.fn();
|
||||
avdManagerPromise.child = { stdin: { write, end } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: 'system-images;android-35;default;x86_64',
|
||||
deviceIdentifier: 'medium_phone' as AndroidDeviceName,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'avdmanager',
|
||||
[
|
||||
'create',
|
||||
'avd',
|
||||
'--name',
|
||||
deviceName,
|
||||
'--package',
|
||||
'system-images;android-35;default;x86_64',
|
||||
'--force',
|
||||
'--device',
|
||||
'medium_phone',
|
||||
],
|
||||
{ env: expect.any(Object), stdio: 'pipe' }
|
||||
);
|
||||
expect(write).toHaveBeenCalledWith('no');
|
||||
expect(end).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting scaled screen resolution: hw.lcd.height to 1270 and hw.lcd.width to 566.'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting screen resolution to 720x1600 and density to 262 ppi.'
|
||||
);
|
||||
await expect(fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8')).resolves.toBe(
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
'hw.ramSize=2048',
|
||||
'',
|
||||
'hw.lcd.density=220',
|
||||
'',
|
||||
'hw.lcd.height=1270',
|
||||
'hw.lcd.width=566',
|
||||
'',
|
||||
'hw.lcd.height=1600',
|
||||
'hw.lcd.width=720',
|
||||
'hw.lcd.density=262',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply an LCD configuration when it is omitted', async () => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
avdManagerPromise.child = { stdin: { write: jest.fn(), end: jest.fn() } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
|
||||
expect(config).toContain(initialConfig);
|
||||
expect(config).not.toContain('hw.lcd.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
|
||||
import { withLogPhaseAsync } from '../logPhase';
|
||||
|
||||
describe(withLogPhaseAsync.name, () => {
|
||||
const phaseLogger = { info: jest.fn() };
|
||||
const logger = { child: jest.fn(() => phaseLogger) };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the callback result and ends the phase successfully', async () => {
|
||||
const result = await withLogPhaseAsync(logger as any, 'Phase name', async callbackLogger => {
|
||||
expect(callbackLogger).toBe(phaseLogger);
|
||||
return 'result';
|
||||
});
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(logger.child).toHaveBeenCalledWith({
|
||||
phase: BuildPhase.CUSTOM,
|
||||
buildStepId: expect.stringMatching(/^step-\d{3,}$/),
|
||||
buildStepDisplayName: 'Phase name',
|
||||
});
|
||||
expect(phaseLogger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ marker: LogMarker.START_PHASE },
|
||||
'Start phase: Phase name'
|
||||
);
|
||||
expect(phaseLogger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
|
||||
'End phase: Phase name'
|
||||
);
|
||||
});
|
||||
|
||||
it('ends the phase with failure and rethrows the error', async () => {
|
||||
const error = new Error('failed');
|
||||
|
||||
await expect(
|
||||
withLogPhaseAsync(logger as any, 'Phase name', async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(phaseLogger.info).toHaveBeenLastCalledWith(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
|
||||
'End phase: Phase name'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildStep } from '@expo/steps';
|
||||
|
||||
export async function withLogPhaseAsync<T>(
|
||||
logger: bunyan,
|
||||
name: string,
|
||||
fn: (logger: bunyan) => Promise<T>
|
||||
): Promise<T> {
|
||||
const phaseLogger = logger.child({
|
||||
phase: BuildPhase.CUSTOM,
|
||||
buildStepId: BuildStep.getNewId(),
|
||||
buildStepDisplayName: name,
|
||||
});
|
||||
phaseLogger.info({ marker: LogMarker.START_PHASE }, `Start phase: ${name}`);
|
||||
try {
|
||||
const result = await fn(phaseLogger);
|
||||
phaseLogger.info(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
|
||||
`End phase: ${name}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
phaseLogger.info(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
|
||||
`End phase: ${name}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "23.1.0",
|
||||
"version": "24.0.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "23.1.0"
|
||||
"@expo/steps": "24.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "23.0.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "23.0.0",
|
||||
"version": "24.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "23.1.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
+380
-205
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ const config: CodegenConfig = {
|
||||
'src/graphql/generated.ts': {
|
||||
plugins: ['typescript', 'typescript-operations'],
|
||||
config: {
|
||||
addUnderscoreToArgsType: true,
|
||||
dedupeOperationSuffix: true,
|
||||
},
|
||||
hooks: {
|
||||
|
||||
Generated
+8035
-1743
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "23.2.0",
|
||||
"version": "24.0.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,15 +44,15 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.22",
|
||||
"@expo/apple-utils": "2.2.0",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/eas-json": "23.1.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/eas-json": "24.0.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getProjectDashboardUrl, getProjectPageUrl } from '../url';
|
||||
import { getProjectDashboardUrl, getProjectPageUrl, getSubmissionUrl } from '../url';
|
||||
|
||||
describe(getProjectPageUrl, () => {
|
||||
it('builds the project dashboard URL when no page is provided', () => {
|
||||
@@ -33,3 +33,11 @@ describe(getProjectDashboardUrl, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSubmissionUrl, () => {
|
||||
it('builds an encoded submission URL', () => {
|
||||
expect(getSubmissionUrl('my org', 'my app', 'submission/id')).toBe(
|
||||
'https://expo.dev/accounts/my%20org/projects/my%20app/submissions/submission%2Fid'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppObserveErrorSeverity } from '../../../graphql/generated';
|
||||
import {
|
||||
fetchObserveErrorGroupsAsync,
|
||||
fetchObserveErrorOccurrencesAsync,
|
||||
} from '../../../observe/fetchErrors';
|
||||
import {
|
||||
buildObserveErrorGroupsJson,
|
||||
buildObserveErrorGroupsTable,
|
||||
buildObserveErrorOccurrencesJson,
|
||||
buildObserveErrorOccurrencesTable,
|
||||
} from '../../../observe/formatErrors';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveErrors from '../errors';
|
||||
|
||||
jest.mock('../../../observe/fetchErrors');
|
||||
jest.mock('../../../observe/formatErrors', () => ({
|
||||
buildObserveErrorGroupsTable: jest.fn().mockReturnValue('errors-table'),
|
||||
buildObserveErrorGroupsJson: jest.fn().mockReturnValue({ errors: [], isTruncated: false }),
|
||||
buildObserveErrorOccurrencesTable: jest.fn().mockReturnValue('occurrences-table'),
|
||||
buildObserveErrorOccurrencesJson: jest
|
||||
.fn()
|
||||
.mockReturnValue({ occurrences: [], pageInfo: { hasNextPage: false, endCursor: null } }),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchErrorGroupsAsync = jest.mocked(fetchObserveErrorGroupsAsync);
|
||||
const mockFetchErrorOccurrencesAsync = jest.mocked(fetchObserveErrorOccurrencesAsync);
|
||||
const mockBuildTable = jest.mocked(buildObserveErrorGroupsTable);
|
||||
const mockBuildJson = jest.mocked(buildObserveErrorGroupsJson);
|
||||
const mockBuildOccurrencesTable = jest.mocked(buildObserveErrorOccurrencesTable);
|
||||
const mockBuildOccurrencesJson = jest.mocked(buildObserveErrorOccurrencesJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveErrors, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchErrorGroupsAsync.mockResolvedValue({ groups: [], isTruncated: false });
|
||||
mockFetchErrorOccurrencesAsync.mockResolvedValue({
|
||||
occurrences: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, endCursor: null } as any,
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveErrors {
|
||||
const command = new ObserveErrors(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches error groups and renders a table by default', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
expect(mockFetchErrorGroupsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildTable).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON with --json', async () => {
|
||||
await createCommand(['--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ errors: [], isTruncated: false });
|
||||
});
|
||||
|
||||
it('maps the --severity flag to the AppObserveErrorSeverity enum', async () => {
|
||||
await createCommand(['--severity', 'fatal']).runAsync();
|
||||
const options = mockFetchErrorGroupsAsync.mock.calls[0][2];
|
||||
expect(options.severity).toBe(AppObserveErrorSeverity.Fatal);
|
||||
});
|
||||
|
||||
it('fetches occurrences (not groups) and renders their table when --fingerprint is set', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1']).runAsync();
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchErrorOccurrencesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({ fingerprint: 'fp-1' });
|
||||
expect(mockBuildOccurrencesTable).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits occurrences JSON with --fingerprint --json', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1', '--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildOccurrencesJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
occurrences: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards --limit to the occurrences fetch when --fingerprint is set', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1', '--limit', '20']).runAsync();
|
||||
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({
|
||||
fingerprint: 'fp-1',
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects --limit without --fingerprint', async () => {
|
||||
await expect(createCommand(['--limit', '20']).runAsync()).rejects.toThrow(
|
||||
/--after or --limit can only be used in combination with the --fingerprint flag/
|
||||
);
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchErrorOccurrencesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --after without --fingerprint', async () => {
|
||||
await expect(createCommand(['--after', 'cursor-1']).runAsync()).rejects.toThrow(
|
||||
/--after or --limit can only be used in combination with the --fingerprint flag/
|
||||
);
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when errors are not available on the plan', async () => {
|
||||
mockFetchErrorGroupsAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'Subscription to EAS is required for this feature.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}
|
||||
),
|
||||
],
|
||||
})
|
||||
);
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow(/Subscription to EAS is required/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import {
|
||||
buildObserveCustomEventDetail,
|
||||
buildObserveCustomEventJson,
|
||||
} from '../../../observe/formatCustomEvents';
|
||||
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../../observe/formatErrors';
|
||||
import { buildObserveEventDetail, buildObserveEventJson } from '../../../observe/formatEvents';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvent from '../event';
|
||||
|
||||
jest.mock('../../../observe/formatEvents', () => ({
|
||||
buildObserveEventDetail: jest.fn().mockReturnValue('metric-detail'),
|
||||
buildObserveEventJson: jest.fn().mockReturnValue({ id: 'metric-json' }),
|
||||
}));
|
||||
jest.mock('../../../observe/formatCustomEvents', () => ({
|
||||
buildObserveCustomEventDetail: jest.fn().mockReturnValue('log-detail'),
|
||||
buildObserveCustomEventJson: jest.fn().mockReturnValue({ id: 'log-json' }),
|
||||
}));
|
||||
jest.mock('../../../observe/formatErrors', () => ({
|
||||
buildObserveErrorDetail: jest.fn().mockReturnValue('error-detail'),
|
||||
buildObserveErrorJson: jest.fn().mockReturnValue({ id: 'error-json' }),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
logByIdAsync: jest.fn(),
|
||||
metricByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockLogByIdAsync = jest.mocked(ObserveQuery.logByIdAsync);
|
||||
const mockMetricByIdAsync = jest.mocked(ObserveQuery.metricByIdAsync);
|
||||
const mockBuildObserveEventDetail = jest.mocked(buildObserveEventDetail);
|
||||
const mockBuildObserveEventJson = jest.mocked(buildObserveEventJson);
|
||||
const mockBuildObserveCustomEventDetail = jest.mocked(buildObserveCustomEventDetail);
|
||||
const mockBuildObserveCustomEventJson = jest.mocked(buildObserveCustomEventJson);
|
||||
const mockBuildObserveErrorDetail = jest.mocked(buildObserveErrorDetail);
|
||||
const mockBuildObserveErrorJson = jest.mocked(buildObserveErrorJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
// A log event ID (user event or error) is a UUID; a metric event ID is
|
||||
// base64url-encoded JSON.
|
||||
const UUID_ID = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const BASE64_ID = 'eyJhIjoxfQ';
|
||||
const INVALID_ID = 'not a valid id';
|
||||
|
||||
const userEventLog = { id: UUID_ID, __typename: 'AppObserveUserEvent' } as any;
|
||||
const errorLog = { id: UUID_ID, __typename: 'AppObserveError' } as any;
|
||||
|
||||
describe(ObserveEvent, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLogByIdAsync.mockResolvedValue(null);
|
||||
mockMetricByIdAsync.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveEvent {
|
||||
const command = new ObserveEvent(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function planGateError(): CombinedError {
|
||||
const serverMessage =
|
||||
'Subscription to EAS is required for this feature. ' +
|
||||
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
|
||||
return new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('looks up a UUID id with the log query only', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(userEventLog);
|
||||
await createCommand([UUID_ID]).runAsync();
|
||||
expect(mockLogByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: UUID_ID,
|
||||
});
|
||||
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders an error detail when a UUID id resolves to an error', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(errorLog);
|
||||
await createCommand([UUID_ID]).runAsync();
|
||||
expect(mockBuildObserveErrorDetail).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveCustomEventDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks up a base64 id with the metric query only', async () => {
|
||||
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
|
||||
await createCommand([BASE64_ID]).runAsync();
|
||||
expect(mockMetricByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: BASE64_ID,
|
||||
});
|
||||
expect(mockLogByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveEventDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('errors immediately for an ID that is neither a UUID nor base64, without querying', async () => {
|
||||
await expect(createCommand([INVALID_ID]).runAsync()).rejects.toThrow(
|
||||
/is not a valid Observe event ID/
|
||||
);
|
||||
expect(mockLogByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits typed JSON for a metric event with --json', async () => {
|
||||
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
|
||||
await createCommand([BASE64_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveEventJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'metric',
|
||||
event: { id: 'metric-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits typed JSON for a log event with --json', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(userEventLog);
|
||||
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockBuildObserveCustomEventJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'log',
|
||||
event: { id: 'log-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits typed JSON for an error event with --json', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(errorLog);
|
||||
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockBuildObserveErrorJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
event: { id: 'error-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws not-found when a well-formed ID resolves to nothing', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(null);
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
new RegExp(`No Observe event found with ID "${UUID_ID}"`)
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when the lookup is not available on the plan', async () => {
|
||||
mockLogByIdAsync.mockRejectedValueOnce(planGateError());
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
/Subscription to EAS is required/
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps an unexpected server error with an ID-specific message and preserves the request ID', async () => {
|
||||
const serverError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError('unexpected server error', null, null, null, null, null, {
|
||||
requestId: 'req-123',
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockLogByIdAsync.mockRejectedValue(serverError);
|
||||
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
new RegExp(
|
||||
`Could not retrieve Observe event with ID "${UUID_ID}"[\\s\\S]*unexpected server error \\(Request ID: req-123\\)`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,25 @@ describe(ObserveEvents, () => {
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --environment to the custom events filter', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [{ id: 'evt-1' } as any],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
const command = createCommand(['my_event', '--environment', 'production']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.environment).toBe('production');
|
||||
});
|
||||
|
||||
it('passes --environment to customEventNamesAsync when listing event names', async () => {
|
||||
const command = createCommand(['--environment', 'production']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync.mock.calls[0][1].environment).toBe('production');
|
||||
});
|
||||
|
||||
it('routes to customEventNamesAsync when no positional arg is provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
@@ -164,7 +183,8 @@ describe(ObserveEvents, () => {
|
||||
appId: projectId,
|
||||
startTime: '2025-06-08T12:00:00.000Z',
|
||||
endTime: '2025-06-15T12:00:00.000Z',
|
||||
platform: AppObservePlatform.Ios,
|
||||
platforms: [AppObservePlatform.Ios],
|
||||
environment: undefined,
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
@@ -231,7 +251,20 @@ describe(ObserveEvents, () => {
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Ios);
|
||||
expect(options.platforms).toEqual([AppObservePlatform.Ios]);
|
||||
});
|
||||
|
||||
it('passes --platform apple as every Apple platform', async () => {
|
||||
const command = createCommand(['my_event', '--platform', 'apple']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.platforms).toEqual([
|
||||
AppObservePlatform.Ios,
|
||||
AppObservePlatform.Ipados,
|
||||
AppObservePlatform.Tvos,
|
||||
AppObservePlatform.Macos,
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes --app-version', async () => {
|
||||
@@ -242,6 +275,14 @@ describe(ObserveEvents, () => {
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
});
|
||||
|
||||
it('passes --build-number', async () => {
|
||||
const command = createCommand(['my_event', '--build-number', '42']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.buildNumber).toBe('42');
|
||||
});
|
||||
|
||||
it('passes --update-id', async () => {
|
||||
const command = createCommand(['my_event', '--update-id', 'update-xyz']);
|
||||
await command.runAsync();
|
||||
@@ -258,12 +299,12 @@ describe(ObserveEvents, () => {
|
||||
expect(options.sessionId).toBe('session-xyz');
|
||||
});
|
||||
|
||||
it('does not pass platform, appVersion, updateId, or sessionId when flags are not provided', async () => {
|
||||
it('does not pass platforms, appVersion, updateId, or sessionId when flags are not provided', async () => {
|
||||
const command = createCommand(['my_event']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBeUndefined();
|
||||
expect(options.platforms).toBeUndefined();
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
expect(options.sessionId).toBeUndefined();
|
||||
|
||||
@@ -3,11 +3,12 @@ import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { AppObservePlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import { ObservePlatformTarget } from '../../../observe/platforms';
|
||||
import ObserveMetricsSummary from '../metrics-summary';
|
||||
|
||||
jest.mock('../../../observe/fetchMetrics', () => {
|
||||
@@ -31,6 +32,10 @@ const mockBuildObserveMetricsSummaryJson = jest.mocked(buildObserveMetricsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function target(platform: AppObservePlatform): ObservePlatformTarget {
|
||||
return { key: platform, platforms: [platform] };
|
||||
}
|
||||
|
||||
describe(ObserveMetricsSummary, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
@@ -83,7 +88,7 @@ describe(ObserveMetricsSummary, () => {
|
||||
|
||||
expect(mockFetchObserveMetricsSummaryAsync).toHaveBeenCalledTimes(1);
|
||||
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
expect(platforms).toEqual([target(AppObservePlatform.Android), target(AppObservePlatform.Ios)]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -93,7 +98,7 @@ describe(ObserveMetricsSummary, () => {
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Android]);
|
||||
expect(platforms).toEqual([target(AppObservePlatform.Android)]);
|
||||
});
|
||||
|
||||
it('queries only iOS when --platform ios is passed', async () => {
|
||||
@@ -101,7 +106,40 @@ describe(ObserveMetricsSummary, () => {
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Ios]);
|
||||
expect(platforms).toEqual([target(AppObservePlatform.Ios)]);
|
||||
});
|
||||
|
||||
it('queries one combined target covering every Apple platform when --platform apple is passed', async () => {
|
||||
const command = createCommand(['--platform', 'apple']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([
|
||||
{
|
||||
key: 'APPLE',
|
||||
platforms: [
|
||||
AppObservePlatform.Ios,
|
||||
AppObservePlatform.Ipados,
|
||||
AppObservePlatform.Tvos,
|
||||
AppObservePlatform.Macos,
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('queries only macOS when --platform macos is passed', async () => {
|
||||
const command = createCommand(['--platform', 'macos']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsSummaryAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([target(AppObservePlatform.Macos)]);
|
||||
});
|
||||
|
||||
it('passes --environment through to fetchObserveMetricsAsync', async () => {
|
||||
const command = createCommand(['--environment', 'production']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveMetricsSummaryAsync.mock.calls[0][6]).toBe('production');
|
||||
});
|
||||
|
||||
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { GraphQLError } from 'graphql';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObserveMetricsListOrderByField,
|
||||
AppObserveOrderDirection,
|
||||
AppObservePlatform,
|
||||
} from '../../../graphql/generated';
|
||||
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
|
||||
@@ -135,6 +135,14 @@ describe(ObserveMetrics, () => {
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes --environment to the events filter', async () => {
|
||||
const command = createCommand(['tti', '--environment', 'production']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.environment).toBe('production');
|
||||
});
|
||||
|
||||
it('defaults endTime to now when only --start is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
@@ -185,20 +193,20 @@ describe(ObserveMetrics, () => {
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
|
||||
it('passes --platform ios to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Ios);
|
||||
expect(options.platforms).toEqual([AppObservePlatform.Ios]);
|
||||
});
|
||||
|
||||
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
|
||||
it('passes --platform android to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Android);
|
||||
expect(options.platforms).toEqual([AppObservePlatform.Android]);
|
||||
});
|
||||
|
||||
it('passes --app-version to fetchObserveEventsAsync', async () => {
|
||||
@@ -209,6 +217,14 @@ describe(ObserveMetrics, () => {
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
});
|
||||
|
||||
it('passes --build-number to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--build-number', '42']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.buildNumber).toBe('42');
|
||||
});
|
||||
|
||||
it('passes --update-id to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--update-id', 'update-xyz']);
|
||||
await command.runAsync();
|
||||
@@ -217,12 +233,12 @@ describe(ObserveMetrics, () => {
|
||||
expect(options.updateId).toBe('update-xyz');
|
||||
});
|
||||
|
||||
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
|
||||
it('does not pass platforms, appVersion, or updateId when flags are not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBeUndefined();
|
||||
expect(options.platforms).toBeUndefined();
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
});
|
||||
@@ -231,8 +247,8 @@ describe(ObserveMetrics, () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
@@ -274,8 +290,8 @@ describe(ObserveMetrics, () => {
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.orderBy).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,29 +315,29 @@ describe(ObserveMetrics, () => {
|
||||
describe(resolveOrderBy, () => {
|
||||
it('resolves lowercase "slowest" to MetricValue DESC', () => {
|
||||
expect(resolveOrderBy('slowest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "fastest" to MetricValue ASC', () => {
|
||||
expect(resolveOrderBy('fastest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "newest" to Timestamp DESC', () => {
|
||||
expect(resolveOrderBy('newest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "oldest" to Timestamp ASC', () => {
|
||||
expect(resolveOrderBy('oldest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
field: AppObserveMetricsListOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { AppObservePlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../../../observe/fetchNavigationRoutes';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildObserveNavigationRoutesTable,
|
||||
} from '../../../observe/formatNavigationRoutes';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import { ObservePlatformTarget } from '../../../observe/platforms';
|
||||
import ObserveRoutes from '../routes';
|
||||
|
||||
jest.mock('../../../observe/fetchNavigationRoutes');
|
||||
@@ -31,6 +32,10 @@ const mockBuildObserveNavigationRoutesJson = jest.mocked(buildObserveNavigationR
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function target(platform: AppObservePlatform): ObservePlatformTarget {
|
||||
return { key: platform, platforms: [platform] };
|
||||
}
|
||||
|
||||
describe(ObserveRoutes, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
@@ -78,7 +83,10 @@ describe(ObserveRoutes, () => {
|
||||
|
||||
expect(mockFetchObserveNavigationRoutesAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
expect(options.targets).toEqual([
|
||||
target(AppObservePlatform.Android),
|
||||
target(AppObservePlatform.Ios),
|
||||
]);
|
||||
expect(options.limit).toBe(50);
|
||||
|
||||
const tableCall = mockBuildObserveNavigationRoutesTable.mock.calls[0];
|
||||
@@ -95,7 +103,7 @@ describe(ObserveRoutes, () => {
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.platforms).toEqual([AppPlatform.Ios]);
|
||||
expect(options.targets).toEqual([target(AppObservePlatform.Ios)]);
|
||||
});
|
||||
|
||||
it('resolves --metric short aliases to navigation metric full names and deduplicates', async () => {
|
||||
@@ -174,6 +182,14 @@ describe(ObserveRoutes, () => {
|
||||
expect(options.buildNumber).toBe('42');
|
||||
});
|
||||
|
||||
it('passes --environment through to the fetcher', async () => {
|
||||
const command = createCommand(['--environment', 'production']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.environment).toBe('production');
|
||||
});
|
||||
|
||||
it('passes --route-name flags through as routeNames array', async () => {
|
||||
const command = createCommand(['--route-name', '/home', '--route-name', '/profile']);
|
||||
await command.runAsync();
|
||||
|
||||
@@ -56,10 +56,10 @@ const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeMetricEvent(overrides: any = {}): any {
|
||||
return {
|
||||
__typename: 'AppObserveEvent',
|
||||
__typename: 'AppObserveMetric',
|
||||
id: 'evt-m-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 1.23,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
@@ -78,9 +78,9 @@ function makeMetricEvent(overrides: any = {}): any {
|
||||
|
||||
function makeCustomEvent(overrides: any = {}): any {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent',
|
||||
__typename: 'AppObserveUserEvent',
|
||||
id: 'evt-c-1',
|
||||
eventName: 'login_pressed',
|
||||
name: 'login_pressed',
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-log-1',
|
||||
severityNumber: null,
|
||||
@@ -239,6 +239,11 @@ describe(ObserveSession, () => {
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
});
|
||||
|
||||
it('rejects --environment when a session ID is also provided', async () => {
|
||||
const command = createCommand(['session-abc', '--environment', 'production']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
});
|
||||
|
||||
it('picker mode with --event-name tti fetches metric events and threads selected sessionId', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-1' }),
|
||||
@@ -284,9 +289,7 @@ describe(ObserveSession, () => {
|
||||
|
||||
it('picker mode without --event-name or --sort prompts for event name, then sort, then event', async () => {
|
||||
mockCustomEventNamesAsync.mockResolvedValue({
|
||||
names: [
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'login_pressed', count: 12 } as any,
|
||||
],
|
||||
names: [{ __typename: 'AppObserveUserEventName', name: 'login_pressed', count: 12 } as any],
|
||||
isTruncated: false,
|
||||
});
|
||||
// 1) event-name picker → metric choice
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { AppObservePlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveVersionsAsync } from '../../../observe/fetchVersions';
|
||||
import {
|
||||
buildObserveVersionsJson,
|
||||
buildObserveVersionsTable,
|
||||
} from '../../../observe/formatVersions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import { ObservePlatformTarget } from '../../../observe/platforms';
|
||||
import ObserveVersions from '../versions';
|
||||
|
||||
jest.mock('../../../observe/fetchVersions');
|
||||
@@ -23,6 +24,10 @@ const mockBuildObserveVersionsJson = jest.mocked(buildObserveVersionsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function target(platform: AppObservePlatform): ObservePlatformTarget {
|
||||
return { key: platform, platforms: [platform] };
|
||||
}
|
||||
|
||||
describe(ObserveVersions, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
@@ -52,7 +57,7 @@ describe(ObserveVersions, () => {
|
||||
|
||||
expect(mockFetchObserveVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
expect(platforms).toEqual([target(AppObservePlatform.Android), target(AppObservePlatform.Ios)]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -62,7 +67,7 @@ describe(ObserveVersions, () => {
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Android]);
|
||||
expect(platforms).toEqual([target(AppObservePlatform.Android)]);
|
||||
});
|
||||
|
||||
it('queries only iOS when --platform ios is passed', async () => {
|
||||
@@ -70,7 +75,14 @@ describe(ObserveVersions, () => {
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Ios]);
|
||||
expect(platforms).toEqual([target(AppObservePlatform.Ios)]);
|
||||
});
|
||||
|
||||
it('passes --environment through to fetchObserveVersionsAsync', async () => {
|
||||
const command = createCommand(['--environment', 'production']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveVersionsAsync.mock.calls[0][5]).toBe('production');
|
||||
});
|
||||
|
||||
it('uses default time range (60 days back) when no --start/--end flags', async () => {
|
||||
@@ -164,7 +176,7 @@ describe(ObserveVersions, () => {
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
|
||||
mockFetchObserveVersionsAsync.mockResolvedValue([
|
||||
{
|
||||
platform: AppPlatform.Ios,
|
||||
platform: AppObservePlatform.Ios,
|
||||
appVersions: [
|
||||
{
|
||||
appVersion: '1.0.0',
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { AppObserveErrorSeverity } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
fetchObserveErrorGroupsAsync,
|
||||
fetchObserveErrorOccurrencesAsync,
|
||||
} from '../../observe/fetchErrors';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
buildObserveErrorGroupsJson,
|
||||
buildObserveErrorGroupsTable,
|
||||
buildObserveErrorOccurrencesJson,
|
||||
buildObserveErrorOccurrencesTable,
|
||||
} from '../../observe/formatErrors';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const SEVERITY_BY_FLAG: Record<string, AppObserveErrorSeverity> = {
|
||||
fatal: AppObserveErrorSeverity.Fatal,
|
||||
error: AppObserveErrorSeverity.Error,
|
||||
};
|
||||
|
||||
const DEFAULT_OCCURRENCES_LIMIT = 10;
|
||||
|
||||
export default class ObserveErrors extends EasCommand {
|
||||
static override description =
|
||||
'display error and exception issue groups (grouped by fingerprint) for the app';
|
||||
|
||||
static override flags = {
|
||||
...ObservePlatformFlag,
|
||||
fingerprint: Flags.string({
|
||||
description:
|
||||
'Show individual occurrences (with stack traces) for this error group fingerprint instead of the grouped summary',
|
||||
required: false,
|
||||
}),
|
||||
severity: Flags.option({
|
||||
description: 'Filter by severity (ignored when --fingerprint is set)',
|
||||
options: Object.keys(SEVERITY_BY_FLAG),
|
||||
required: false,
|
||||
})(),
|
||||
...ObserveAfterFlag,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_OCCURRENCES_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveErrors);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveErrors,
|
||||
loggedInOnlyContextDefinition: ObserveErrors.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (!flags.fingerprint && (flags.after || flags.limit)) {
|
||||
throw new Error(
|
||||
'--after or --limit can only be used in combination with the --fingerprint flag.'
|
||||
);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
if (flags.fingerprint) {
|
||||
const { occurrences, pageInfo } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveErrorOccurrencesAsync(graphqlClient, projectId, {
|
||||
fingerprint: flags.fingerprint as string,
|
||||
startTime,
|
||||
endTime,
|
||||
platforms: observePlatformsFromFlag(flags.platform),
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
limit: flags.limit ?? DEFAULT_OCCURRENCES_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveErrorOccurrencesJson(occurrences, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveErrorOccurrencesTable(occurrences, pageInfo, {
|
||||
fingerprint: flags.fingerprint,
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { groups, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveErrorGroupsAsync(graphqlClient, projectId, {
|
||||
startTime,
|
||||
endTime,
|
||||
platforms: observePlatformsFromFlag(flags.platform),
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
...(flags.severity && { severity: SEVERITY_BY_FLAG[flags.severity] }),
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveErrorGroupsJson(groups, isTruncated));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveErrorGroupsTable(groups, { daysBack, startTime, endTime }));
|
||||
if (isTruncated) {
|
||||
Log.warn('Result is truncated; not all error groups are shown.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import { validate as isUuid } from 'uuid';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { GraphqlError } from '../../graphql/client';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
buildObserveCustomEventDetail,
|
||||
buildObserveCustomEventJson,
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../observe/formatErrors';
|
||||
import { buildObserveEventDetail, buildObserveEventJson } from '../../observe/formatEvents';
|
||||
import { ObserveProjectIdFlag } from '../../observe/flags';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ObserveEvent extends EasCommand {
|
||||
static override description =
|
||||
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
description: 'ID of the event to display',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvent);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveEvent,
|
||||
loggedInOnlyContextDefinition: ObserveEvent.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const id = args.id;
|
||||
|
||||
// A log event ID (user event or error) is a UUID; a metric event ID is
|
||||
// base64url-encoded JSON. Route to the matching query so we never issue the
|
||||
// query that is guaranteed to fail for this ID, and reject anything that is
|
||||
// neither up front.
|
||||
if (isUuid(id)) {
|
||||
const log = await fetchObserveEventAsync(id, () =>
|
||||
ObserveQuery.logByIdAsync(graphqlClient, { appId: projectId, id })
|
||||
);
|
||||
if (!log) {
|
||||
throw eventNotFoundError(id);
|
||||
}
|
||||
if (log.__typename === 'AppObserveError') {
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ type: 'error', event: buildObserveErrorJson(log) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveErrorDetail(log));
|
||||
}
|
||||
} else if (json) {
|
||||
printJsonOnlyOutput({ type: 'log', event: buildObserveCustomEventJson(log) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveCustomEventDetail(log));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsesAsBase64(id)) {
|
||||
const event = await fetchObserveEventAsync(id, () =>
|
||||
ObserveQuery.metricByIdAsync(graphqlClient, { appId: projectId, id })
|
||||
);
|
||||
if (!event) {
|
||||
throw eventNotFoundError(id);
|
||||
}
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ type: 'metric', event: buildObserveEventJson(event) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveEventDetail(event));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new EasCommandError(
|
||||
`"${id}" is not a valid Observe event ID. IDs come from \`eas observe:events\` or \`eas observe:session\`.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A metric event ID is canonical base64url (of JSON). Round-tripping rejects
|
||||
* strings that only coincidentally contain base64 characters, so a value that
|
||||
* is neither a UUID nor this is treated as an invalid ID.
|
||||
*/
|
||||
function parsesAsBase64(id: string): boolean {
|
||||
if (id.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return Buffer.from(id, 'base64url').toString('base64url') === id;
|
||||
}
|
||||
|
||||
function eventNotFoundError(id: string): EasCommandError {
|
||||
return new EasCommandError(
|
||||
`No Observe event found with ID "${id}". IDs come from \`eas observe:events\` or \`eas observe:session\`, and events age out of retention.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an Observe event lookup, translating plan-gate rejections to their
|
||||
* upgrade message and any other server error into an actionable message that
|
||||
* preserves the underlying error and request ID for support.
|
||||
*/
|
||||
async function fetchObserveEventAsync<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await withObservePlanGateHandlingAsync(fn);
|
||||
} catch (error) {
|
||||
if (error instanceof EasCommandError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Could not retrieve Observe event with ID "${id}". ` +
|
||||
'The ID may be invalid or the event may not exist (events also age out of retention). ' +
|
||||
'Verify it was copied in full from `eas observe:events` or `eas observe:session`.' +
|
||||
`\n\n${describeObserveServerError(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable description from a GraphQL/server error, including
|
||||
* the request ID(s) so a support request can reference them. Falls back to the
|
||||
* error's own message for non-GraphQL errors.
|
||||
*/
|
||||
function describeObserveServerError(error: unknown): string {
|
||||
if (error instanceof GraphqlError && error.graphQLErrors.length > 0) {
|
||||
return error.graphQLErrors
|
||||
.map(graphQLError => {
|
||||
const message = graphQLError.message.replace('[GraphQL] ', '');
|
||||
const requestId = graphQLError.extensions?.requestId;
|
||||
return requestId ? `${message} (Request ID: ${String(requestId)})` : message;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
buildObserveCustomEventsTable,
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { appObservePlatformFromFlag } from '../../observe/platforms';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -53,7 +55,9 @@ export default class ObserveEvents extends EasCommand {
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
'session-id': Flags.string({
|
||||
description:
|
||||
'Filter by session ID. When no event name is given, lists the events in the session instead of the event-name summary.',
|
||||
@@ -100,7 +104,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platform = appObservePlatformFromFlag(flags.platform);
|
||||
const platforms = observePlatformsFromFlag(flags.platform);
|
||||
|
||||
// A session ID narrows to a single session, so show that session's events
|
||||
// (like --all-events) instead of the account-wide name+count summary, which
|
||||
@@ -111,7 +115,8 @@ export default class ObserveEvents extends EasCommand {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
platforms,
|
||||
environment: flags.environment,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -138,10 +143,12 @@ export default class ObserveEvents extends EasCommand {
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
platforms,
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
sessionId: flags['session-id'],
|
||||
environment: flags.environment,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -150,7 +157,8 @@ export default class ObserveEvents extends EasCommand {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
platforms,
|
||||
environment: flags.environment,
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import Log from '../../log';
|
||||
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
} from '../../observe/formatMetrics';
|
||||
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -62,6 +63,7 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
options: DEFAULT_STATS_JSON,
|
||||
})(),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -97,7 +99,7 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
const targets = observePlatformTargetsFromFlag(flags.platform);
|
||||
|
||||
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
|
||||
await withObservePlanGateHandlingAsync(() =>
|
||||
@@ -105,9 +107,10 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricNames,
|
||||
platforms,
|
||||
targets,
|
||||
startTime,
|
||||
endTime
|
||||
endTime,
|
||||
flags.environment
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { observePlatformTargetsFromFlag, observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { selectAsync } from '../../prompts';
|
||||
@@ -59,7 +61,9 @@ export default class ObserveMetrics extends EasCommand {
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -108,8 +112,8 @@ export default class ObserveMetrics extends EasCommand {
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platform = appObservePlatformFromFlag(flags.platform);
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
const platforms = observePlatformsFromFlag(flags.platform);
|
||||
const targets = observePlatformTargetsFromFlag(flags.platform);
|
||||
|
||||
const [{ events, pageInfo }, totalEventCount] = await withObservePlanGateHandlingAsync(() =>
|
||||
Promise.all([
|
||||
@@ -120,17 +124,20 @@ export default class ObserveMetrics extends EasCommand {
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
platforms,
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
}),
|
||||
fetchTotalEventCountAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricName,
|
||||
platforms,
|
||||
targets,
|
||||
startTime,
|
||||
endTime
|
||||
endTime,
|
||||
flags.environment
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigation
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
} from '../../observe/formatNavigationRoutes';
|
||||
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -61,15 +63,14 @@ export default class ObserveRoutes extends EasCommand {
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
'build-number': Flags.string({
|
||||
description: 'Filter by app build number',
|
||||
}),
|
||||
'route-name': Flags.string({
|
||||
description:
|
||||
'Filter by route name (can be specified multiple times to include several routes)',
|
||||
multiple: true,
|
||||
}),
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -112,19 +113,20 @@ export default class ObserveRoutes extends EasCommand {
|
||||
: undefined;
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
const targets = observePlatformTargetsFromFlag(flags.platform);
|
||||
|
||||
const { routes, pageInfoByPlatform } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveNavigationRoutesAsync(graphqlClient, projectId, {
|
||||
startTime,
|
||||
endTime,
|
||||
platforms,
|
||||
targets,
|
||||
limit: flags.limit ?? DEFAULT_ROUTES_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
buildNumber: flags['build-number'],
|
||||
routeNames,
|
||||
environment: flags.environment,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
verifyObserveSessionAccessAsync,
|
||||
} from '../../observe/fetchSessions';
|
||||
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
@@ -66,6 +70,7 @@ export default class ObserveSession extends EasCommand {
|
||||
'Metric or log event name to pick candidate sessions by (e.g. tti, cold_launch, login_pressed). If omitted in interactive mode, you will be prompted.',
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -102,10 +107,11 @@ export default class ObserveSession extends EasCommand {
|
||||
flags.sort !== undefined ||
|
||||
flags.days !== undefined ||
|
||||
flags.start !== undefined ||
|
||||
flags.end !== undefined;
|
||||
flags.end !== undefined ||
|
||||
flags.environment !== undefined;
|
||||
if (pickerFlagsProvided) {
|
||||
throw new EasCommandError(
|
||||
'The picker flags (--event-name, --sort, --days, --start, --end) describe how to find a session and cannot be combined with a session ID argument.'
|
||||
'The picker flags (--event-name, --sort, --days, --start, --end, --environment) describe how to find a session and cannot be combined with a session ID argument.'
|
||||
);
|
||||
}
|
||||
sessionId = args.sessionId;
|
||||
@@ -127,6 +133,7 @@ export default class ObserveSession extends EasCommand {
|
||||
eventNameFlag: flags['event-name'],
|
||||
sort: flags.sort,
|
||||
timeRangeFlags: { days: flags.days, start: flags.start, end: flags.end },
|
||||
environment: flags.environment,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,18 +179,20 @@ async function pickSessionIdInteractivelyAsync({
|
||||
eventNameFlag,
|
||||
sort,
|
||||
timeRangeFlags,
|
||||
environment,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
eventNameFlag: string | undefined;
|
||||
sort: string | undefined;
|
||||
timeRangeFlags: { days: number | undefined; start: string | undefined; end: string | undefined };
|
||||
environment: string | undefined;
|
||||
}): Promise<string> {
|
||||
const { startTime, endTime } = resolveTimeRange(timeRangeFlags);
|
||||
|
||||
const eventNameChoice: EventNameChoice = eventNameFlag
|
||||
? { name: eventNameFlag, isMetric: isKnownMetricName(eventNameFlag) }
|
||||
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime });
|
||||
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime, environment });
|
||||
|
||||
let sortValue: string;
|
||||
if (sort) {
|
||||
@@ -208,6 +217,7 @@ async function pickSessionIdInteractivelyAsync({
|
||||
startTime,
|
||||
endTime,
|
||||
limit: PICKER_CANDIDATE_LIMIT,
|
||||
environment,
|
||||
})
|
||||
).map(event => ({ sessionId: event.sessionId, title: formatMetricCandidateTitle(event) }))
|
||||
: (
|
||||
@@ -217,6 +227,7 @@ async function pickSessionIdInteractivelyAsync({
|
||||
startTime,
|
||||
endTime,
|
||||
limit: PICKER_CANDIDATE_LIMIT,
|
||||
environment,
|
||||
})
|
||||
).map(event => ({ sessionId: event.sessionId, title: formatLogCandidateTitle(event) }));
|
||||
|
||||
@@ -254,16 +265,19 @@ async function promptForEventNameAsync({
|
||||
projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
environment,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
environment: string | undefined;
|
||||
}): Promise<EventNameChoice> {
|
||||
const { names: customEventNames } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
environment,
|
||||
});
|
||||
|
||||
const metricChoices: ExpoChoice<EventNameChoice>[] = Object.entries(METRIC_SHORT_NAMES).map(
|
||||
@@ -273,12 +287,10 @@ async function promptForEventNameAsync({
|
||||
})
|
||||
);
|
||||
|
||||
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(
|
||||
({ eventName, count }) => ({
|
||||
title: `${eventName} (${count} log event${count === 1 ? '' : 's'})`,
|
||||
value: { name: eventName, isMetric: false },
|
||||
})
|
||||
);
|
||||
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(({ name, count }) => ({
|
||||
title: `${name} (${count} log event${count === 1 ? '' : 's'})`,
|
||||
value: { name, isMetric: false },
|
||||
}));
|
||||
|
||||
const choices = [...metricChoices, ...logChoices];
|
||||
if (choices.length === 0) {
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
import Log from '../../log';
|
||||
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
|
||||
import { appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -22,6 +23,7 @@ export default class ObserveVersions extends EasCommand {
|
||||
static override flags = {
|
||||
...ObservePlatformFlag,
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -53,14 +55,15 @@ export default class ObserveVersions extends EasCommand {
|
||||
|
||||
const { startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
const targets = observePlatformTargetsFromFlag(flags.platform);
|
||||
|
||||
const results = await fetchObserveVersionsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platforms,
|
||||
targets,
|
||||
startTime,
|
||||
endTime
|
||||
endTime,
|
||||
flags.environment
|
||||
);
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppFragment,
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
DistributionType,
|
||||
SubmissionStatus,
|
||||
UpdateFragment,
|
||||
WorkflowRunFragment,
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../../graphql/generated';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
|
||||
import { ProjectStatusSubmission, SubmissionQuery } from '../../../graphql/queries/SubmissionQuery';
|
||||
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ProjectStatus from '../status';
|
||||
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/queries/BuildQuery');
|
||||
jest.mock('../../../graphql/queries/SubmissionQuery');
|
||||
jest.mock('../../../graphql/queries/UpdateQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockAppByIdAsync = jest.mocked(AppQuery.byIdAsync);
|
||||
const mockWorkflowRunsAsync = jest.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync);
|
||||
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
|
||||
const mockProjectStatusSubmissionsAsync = jest.mocked(SubmissionQuery.forProjectStatusAsync);
|
||||
const mockViewUpdateGroupsAsync = jest.mocked(UpdateQuery.viewUpdateGroupsOnAppAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
function makeApp(): AppFragment {
|
||||
return {
|
||||
id: projectId,
|
||||
name: 'my-app',
|
||||
fullName: '@jester/my-app',
|
||||
slug: 'my-app',
|
||||
ownerAccount: { id: 'account-1', name: 'jester' },
|
||||
} as unknown as AppFragment;
|
||||
}
|
||||
|
||||
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
|
||||
return {
|
||||
id: 'build-1',
|
||||
platform: AppPlatform.Ios,
|
||||
status: BuildStatus.Finished,
|
||||
distribution: DistributionType.Store,
|
||||
buildProfile: 'production',
|
||||
appVersion: '1.0.0',
|
||||
appBuildVersion: '42',
|
||||
runtime: { id: 'runtime-1', version: '1.0.0' },
|
||||
updateChannel: { id: 'channel-1', name: 'production' },
|
||||
gitCommitHash: 'abcdef1234567890',
|
||||
gitCommitMessage: 'Ship it\nlonger body',
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
completedAt: '2026-07-01T00:10:00.000Z',
|
||||
initiatingActor: { id: 'actor-1', displayName: 'jester' },
|
||||
app: {
|
||||
id: projectId,
|
||||
name: 'my-app',
|
||||
slug: 'my-app',
|
||||
ownerAccount: { id: 'account-1', name: 'jester' },
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as BuildFragment;
|
||||
}
|
||||
|
||||
function makeWorkflowRun(overrides: Partial<WorkflowRunFragment> = {}): WorkflowRunFragment {
|
||||
return {
|
||||
id: 'run-1',
|
||||
status: WorkflowRunStatus.Success,
|
||||
gitCommitMessage: 'Automate all the things',
|
||||
gitCommitHash: 'fedcba0987654321',
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2026-07-02T00:00:00.000Z',
|
||||
updatedAt: '2026-07-02T00:05:00.000Z',
|
||||
errors: [],
|
||||
workflow: { id: 'wf-1', name: 'Publish', fileName: 'publish.yml' },
|
||||
...overrides,
|
||||
} as unknown as WorkflowRunFragment;
|
||||
}
|
||||
|
||||
function makeSubmission(overrides: Partial<ProjectStatusSubmission> = {}): ProjectStatusSubmission {
|
||||
return {
|
||||
id: 'submission-1',
|
||||
platform: AppPlatform.Android,
|
||||
status: SubmissionStatus.Finished,
|
||||
createdAt: '2026-07-02T12:00:00.000Z',
|
||||
completedAt: '2026-07-02T12:05:00.000Z',
|
||||
initiatingActor: { id: 'actor-1', displayName: 'jester' },
|
||||
submittedBuild: { id: 'build-1' },
|
||||
androidConfig: {
|
||||
applicationIdentifier: 'com.example.app',
|
||||
track: 'production',
|
||||
releaseStatus: null,
|
||||
rollout: 50,
|
||||
},
|
||||
error: null,
|
||||
...overrides,
|
||||
} as unknown as ProjectStatusSubmission;
|
||||
}
|
||||
|
||||
function makeUpdateGroup(): UpdateFragment[] {
|
||||
return [
|
||||
{
|
||||
id: 'update-ios',
|
||||
group: 'group-1',
|
||||
message: 'Fix crash on launch',
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
runtime: { id: 'runtime-1', version: '1.0.0' },
|
||||
platform: 'ios',
|
||||
isRollBackToEmbedded: false,
|
||||
gitCommitHash: '1234567890abcdef',
|
||||
rolloutPercentage: 25,
|
||||
environment: 'production',
|
||||
actor: { __typename: 'User', id: 'actor-1', username: 'jester' },
|
||||
branch: { id: 'branch-1', name: 'production' },
|
||||
},
|
||||
{
|
||||
id: 'update-android',
|
||||
group: 'group-1',
|
||||
message: 'Fix crash on launch',
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
runtime: { id: 'runtime-1', version: '1.0.0' },
|
||||
platform: 'android',
|
||||
isRollBackToEmbedded: false,
|
||||
gitCommitHash: '1234567890abcdef',
|
||||
rolloutPercentage: 25,
|
||||
environment: 'production',
|
||||
actor: { __typename: 'User', id: 'actor-1', username: 'jester' },
|
||||
branch: { id: 'branch-1', name: 'production' },
|
||||
},
|
||||
] as unknown as UpdateFragment[];
|
||||
}
|
||||
|
||||
describe(ProjectStatus, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAppByIdAsync.mockResolvedValue(makeApp());
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
|
||||
mockWorkflowRunsAsync.mockResolvedValue([makeWorkflowRun()]);
|
||||
mockProjectStatusSubmissionsAsync.mockResolvedValue([makeSubmission()]);
|
||||
mockViewUpdateGroupsAsync.mockResolvedValue([makeUpdateGroup()]);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ProjectStatus {
|
||||
const command = new ProjectStatus(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is stubbed for the test
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('splits production and development build queries by filter', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit: 4,
|
||||
offset: 0,
|
||||
filter: { developmentClient: false, distribution: DistributionType.Store },
|
||||
});
|
||||
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit: 4,
|
||||
offset: 0,
|
||||
filter: { developmentClient: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('respects the --limit flag across all sections', async () => {
|
||||
const command = createCommand(['--limit', '5']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockWorkflowRunsAsync).toHaveBeenCalledWith(graphqlClient, projectId, undefined, 6);
|
||||
expect(mockProjectStatusSubmissionsAsync).toHaveBeenCalledWith(graphqlClient, projectId, {
|
||||
limit: 6,
|
||||
offset: 0,
|
||||
});
|
||||
expect(mockViewUpdateGroupsAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit: 6,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders human-readable text output by default without touching JSON output', async () => {
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).resolves.not.toThrow();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders build statuses unknown to this CLI version', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([
|
||||
makeBuild({ status: 'WAITING_FOR_CAPACITY' as BuildStatus }),
|
||||
]);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('waiting for capacity'));
|
||||
});
|
||||
|
||||
it('renders workflow run statuses unknown to this CLI version', async () => {
|
||||
mockWorkflowRunsAsync.mockResolvedValue([
|
||||
makeWorkflowRun({ status: 'PENDING_CANCEL' as WorkflowRunStatus }),
|
||||
]);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('pending cancel'));
|
||||
});
|
||||
|
||||
it('handles a project with no activity in any section', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([]);
|
||||
mockWorkflowRunsAsync.mockResolvedValue([]);
|
||||
mockProjectStatusSubmissionsAsync.mockResolvedValue([]);
|
||||
mockViewUpdateGroupsAsync.mockResolvedValue([]);
|
||||
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('prints an aggregated JSON snapshot with --json', async () => {
|
||||
const command = createCommand(['--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledTimes(1);
|
||||
|
||||
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(output.generatedAt).toEqual(expect.any(String));
|
||||
expect(output.limit).toBe(3);
|
||||
expect(output.hasMore).toEqual({
|
||||
productionBuilds: false,
|
||||
developmentBuilds: false,
|
||||
workflowRuns: false,
|
||||
submissions: false,
|
||||
updates: false,
|
||||
});
|
||||
expect(output.project).toMatchObject({
|
||||
id: projectId,
|
||||
fullName: '@jester/my-app',
|
||||
account: 'jester',
|
||||
url: 'https://expo.dev/accounts/jester/projects/my-app',
|
||||
});
|
||||
expect(output.productionBuilds).toHaveLength(1);
|
||||
expect(output.productionBuilds[0]).toMatchObject({
|
||||
id: 'build-1',
|
||||
status: BuildStatus.Finished,
|
||||
gitCommitMessage: 'Ship it',
|
||||
url: 'https://expo.dev/accounts/jester/projects/my-app/builds/build-1',
|
||||
});
|
||||
expect(output.developmentBuilds).toHaveLength(1);
|
||||
expect(output.workflowRuns[0]).toMatchObject({
|
||||
id: 'run-1',
|
||||
workflowName: 'Publish',
|
||||
errors: [],
|
||||
url: 'https://expo.dev/accounts/jester/projects/my-app/workflows/run-1',
|
||||
});
|
||||
expect(output.submissions[0]).toMatchObject({
|
||||
id: 'submission-1',
|
||||
androidTrack: 'production',
|
||||
appIdentifier: 'com.example.app',
|
||||
buildId: 'build-1',
|
||||
rollout: 50,
|
||||
createdAt: '2026-07-02T12:00:00.000Z',
|
||||
url: 'https://expo.dev/accounts/jester/projects/my-app/submissions/submission-1',
|
||||
});
|
||||
expect(output.updates[0]).toMatchObject({
|
||||
group: 'group-1',
|
||||
branch: 'production',
|
||||
platforms: ['android', 'ios'],
|
||||
message: 'Fix crash on launch',
|
||||
rolloutPercentage: 25,
|
||||
environment: 'production',
|
||||
initiatingActor: 'jester',
|
||||
url: 'https://expo.dev/accounts/jester/projects/my-app/updates/group-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes structured activity errors', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([
|
||||
makeBuild({
|
||||
status: BuildStatus.Errored,
|
||||
error: {
|
||||
errorCode: 'BUILD_FAILED',
|
||||
message: 'Gradle failed',
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/troubleshooting/',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockWorkflowRunsAsync.mockResolvedValue([
|
||||
makeWorkflowRun({
|
||||
status: WorkflowRunStatus.Failure,
|
||||
errors: [{ title: 'Deploy failed', message: 'Deployment did not complete' }],
|
||||
}),
|
||||
]);
|
||||
mockProjectStatusSubmissionsAsync.mockResolvedValue([
|
||||
makeSubmission({
|
||||
status: SubmissionStatus.Errored,
|
||||
error: { errorCode: 'SUBMIT_FAILED', message: 'Store upload failed' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(output.productionBuilds[0].error).toEqual({
|
||||
code: 'BUILD_FAILED',
|
||||
message: 'Gradle failed',
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/troubleshooting/',
|
||||
});
|
||||
expect(output.workflowRuns[0].errors).toEqual([
|
||||
{ title: 'Deploy failed', message: 'Deployment did not complete' },
|
||||
]);
|
||||
expect(output.submissions[0].error).toEqual({
|
||||
code: 'SUBMIT_FAILED',
|
||||
message: 'Store upload failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports when more results are available and returns only the requested limit', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeBuild()));
|
||||
mockWorkflowRunsAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeWorkflowRun()));
|
||||
mockProjectStatusSubmissionsAsync.mockResolvedValue(
|
||||
Array.from({ length: 4 }, () => makeSubmission())
|
||||
);
|
||||
mockViewUpdateGroupsAsync.mockResolvedValue(Array.from({ length: 4 }, () => makeUpdateGroup()));
|
||||
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
const output = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(output.hasMore).toEqual({
|
||||
productionBuilds: true,
|
||||
developmentBuilds: true,
|
||||
workflowRuns: true,
|
||||
submissions: true,
|
||||
updates: true,
|
||||
});
|
||||
expect(output.productionBuilds).toHaveLength(3);
|
||||
expect(output.developmentBuilds).toHaveLength(3);
|
||||
expect(output.workflowRuns).toHaveLength(3);
|
||||
expect(output.submissions).toHaveLength(3);
|
||||
expect(output.updates).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import {
|
||||
PROJECT_STATUS_DEFAULT_LIMIT,
|
||||
getProjectStatusAsync,
|
||||
printProjectStatusAsText,
|
||||
} from '../../project/projectStatus';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ProjectStatus extends EasCommand {
|
||||
static override description =
|
||||
'show a snapshot of the project: recent builds, dev builds, workflow runs, submissions, and updates';
|
||||
|
||||
static override aliases = ['status'];
|
||||
|
||||
static override examples = [
|
||||
'$ eas status \t # Show a concise project snapshot',
|
||||
'$ eas status --json \t # Output a machine-readable snapshot for agents and automation',
|
||||
'$ eas status --json --limit 10 \t # Include more activity from each section',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: PROJECT_STATUS_DEFAULT_LIMIT,
|
||||
limit: 25,
|
||||
description: `The number of items to show in each section. Defaults to ${PROJECT_STATUS_DEFAULT_LIMIT} and is capped at 25.`,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ProjectStatus);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ProjectStatus, { nonInteractive });
|
||||
|
||||
const status = await getProjectStatusAsync(graphqlClient, {
|
||||
projectId,
|
||||
limit: flags.limit ?? PROJECT_STATUS_DEFAULT_LIMIT,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(status);
|
||||
return;
|
||||
}
|
||||
|
||||
printProjectStatusAsText(status);
|
||||
}
|
||||
}
|
||||
@@ -509,7 +509,7 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --device to the create mutation as deviceIdentifier', async () => {
|
||||
it('forwards --device in the iOS create options', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
@@ -521,7 +521,23 @@ describe(Simulator, () => {
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
|
||||
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --device in the Android create options', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--device',
|
||||
'pixel_9',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ android: { deviceIdentifier: 'pixel_9' } })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -537,7 +553,7 @@ describe(Simulator, () => {
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
|
||||
expect.objectContaining({ ios: { deviceIdentifier: 'iPhone 16 Pro' } })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -545,10 +561,7 @@ describe(Simulator, () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--device', ' ']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: undefined })
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('ios');
|
||||
});
|
||||
|
||||
it('omits resourceClass when --resource-class is not set', async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
formatRemoteSessionInstructions,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../../simulator/utils';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
|
||||
@@ -96,7 +97,9 @@ export default class SimulatorGet extends EasCommand {
|
||||
finishedAt: session.finishedAt ?? undefined,
|
||||
updatedAt: session.updatedAt,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig: session.remoteConfig,
|
||||
remoteConfig: session.remoteConfig
|
||||
? sanitizeRemoteConfigForJson(session.remoteConfig)
|
||||
: session.remoteConfig,
|
||||
artifacts: session.artifacts,
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
@@ -249,7 +250,11 @@ export default class Simulator extends EasCommand {
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
deviceIdentifier,
|
||||
...(deviceIdentifier
|
||||
? platform === AppPlatform.Ios
|
||||
? { ios: { deviceIdentifier } }
|
||||
: { android: { deviceIdentifier } }
|
||||
: {}),
|
||||
...(buildId ? { buildId } : {}),
|
||||
...(applicationArchiveUrlFromFlag
|
||||
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
|
||||
@@ -381,7 +386,7 @@ export default class Simulator extends EasCommand {
|
||||
name,
|
||||
type: flags.type,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig,
|
||||
remoteConfig: sanitizeRemoteConfigForJson(remoteConfig),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ export const distributionCertificateSchema: CredentialSchema<DistributionCertifi
|
||||
export type MinimalAscApiKey = {
|
||||
keyP8: string;
|
||||
keyId: string;
|
||||
issuerId: string;
|
||||
// Absent for individual API keys, which are valid only for submissions.
|
||||
issuerId?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
roles?: UserRole[];
|
||||
@@ -115,7 +116,7 @@ export type MinimalAscApiKey = {
|
||||
export interface AscApiKeyPath {
|
||||
keyP8Path: string;
|
||||
keyId: string;
|
||||
issuerId: string;
|
||||
issuerId?: string;
|
||||
}
|
||||
|
||||
export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>> = {
|
||||
@@ -129,7 +130,7 @@ export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>
|
||||
],
|
||||
};
|
||||
|
||||
export const ascApiKeyIssuerIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'issuerId'>> = {
|
||||
export const ascApiKeyIssuerIdSchema: CredentialSchema<{ issuerId: string }> = {
|
||||
name: 'App Store Connect API Key',
|
||||
questions: [
|
||||
{
|
||||
|
||||
@@ -246,7 +246,11 @@ function displayAscApiKey(
|
||||
if (name) {
|
||||
fields.push({ label: 'Name', value: name });
|
||||
}
|
||||
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
|
||||
if (issuerIdentifier) {
|
||||
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
|
||||
} else {
|
||||
fields.push({ label: 'Key Type', value: 'Individual (submissions only)' });
|
||||
}
|
||||
if (roles) {
|
||||
fields.push({ label: 'Roles', value: roles.join(',') });
|
||||
}
|
||||
|
||||
+1804
-865
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@ import {
|
||||
GenerateRealtimeLogsCentrifugoSubscriptionTokenMutationVariables,
|
||||
RealtimeLogsCentrifugoConnectionToken,
|
||||
RealtimeLogsCentrifugoSubscriptionToken,
|
||||
RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs,
|
||||
RealtimeLogsMutation_GenerateCentrifugoSubscriptionTokenArgs,
|
||||
} from '../generated';
|
||||
|
||||
export const RealtimeLogsMutation = {
|
||||
@@ -39,7 +39,7 @@ export const RealtimeLogsMutation = {
|
||||
},
|
||||
async generateCentrifugoSubscriptionTokenAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ target, thread }: RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs
|
||||
{ target, thread }: RealtimeLogsMutation_GenerateCentrifugoSubscriptionTokenArgs
|
||||
): Promise<RealtimeLogsCentrifugoSubscriptionToken> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
|
||||
@@ -12,7 +12,7 @@ export const AppStoreConnectApiKeyQuery = {
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
ascApiKeyId: string
|
||||
): Promise<{
|
||||
issuerIdentifier: string;
|
||||
issuerIdentifier: string | undefined;
|
||||
keyIdentifier: string;
|
||||
keyP8: string;
|
||||
}> {
|
||||
@@ -42,7 +42,7 @@ export const AppStoreConnectApiKeyQuery = {
|
||||
const key = data.appStoreConnectApiKey.byId;
|
||||
|
||||
return {
|
||||
issuerIdentifier: key.issuerIdentifier,
|
||||
issuerIdentifier: key.issuerIdentifier ?? undefined,
|
||||
keyIdentifier: key.keyIdentifier,
|
||||
keyP8: key.keyP8,
|
||||
};
|
||||
|
||||
@@ -98,6 +98,7 @@ export const ChannelQuery = {
|
||||
updateChannelByName(name: $channelName) {
|
||||
id
|
||||
isPaused
|
||||
isProtected
|
||||
name
|
||||
updatedAt
|
||||
createdAt
|
||||
@@ -144,6 +145,7 @@ export const ChannelQuery = {
|
||||
updateChannels(offset: $offset, limit: $limit) {
|
||||
id
|
||||
isPaused
|
||||
isProtected
|
||||
name
|
||||
updatedAt
|
||||
createdAt
|
||||
|
||||
@@ -87,22 +87,27 @@ export const DeviceRunSessionQuery = {
|
||||
agentDeviceRemoteSessionUrl
|
||||
agentDeviceRemoteSessionToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
}
|
||||
... on ArgentRunSessionRemoteConfig {
|
||||
toolsUrl
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
}
|
||||
... on AppiumRunSessionRemoteConfig {
|
||||
appiumUrl
|
||||
capabilities
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
previewUrl
|
||||
previewToken
|
||||
}
|
||||
... on WebPreviewOnlyRunSessionRemoteConfig {
|
||||
previewUrl: webPreviewUrl
|
||||
previewToken: webPreviewToken
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
|
||||
@@ -4,51 +4,40 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AppObserveAppVersion,
|
||||
AppObserveCustomEvent,
|
||||
AppObserveCustomEventListFilter,
|
||||
AppObserveCustomEventListOrderBy,
|
||||
AppObserveCustomEventName,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObserveError,
|
||||
AppObserveErrorGroup,
|
||||
AppObserveErrorOccurrencesFilter,
|
||||
AppObserveErrorOccurrencesOrderBy,
|
||||
AppObserveErrorsGroupsInput,
|
||||
AppObserveLogsOrderBy,
|
||||
AppObserveMetric,
|
||||
AppObserveMetricsListFilter,
|
||||
AppObserveMetricsListOrderBy,
|
||||
AppObserveNavigationFilter,
|
||||
AppObserveNavigationOrderBy,
|
||||
AppObserveNavigationRoute,
|
||||
AppObserveNavigationRoutesFilter,
|
||||
AppObserveNavigationRoutesOrderBy,
|
||||
AppObservePlatform,
|
||||
AppObserveReleasesInput,
|
||||
AppObserveTimeSeriesInput,
|
||||
AppObserveTimeSeriesStatistics,
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListFilter,
|
||||
AppObserveUserEventListOrderBy,
|
||||
AppObserveUserEventName,
|
||||
PageInfo,
|
||||
} from '../generated';
|
||||
import { print } from 'graphql';
|
||||
import {
|
||||
AppObserveAppVersionFragmentNode,
|
||||
AppObserveCustomEventFragmentNode,
|
||||
AppObserveEventFragmentNode,
|
||||
AppObserveTimeSeriesFragmentNode,
|
||||
AppObserveErrorFragmentNode,
|
||||
AppObserveErrorGroupFragmentNode,
|
||||
AppObserveErrorOccurrenceFragmentNode,
|
||||
AppObserveMetricFragmentNode,
|
||||
AppObserveUserEventFragmentNode,
|
||||
} from '../types/Observe';
|
||||
|
||||
export type AppObserveTimeSeriesResult = {
|
||||
appVersionMarkers: AppObserveAppVersion[];
|
||||
eventCount: number;
|
||||
statistics: AppObserveTimeSeriesStatistics;
|
||||
};
|
||||
|
||||
type AppObserveTimeSeriesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
timeSeries: AppObserveTimeSeriesResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveTimeSeriesQueryVariables = {
|
||||
appId: string;
|
||||
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
|
||||
};
|
||||
/** A `session.logs` node is a user event or an error (the `AppObserveLog` interface). */
|
||||
export type AppObserveSessionLog =
|
||||
| ({ __typename: 'AppObserveUserEvent' } & AppObserveUserEvent)
|
||||
| ({ __typename: 'AppObserveError' } & AppObserveError);
|
||||
|
||||
type AppObserveAppVersionsQuery = {
|
||||
app: {
|
||||
@@ -66,76 +55,84 @@ type AppObserveAppVersionsQueryVariables = {
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
type AppObserveEventsQuery = {
|
||||
type AppObserveMetricsListQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
events: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveEvent;
|
||||
}>;
|
||||
metrics: {
|
||||
list: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveMetric;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveEventsQueryVariables = {
|
||||
type AppObserveMetricsListQueryVariables = {
|
||||
appId: string;
|
||||
filter?: AppObserveEventsFilter;
|
||||
filter?: AppObserveMetricsListFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveEventsOrderBy;
|
||||
orderBy?: AppObserveMetricsListOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveCustomEventListQuery = {
|
||||
type AppObserveUserEventListQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
customEventList: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveCustomEvent;
|
||||
}>;
|
||||
userEvents: {
|
||||
list: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveUserEvent;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveCustomEventListQueryVariables = {
|
||||
type AppObserveUserEventListQueryVariables = {
|
||||
appId: string;
|
||||
filter?: AppObserveCustomEventListFilter;
|
||||
filter?: AppObserveUserEventListFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveCustomEventListOrderBy;
|
||||
orderBy?: AppObserveUserEventListOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveCustomEventNamesQuery = {
|
||||
type AppObserveUserEventNamesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
customEventNames: {
|
||||
isTruncated: boolean;
|
||||
names: AppObserveCustomEventName[];
|
||||
userEvents: {
|
||||
names: {
|
||||
isTruncated: boolean;
|
||||
names: AppObserveUserEventName[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveCustomEventNamesQueryVariables = {
|
||||
type AppObserveUserEventNamesQueryVariables = {
|
||||
appId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platform?: AppObservePlatform;
|
||||
environment?: string;
|
||||
input: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platforms?: AppObservePlatform[];
|
||||
environment?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveNavigationRoutesQuery = {
|
||||
@@ -143,12 +140,14 @@ type AppObserveNavigationRoutesQuery = {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
navigationRoutes: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveNavigationRoute;
|
||||
}>;
|
||||
navigation: {
|
||||
routes: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveNavigationRoute;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -157,86 +156,139 @@ type AppObserveNavigationRoutesQuery = {
|
||||
|
||||
type AppObserveNavigationRoutesQueryVariables = {
|
||||
appId: string;
|
||||
filter: AppObserveNavigationRoutesFilter;
|
||||
filter: AppObserveNavigationFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveNavigationRoutesOrderBy;
|
||||
orderBy?: AppObserveNavigationOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveErrorGroupsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
errors: {
|
||||
groups: {
|
||||
isTruncated: boolean;
|
||||
groups: AppObserveErrorGroup[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveErrorGroupsQueryVariables = {
|
||||
appId: string;
|
||||
input: AppObserveErrorsGroupsInput;
|
||||
};
|
||||
|
||||
type AppObserveErrorOccurrencesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
errors: {
|
||||
occurrences: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{ cursor: string; node: AppObserveError }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveErrorOccurrencesQueryVariables = {
|
||||
appId: string;
|
||||
filter: AppObserveErrorOccurrencesFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveErrorOccurrencesOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveSessionEventsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
session: {
|
||||
id: string;
|
||||
metrics: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{ node: AppObserveMetric }>;
|
||||
};
|
||||
logs: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{ node: AppObserveSessionLog }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveSessionEventsQueryVariables = {
|
||||
appId: string;
|
||||
id: string;
|
||||
first?: number;
|
||||
metricsOrderBy?: AppObserveMetricsListOrderBy;
|
||||
logsOrderBy?: AppObserveLogsOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveMetricByIdQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
metrics: {
|
||||
metric: AppObserveMetric | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveLogByIdQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
log: AppObserveSessionLog | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveByIdQueryVariables = {
|
||||
appId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const ObserveQuery = {
|
||||
async timeSeriesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
metricName,
|
||||
platform,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
appId: string;
|
||||
metricName: string;
|
||||
platform: AppObservePlatform;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
): Promise<AppObserveTimeSeriesResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveTimeSeries(
|
||||
$appId: String!
|
||||
$input: AppObserveTimeSeriesInput!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
timeSeries(input: $input) {
|
||||
...AppObserveTimeSeriesFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveAppVersionFragmentNode)}
|
||||
${print(AppObserveTimeSeriesFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
input: { metricName, platform, startTime, endTime },
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.timeSeries;
|
||||
},
|
||||
|
||||
async appVersionsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
platform,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames,
|
||||
environment,
|
||||
}: {
|
||||
appId: string;
|
||||
platform: AppObservePlatform;
|
||||
platforms: AppObservePlatform[];
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
metricNames?: string[];
|
||||
environment?: string;
|
||||
}
|
||||
): Promise<AppObserveAppVersion[]> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveAppVersionsQuery, AppObserveAppVersionsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveAppVersions(
|
||||
$appId: String!
|
||||
$input: AppObserveReleasesInput!
|
||||
) {
|
||||
query AppObserveAppVersions($appId: String!, $input: AppObserveReleasesInput!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
@@ -252,7 +304,13 @@ export const ObserveQuery = {
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
input: { platform, startTime, endTime, ...(metricNames && { metricNames }) },
|
||||
input: {
|
||||
platforms,
|
||||
startTime,
|
||||
endTime,
|
||||
...(metricNames && { metricNames }),
|
||||
...(environment && { environment }),
|
||||
},
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
@@ -263,39 +321,36 @@ export const ObserveQuery = {
|
||||
|
||||
async eventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveEventsQueryVariables
|
||||
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
|
||||
variables: AppObserveMetricsListQueryVariables
|
||||
): Promise<{ events: AppObserveMetric[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
|
||||
.query<AppObserveMetricsListQuery, AppObserveMetricsListQueryVariables>(
|
||||
gql`
|
||||
query AppObserveEvents(
|
||||
query AppObserveMetricsList(
|
||||
$appId: String!
|
||||
$filter: AppObserveEventsFilter
|
||||
$filter: AppObserveMetricsListFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveEventsOrderBy
|
||||
$orderBy: AppObserveMetricsListOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
events(
|
||||
filter: $filter
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $orderBy
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveEventFragment
|
||||
metrics {
|
||||
list(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveMetricFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,14 +358,14 @@ export const ObserveQuery = {
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveEventFragmentNode)}
|
||||
${print(AppObserveMetricFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.events;
|
||||
const { edges, pageInfo } = data.app.byId.observe.metrics.list;
|
||||
return {
|
||||
events: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
@@ -319,39 +374,36 @@ export const ObserveQuery = {
|
||||
|
||||
async customEventListAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveCustomEventListQueryVariables
|
||||
): Promise<{ events: AppObserveCustomEvent[]; pageInfo: PageInfo }> {
|
||||
variables: AppObserveUserEventListQueryVariables
|
||||
): Promise<{ events: AppObserveUserEvent[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveCustomEventListQuery, AppObserveCustomEventListQueryVariables>(
|
||||
.query<AppObserveUserEventListQuery, AppObserveUserEventListQueryVariables>(
|
||||
gql`
|
||||
query AppObserveCustomEventList(
|
||||
query AppObserveUserEventList(
|
||||
$appId: String!
|
||||
$filter: AppObserveCustomEventListFilter
|
||||
$filter: AppObserveUserEventListFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveCustomEventListOrderBy
|
||||
$orderBy: AppObserveUserEventListOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
customEventList(
|
||||
filter: $filter
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $orderBy
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveCustomEventFragment
|
||||
userEvents {
|
||||
list(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveUserEventFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,14 +411,14 @@ export const ObserveQuery = {
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveCustomEventFragmentNode)}
|
||||
${print(AppObserveUserEventFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.customEventList;
|
||||
const { edges, pageInfo } = data.app.byId.observe.userEvents.list;
|
||||
return {
|
||||
events: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
@@ -379,41 +431,32 @@ export const ObserveQuery = {
|
||||
appId,
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
platforms,
|
||||
environment,
|
||||
}: {
|
||||
appId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platform?: AppObservePlatform;
|
||||
platforms?: AppObservePlatform[];
|
||||
environment?: string;
|
||||
}
|
||||
): Promise<{ names: AppObserveCustomEventName[]; isTruncated: boolean }> {
|
||||
): Promise<{ names: AppObserveUserEventName[]; isTruncated: boolean }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveCustomEventNamesQuery, AppObserveCustomEventNamesQueryVariables>(
|
||||
.query<AppObserveUserEventNamesQuery, AppObserveUserEventNamesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveCustomEventNames(
|
||||
$appId: String!
|
||||
$startTime: DateTime!
|
||||
$endTime: DateTime!
|
||||
$platform: AppObservePlatform
|
||||
$environment: String
|
||||
) {
|
||||
query AppObserveUserEventNames($appId: String!, $input: AppObserveUserEventNamesInput!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
customEventNames(
|
||||
startTime: $startTime
|
||||
endTime: $endTime
|
||||
platform: $platform
|
||||
environment: $environment
|
||||
) {
|
||||
isTruncated
|
||||
names {
|
||||
eventName
|
||||
count
|
||||
userEvents {
|
||||
names(input: $input) {
|
||||
isTruncated
|
||||
names {
|
||||
name
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,16 +466,18 @@ export const ObserveQuery = {
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
startTime,
|
||||
endTime,
|
||||
...(platform && { platform }),
|
||||
...(environment && { environment }),
|
||||
input: {
|
||||
startTime,
|
||||
endTime,
|
||||
...(platforms?.length && { platforms }),
|
||||
...(environment && { environment }),
|
||||
},
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.customEventNames;
|
||||
return data.app.byId.observe.userEvents.names;
|
||||
},
|
||||
|
||||
async navigationRoutesAsync(
|
||||
@@ -445,39 +490,41 @@ export const ObserveQuery = {
|
||||
gql`
|
||||
query AppObserveNavigationRoutes(
|
||||
$appId: String!
|
||||
$filter: AppObserveNavigationRoutesFilter!
|
||||
$filter: AppObserveNavigationFilter!
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveNavigationRoutesOrderBy
|
||||
$orderBy: AppObserveNavigationOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
navigationRoutes(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
routeName
|
||||
coldTtr {
|
||||
count
|
||||
median
|
||||
p90
|
||||
}
|
||||
warmTtr {
|
||||
count
|
||||
median
|
||||
p90
|
||||
}
|
||||
tti {
|
||||
count
|
||||
median
|
||||
p90
|
||||
navigation {
|
||||
routes(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
routeName
|
||||
coldTtr {
|
||||
count
|
||||
median
|
||||
p90
|
||||
}
|
||||
warmTtr {
|
||||
count
|
||||
median
|
||||
p90
|
||||
}
|
||||
tti {
|
||||
count
|
||||
median
|
||||
p90
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,10 +539,255 @@ export const ObserveQuery = {
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.navigationRoutes;
|
||||
const { edges, pageInfo } = data.app.byId.observe.navigation.routes;
|
||||
return {
|
||||
routes: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async errorGroupsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, input }: { appId: string; input: AppObserveErrorsGroupsInput }
|
||||
): Promise<{ groups: AppObserveErrorGroup[]; isTruncated: boolean }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveErrorGroupsQuery, AppObserveErrorGroupsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveErrorGroups($appId: String!, $input: AppObserveErrorsGroupsInput!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
errors {
|
||||
groups(input: $input) {
|
||||
isTruncated
|
||||
groups {
|
||||
...AppObserveErrorGroupFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveErrorGroupFragmentNode)}
|
||||
`,
|
||||
{ appId, input }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.errors.groups;
|
||||
},
|
||||
|
||||
async errorOccurrencesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveErrorOccurrencesQueryVariables
|
||||
): Promise<{ occurrences: AppObserveError[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveErrorOccurrencesQuery, AppObserveErrorOccurrencesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveErrorOccurrences(
|
||||
$appId: String!
|
||||
$filter: AppObserveErrorOccurrencesFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveErrorOccurrencesOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
errors {
|
||||
occurrences(
|
||||
filter: $filter
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $orderBy
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveErrorOccurrenceFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveErrorOccurrenceFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.errors.occurrences;
|
||||
return {
|
||||
occurrences: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async sessionEventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveSessionEventsQueryVariables
|
||||
): Promise<{
|
||||
metrics: AppObserveMetric[];
|
||||
logs: AppObserveSessionLog[];
|
||||
metricsPageInfo: PageInfo;
|
||||
logsPageInfo: PageInfo;
|
||||
}> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveSessionEventsQuery, AppObserveSessionEventsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveSessionEvents(
|
||||
$appId: String!
|
||||
$id: ID!
|
||||
$first: Int
|
||||
$metricsOrderBy: AppObserveMetricsListOrderBy
|
||||
$logsOrderBy: AppObserveLogsOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
session(id: $id) {
|
||||
id
|
||||
metrics(first: $first, orderBy: $metricsOrderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...AppObserveMetricFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
logs(first: $first, orderBy: $logsOrderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
__typename
|
||||
... on AppObserveUserEvent {
|
||||
...AppObserveUserEventFragment
|
||||
}
|
||||
... on AppObserveError {
|
||||
...AppObserveErrorFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveMetricFragmentNode)}
|
||||
${print(AppObserveUserEventFragmentNode)}
|
||||
${print(AppObserveErrorFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { metrics, logs } = data.app.byId.observe.session;
|
||||
return {
|
||||
metrics: metrics.edges.map(edge => edge.node),
|
||||
logs: logs.edges.map(edge => edge.node),
|
||||
metricsPageInfo: metrics.pageInfo,
|
||||
logsPageInfo: logs.pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async metricByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, id }: AppObserveByIdQueryVariables
|
||||
): Promise<AppObserveMetric | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveMetricByIdQuery, AppObserveByIdQueryVariables>(
|
||||
gql`
|
||||
query AppObserveMetricById($appId: String!, $id: ID!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
metrics {
|
||||
metric(id: $id) {
|
||||
id
|
||||
...AppObserveMetricFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveMetricFragmentNode)}
|
||||
`,
|
||||
{ appId, id }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.metrics.metric ?? null;
|
||||
},
|
||||
|
||||
async logByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, id }: AppObserveByIdQueryVariables
|
||||
): Promise<AppObserveSessionLog | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveLogByIdQuery, AppObserveByIdQueryVariables>(
|
||||
gql`
|
||||
query AppObserveLogById($appId: String!, $id: ID!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
log(id: $id) {
|
||||
id
|
||||
__typename
|
||||
... on AppObserveUserEvent {
|
||||
...AppObserveUserEventFragment
|
||||
}
|
||||
... on AppObserveError {
|
||||
...AppObserveErrorFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveUserEventFragmentNode)}
|
||||
${print(AppObserveErrorFragmentNode)}
|
||||
`,
|
||||
{ appId, id }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.log ?? null;
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
AppPlatform,
|
||||
GetAllSubmissionsForAppQuery,
|
||||
GetAllSubmissionsForAppQueryVariables,
|
||||
GetProjectStatusSubmissionsQuery,
|
||||
GetProjectStatusSubmissionsQueryVariables,
|
||||
SubmissionByIdWithSubmittedBuildQuery,
|
||||
SubmissionByIdWithSubmittedBuildQueryVariables,
|
||||
SubmissionFragment,
|
||||
@@ -25,6 +27,9 @@ type Filters = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ProjectStatusSubmission =
|
||||
GetProjectStatusSubmissionsQuery['app']['byId']['submissions'][number];
|
||||
|
||||
export const SubmissionQuery = {
|
||||
async byIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -125,4 +130,57 @@ export const SubmissionQuery = {
|
||||
|
||||
return data.app?.byId.submissions ?? [];
|
||||
},
|
||||
|
||||
async forProjectStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
{ limit, offset }: { limit: number; offset: number }
|
||||
): Promise<ProjectStatusSubmission[]> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetProjectStatusSubmissionsQuery, GetProjectStatusSubmissionsQueryVariables>(
|
||||
gql`
|
||||
query GetProjectStatusSubmissions($appId: String!, $offset: Int!, $limit: Int!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
submissions(filter: {}, offset: $offset, limit: $limit) {
|
||||
id
|
||||
status
|
||||
platform
|
||||
createdAt
|
||||
completedAt
|
||||
initiatingActor {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
submittedBuild {
|
||||
id
|
||||
}
|
||||
androidConfig {
|
||||
applicationIdentifier
|
||||
track
|
||||
releaseStatus
|
||||
rollout
|
||||
}
|
||||
iosConfig {
|
||||
ascAppIdentifier
|
||||
}
|
||||
error {
|
||||
errorCode
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, offset, limit },
|
||||
{ additionalTypenames: ['Submission'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app?.byId.submissions ?? [];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { ChannelQuery } from '../ChannelQuery';
|
||||
|
||||
function makeGraphqlClient(data: unknown): {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
query: jest.Mock;
|
||||
} {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: jest.fn().mockResolvedValue({ data }),
|
||||
});
|
||||
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
|
||||
}
|
||||
|
||||
describe(ChannelQuery.viewUpdateChannelAsync.name, () => {
|
||||
it('requests and returns channel protection state', async () => {
|
||||
const channel = {
|
||||
id: 'channel-id',
|
||||
name: 'production',
|
||||
isPaused: false,
|
||||
isProtected: true,
|
||||
updatedAt: '2026-08-31T00:00:00.000Z',
|
||||
createdAt: '2026-08-31T00:00:00.000Z',
|
||||
branchMapping: '{"version":0,"data":[]}',
|
||||
updateBranches: [],
|
||||
};
|
||||
const { graphqlClient, query } = makeGraphqlClient({
|
||||
app: { byId: { id: 'app-id', updateChannelByName: channel } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: 'app-id',
|
||||
channelName: 'production',
|
||||
})
|
||||
).resolves.toEqual(channel);
|
||||
|
||||
expect(query.mock.calls[0][0].loc.source.body).toContain('isProtected');
|
||||
});
|
||||
});
|
||||
|
||||
describe(ChannelQuery.viewUpdateChannelsOnAppAsync.name, () => {
|
||||
it('requests and returns protection state for every channel', async () => {
|
||||
const channels = [
|
||||
{
|
||||
id: 'channel-id',
|
||||
name: 'production',
|
||||
isPaused: false,
|
||||
isProtected: true,
|
||||
updatedAt: '2026-08-31T00:00:00.000Z',
|
||||
createdAt: '2026-08-31T00:00:00.000Z',
|
||||
branchMapping: '{"version":0,"data":[]}',
|
||||
updateBranches: [],
|
||||
},
|
||||
];
|
||||
const { graphqlClient, query } = makeGraphqlClient({
|
||||
app: { byId: { id: 'app-id', updateChannels: channels } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
|
||||
appId: 'app-id',
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
).resolves.toEqual(channels);
|
||||
|
||||
expect(query.mock.calls[0][0].loc.source.body).toContain('isProtected');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,9 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const AppObserveTimeSeriesFragmentNode = gql`
|
||||
fragment AppObserveTimeSeriesFragment on AppObserveTimeSeries {
|
||||
appVersionMarkers {
|
||||
...AppObserveAppVersionFragment
|
||||
}
|
||||
eventCount
|
||||
statistics {
|
||||
min
|
||||
max
|
||||
median
|
||||
average
|
||||
p80
|
||||
p90
|
||||
p99
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveCustomEventFragmentNode = gql`
|
||||
fragment AppObserveCustomEventFragment on AppObserveCustomEvent {
|
||||
export const AppObserveUserEventFragmentNode = gql`
|
||||
fragment AppObserveUserEventFragment on AppObserveUserEvent {
|
||||
id
|
||||
eventName
|
||||
name
|
||||
timestamp
|
||||
sessionId
|
||||
severityNumber
|
||||
@@ -44,11 +26,73 @@ export const AppObserveCustomEventFragmentNode = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveEventFragmentNode = gql`
|
||||
fragment AppObserveEventFragment on AppObserveEvent {
|
||||
export const AppObserveErrorFragmentNode = gql`
|
||||
fragment AppObserveErrorFragment on AppObserveError {
|
||||
id
|
||||
metricName
|
||||
metricValue
|
||||
timestamp
|
||||
sessionId
|
||||
severityNumber
|
||||
severityText
|
||||
type
|
||||
message
|
||||
source
|
||||
fingerprint
|
||||
isFatal
|
||||
properties {
|
||||
key
|
||||
value
|
||||
type
|
||||
}
|
||||
appVersion
|
||||
appBuildNumber
|
||||
appUpdateId
|
||||
appEasBuildId
|
||||
deviceOs
|
||||
deviceOsVersion
|
||||
deviceModel
|
||||
environment
|
||||
easClientId
|
||||
countryCode
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveErrorOccurrenceFragmentNode = gql`
|
||||
fragment AppObserveErrorOccurrenceFragment on AppObserveError {
|
||||
id
|
||||
timestamp
|
||||
sessionId
|
||||
severityNumber
|
||||
severityText
|
||||
type
|
||||
message
|
||||
source
|
||||
fingerprint
|
||||
isFatal
|
||||
stacktrace
|
||||
body
|
||||
properties {
|
||||
key
|
||||
value
|
||||
type
|
||||
}
|
||||
appVersion
|
||||
appBuildNumber
|
||||
appUpdateId
|
||||
appEasBuildId
|
||||
deviceOs
|
||||
deviceOsVersion
|
||||
deviceModel
|
||||
environment
|
||||
easClientId
|
||||
countryCode
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveMetricFragmentNode = gql`
|
||||
fragment AppObserveMetricFragment on AppObserveMetric {
|
||||
id
|
||||
name
|
||||
value
|
||||
timestamp
|
||||
appVersion
|
||||
appBuildNumber
|
||||
@@ -64,6 +108,23 @@ export const AppObserveEventFragmentNode = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveErrorGroupFragmentNode = gql`
|
||||
fragment AppObserveErrorGroupFragment on AppObserveErrorGroup {
|
||||
fingerprint
|
||||
exceptionType
|
||||
exceptionMessage
|
||||
errorSource
|
||||
severity
|
||||
isFatal
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
affectedSessionCount
|
||||
firstSeenAt
|
||||
lastSeenAt
|
||||
platforms
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveAppVersionFragmentNode = gql`
|
||||
fragment AppObserveAppVersionFragment on AppObserveAppVersion {
|
||||
appVersion
|
||||
|
||||
@@ -5,5 +5,6 @@ export const UpdateChannelBasicInfoFragmentNode = gql`
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
isProtected
|
||||
}
|
||||
`;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { AppObserveCustomEvent, AppObservePlatform } from '../../graphql/generated';
|
||||
import { AppObservePlatform, AppObserveUserEvent } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
|
||||
function makeCustomEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent' as const,
|
||||
__typename: 'AppObserveUserEvent' as const,
|
||||
id: 'evt-1',
|
||||
eventName: 'my_event',
|
||||
name: 'my_event',
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '1',
|
||||
@@ -18,7 +18,7 @@ function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObs
|
||||
easClientId: 'client-1',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveCustomEvent;
|
||||
} as AppObserveUserEvent;
|
||||
}
|
||||
|
||||
describe('fetchObserveCustomEventsAsync', () => {
|
||||
@@ -57,13 +57,13 @@ describe('fetchObserveCustomEventsAsync', () => {
|
||||
});
|
||||
|
||||
const filter = mockCustomEventListAsync.mock.calls[0][1].filter;
|
||||
expect(filter?.eventName).toBe('my_event');
|
||||
expect(filter?.name).toBe('my_event');
|
||||
});
|
||||
|
||||
it('forwards platform, appVersion, and sessionId filters when provided', async () => {
|
||||
await fetchObserveCustomEventsAsync(mockGraphqlClient, 'project-123', {
|
||||
limit: 10,
|
||||
platform: AppObservePlatform.Ios,
|
||||
platforms: [AppObservePlatform.Ios],
|
||||
appVersion: '2.1.0',
|
||||
sessionId: 'session-xyz',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
@@ -71,7 +71,7 @@ describe('fetchObserveCustomEventsAsync', () => {
|
||||
});
|
||||
|
||||
const filter = mockCustomEventListAsync.mock.calls[0][1].filter;
|
||||
expect(filter?.platform).toBe(AppObservePlatform.Ios);
|
||||
expect(filter?.platforms).toEqual([AppObservePlatform.Ios]);
|
||||
expect(filter?.appVersion).toBe('2.1.0');
|
||||
expect(filter?.sessionId).toBe('session-xyz');
|
||||
});
|
||||
@@ -107,7 +107,7 @@ describe('fetchObserveCustomEventsAsync', () => {
|
||||
});
|
||||
|
||||
const filter = mockCustomEventListAsync.mock.calls[0][1].filter;
|
||||
expect(filter).not.toHaveProperty('eventName');
|
||||
expect(filter).not.toHaveProperty('name');
|
||||
expect(filter).not.toHaveProperty('platform');
|
||||
expect(filter).not.toHaveProperty('appVersion');
|
||||
expect(filter).not.toHaveProperty('appUpdateId');
|
||||
|
||||
@@ -2,10 +2,9 @@ import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObserveMetricsListOrderByField,
|
||||
AppObserveOrderDirection,
|
||||
AppObservePlatform,
|
||||
AppPlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import {
|
||||
@@ -15,35 +14,40 @@ import {
|
||||
resolveOrderBy,
|
||||
} from '../fetchEvents';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
|
||||
import { ObservePlatformTarget } from '../platforms';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
function target(platform: AppObservePlatform): ObservePlatformTarget {
|
||||
return { key: platform, platforms: [platform] };
|
||||
}
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
it('maps "slowest" to METRIC_VALUE DESC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Slowest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "fastest" to METRIC_VALUE ASC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Fastest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "newest" to TIMESTAMP DESC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Newest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "oldest" to TIMESTAMP ASC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Oldest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
field: AppObserveMetricsListOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -65,8 +69,8 @@ describe(fetchObserveEventsAsync, () => {
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
@@ -77,14 +81,14 @@ describe(fetchObserveEventsAsync, () => {
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'app-123',
|
||||
filter: {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
name: 'expo.app_startup.tti',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
},
|
||||
first: 10,
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -98,20 +102,20 @@ describe(fetchObserveEventsAsync, () => {
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
limit: 5,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platform: AppObservePlatform.Ios,
|
||||
platforms: [AppObservePlatform.Ios],
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
platform: AppObservePlatform.Ios,
|
||||
platforms: [AppObservePlatform.Ios],
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -126,8 +130,8 @@ describe(fetchObserveEventsAsync, () => {
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
@@ -154,8 +158,8 @@ describe(fetchObserveEventsAsync, () => {
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
@@ -182,8 +186,8 @@ describe(fetchObserveEventsAsync, () => {
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
@@ -199,10 +203,10 @@ describe(fetchObserveEventsAsync, () => {
|
||||
it('returns events and pageInfo from the query result', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
__typename: 'AppObserveMetric' as const,
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
@@ -226,8 +230,8 @@ describe(fetchObserveEventsAsync, () => {
|
||||
const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
@@ -235,7 +239,7 @@ describe(fetchObserveEventsAsync, () => {
|
||||
});
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].metricValue).toBe(1.23);
|
||||
expect(result.events[0].value).toBe(1.23);
|
||||
expect(result.pageInfo.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
@@ -248,8 +252,8 @@ describe(fetchObserveEventsAsync, () => {
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
after: 'cursor-abc',
|
||||
@@ -272,8 +276,8 @@ describe(fetchObserveEventsAsync, () => {
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
@@ -316,7 +320,7 @@ describe(fetchTotalEventCountAsync, () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
'expo.navigation.tti',
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
[target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
)
|
||||
@@ -330,7 +334,7 @@ describe(fetchTotalEventCountAsync, () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
'expo.app_startup.tti',
|
||||
[AppPlatform.Ios],
|
||||
[target(AppObservePlatform.Ios)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { AppObserveAppVersion, AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import { AppObserveAppVersion, AppObservePlatform } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { makeMetricsKey } from '../formatMetrics';
|
||||
import { fetchObserveMetricsAsync } from '../fetchMetrics';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
|
||||
import { ObservePlatformTarget } from '../platforms';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
|
||||
function target(platform: AppObservePlatform): ObservePlatformTarget {
|
||||
return { key: platform, platforms: [platform] };
|
||||
}
|
||||
|
||||
function makeAppVersion(
|
||||
appVersion: string,
|
||||
metrics: Array<{
|
||||
@@ -90,7 +95,7 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
[AppPlatform.Ios],
|
||||
[target(AppObservePlatform.Ios)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
@@ -98,13 +103,13 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'project-123',
|
||||
platform: AppObservePlatform.Ios,
|
||||
platforms: [AppObservePlatform.Ios],
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
metricNames: ['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
});
|
||||
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
|
||||
const metricsForVersion = metricsMap.get(key)!;
|
||||
@@ -137,21 +142,21 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
[target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(2);
|
||||
|
||||
const platforms = mockAppVersionsAsync.mock.calls.map(call => call[1].platform);
|
||||
const platforms = mockAppVersionsAsync.mock.calls.flatMap(call => call[1].platforms);
|
||||
expect(platforms).toContain(AppObservePlatform.Ios);
|
||||
expect(platforms).toContain(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockAppVersionsAsync.mockImplementation(async (_client, { platform }) => {
|
||||
if (platform === AppObservePlatform.Android) {
|
||||
mockAppVersionsAsync.mockImplementation(async (_client, { platforms }) => {
|
||||
if (platforms.includes(AppObservePlatform.Android)) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
return [
|
||||
@@ -177,12 +182,12 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
[target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
const key = makeMetricsKey('2.0.0', AppObservePlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.1,
|
||||
@@ -203,7 +208,7 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
[target(AppObservePlatform.Ios)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
@@ -234,14 +239,14 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.navigation.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
[target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
)
|
||||
).rejects.toBe(gateError);
|
||||
});
|
||||
|
||||
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
|
||||
it('uses the target key in metricsMap keys', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('3.0.0', [
|
||||
{
|
||||
@@ -264,7 +269,7 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Android],
|
||||
[target(AppObservePlatform.Android)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
@@ -295,7 +300,7 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
[target(AppObservePlatform.Ios)],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
@@ -6,15 +6,19 @@ import {
|
||||
AppObserveNavigationRoute,
|
||||
AppObserveNavigationRoutesOrderByField,
|
||||
AppObservePlatform,
|
||||
AppPlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../fetchNavigationRoutes';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
|
||||
import { ObservePlatformTarget } from '../platforms';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
|
||||
function target(platform: AppObservePlatform): ObservePlatformTarget {
|
||||
return { key: platform, platforms: [platform] };
|
||||
}
|
||||
|
||||
function makeRoute(routeName: string): AppObserveNavigationRoute {
|
||||
return {
|
||||
__typename: 'AppObserveNavigationRoute' as const,
|
||||
@@ -41,7 +45,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
targets: [target(AppObservePlatform.Ios)],
|
||||
limit: 25,
|
||||
appVersion: '2.1.0',
|
||||
updateId: 'update-xyz',
|
||||
@@ -53,7 +57,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
expect(call.appId).toBe('project-123');
|
||||
expect(call.first).toBe(25);
|
||||
expect(call.filter).toEqual({
|
||||
platform: AppObservePlatform.Ios,
|
||||
platforms: [AppObservePlatform.Ios],
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
appVersion: '2.1.0',
|
||||
@@ -66,7 +70,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
targets: [target(AppObservePlatform.Ios)],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
@@ -81,7 +85,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
targets: [target(AppObservePlatform.Ios)],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
@@ -96,7 +100,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
targets: [target(AppObservePlatform.Ios)],
|
||||
limit: 50,
|
||||
routeNames: ['/home', '/profile'],
|
||||
});
|
||||
@@ -111,7 +115,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
targets: [target(AppObservePlatform.Ios)],
|
||||
limit: 50,
|
||||
routeNames: [],
|
||||
});
|
||||
@@ -123,7 +127,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
targets: [target(AppObservePlatform.Ios)],
|
||||
limit: 50,
|
||||
after: 'cursor-abc',
|
||||
});
|
||||
@@ -133,7 +137,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
|
||||
it('fans out across multiple platforms and tags each route with its platform', async () => {
|
||||
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
|
||||
if (vars.filter.platform === AppObservePlatform.Android) {
|
||||
if (vars.filter.platforms?.includes(AppObservePlatform.Android)) {
|
||||
return {
|
||||
routes: [makeRoute('/android-home')],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
@@ -151,7 +155,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
{
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios, AppPlatform.Android],
|
||||
targets: [target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
|
||||
limit: 50,
|
||||
}
|
||||
);
|
||||
@@ -159,15 +163,15 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
expect(mockNavigationRoutesAsync).toHaveBeenCalledTimes(2);
|
||||
expect(routes).toHaveLength(2);
|
||||
const byName = new Map(routes.map(r => [r.route.routeName, r.platform]));
|
||||
expect(byName.get('/android-home')).toBe(AppPlatform.Android);
|
||||
expect(byName.get('/ios-home')).toBe(AppPlatform.Ios);
|
||||
expect(pageInfoByPlatform.get(AppPlatform.Ios)?.hasNextPage).toBe(true);
|
||||
expect(pageInfoByPlatform.get(AppPlatform.Android)?.hasNextPage).toBe(false);
|
||||
expect(byName.get('/android-home')).toBe(AppObservePlatform.Android);
|
||||
expect(byName.get('/ios-home')).toBe(AppObservePlatform.Ios);
|
||||
expect(pageInfoByPlatform.get(AppObservePlatform.Ios)?.hasNextPage).toBe(true);
|
||||
expect(pageInfoByPlatform.get(AppObservePlatform.Android)?.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
|
||||
if (vars.filter.platform === AppObservePlatform.Android) {
|
||||
if (vars.filter.platforms?.includes(AppObservePlatform.Android)) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
return {
|
||||
@@ -182,14 +186,14 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
{
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios, AppPlatform.Android],
|
||||
targets: [target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
|
||||
limit: 50,
|
||||
}
|
||||
);
|
||||
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0].platform).toBe(AppPlatform.Ios);
|
||||
expect(pageInfoByPlatform.has(AppPlatform.Android)).toBe(false);
|
||||
expect(routes[0].platform).toBe(AppObservePlatform.Ios);
|
||||
expect(pageInfoByPlatform.has(AppObservePlatform.Android)).toBe(false);
|
||||
});
|
||||
|
||||
it('rethrows plan-gate errors instead of swallowing them as a partial failure', async () => {
|
||||
@@ -214,7 +218,7 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios, AppPlatform.Android],
|
||||
targets: [target(AppObservePlatform.Ios), target(AppObservePlatform.Android)],
|
||||
limit: 50,
|
||||
})
|
||||
).rejects.toBe(gateError);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
AppObserveCustomEvent,
|
||||
AppObserveCustomEventListOrderByField,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObserveError,
|
||||
AppObserveLogsOrderByField,
|
||||
AppObserveMetric,
|
||||
AppObserveMetricsListOrderByField,
|
||||
AppObserveOrderDirection,
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListOrderByField,
|
||||
} from '../../graphql/generated';
|
||||
import { AppObserveSessionLog, ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents';
|
||||
import { fetchObserveEventsAsync } from '../fetchEvents';
|
||||
import {
|
||||
@@ -21,16 +24,20 @@ jest.mock('../fetchEvents', () => {
|
||||
fetchObserveEventsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
|
||||
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
|
||||
const mockSessionEventsAsync = jest.mocked(ObserveQuery.sessionEventsAsync);
|
||||
|
||||
function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
const noNextPage = { hasNextPage: false, hasPreviousPage: false };
|
||||
|
||||
function makeMetricEvent(overrides: Partial<AppObserveMetric> = {}): AppObserveMetric {
|
||||
return {
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
__typename: 'AppObserveMetric' as const,
|
||||
id: 'evt-m-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.5,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 0.5,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
@@ -43,14 +50,14 @@ function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEv
|
||||
easClientId: 'client-1',
|
||||
customParams: null,
|
||||
...overrides,
|
||||
} as AppObserveEvent;
|
||||
} as AppObserveMetric;
|
||||
}
|
||||
|
||||
function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
|
||||
function makeUserEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent' as const,
|
||||
__typename: 'AppObserveUserEvent' as const,
|
||||
id: 'evt-c-1',
|
||||
eventName: 'login_pressed',
|
||||
name: 'login_pressed',
|
||||
timestamp: '2025-01-15T10:01:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
severityNumber: null,
|
||||
@@ -67,7 +74,36 @@ function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObs
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveCustomEvent;
|
||||
} as AppObserveUserEvent;
|
||||
}
|
||||
|
||||
function makeUserLog(overrides: Partial<AppObserveUserEvent> = {}): AppObserveSessionLog {
|
||||
return makeUserEvent(overrides) as AppObserveSessionLog;
|
||||
}
|
||||
|
||||
function makeErrorLog(overrides: Partial<AppObserveError> = {}): AppObserveSessionLog {
|
||||
return {
|
||||
__typename: 'AppObserveError' as const,
|
||||
id: 'err-1',
|
||||
type: 'TypeError',
|
||||
message: 'undefined is not a function',
|
||||
timestamp: '2025-01-15T10:02:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
severityText: 'fatal',
|
||||
severityNumber: null,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveSessionLog;
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
@@ -77,45 +113,47 @@ const baseOptions = {
|
||||
describe('fetchObserveSessionEventsAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
mockSessionEventsAsync.mockResolvedValue({
|
||||
metrics: [],
|
||||
logs: [],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards sessionId to both event sources and orders metric events oldest-first', async () => {
|
||||
it('queries the session timeline oldest-first for both metrics and logs', async () => {
|
||||
await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(mockFetchObserveEventsAsync.mock.calls[0][2].sessionId).toBe('session-1');
|
||||
expect(mockFetchObserveEventsAsync.mock.calls[0][2].orderBy).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].sessionId).toBe('session-1');
|
||||
expect(mockSessionEventsAsync).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
appId: 'project-1',
|
||||
id: 'session-1',
|
||||
first: 100,
|
||||
metricsOrderBy: {
|
||||
field: AppObserveMetricsListOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
},
|
||||
logsOrderBy: {
|
||||
field: AppObserveLogsOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns combined entries sorted chronologically, tagged with their source', async () => {
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [makeMetricEvent({ timestamp: '2025-01-15T10:05:00.000Z' })],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeCustomEvent({ timestamp: '2025-01-15T10:01:00.000Z' }),
|
||||
makeCustomEvent({
|
||||
id: 'evt-c-2',
|
||||
timestamp: '2025-01-15T10:10:00.000Z',
|
||||
eventName: 'logout',
|
||||
}),
|
||||
mockSessionEventsAsync.mockResolvedValue({
|
||||
metrics: [makeMetricEvent({ timestamp: '2025-01-15T10:05:00.000Z' })],
|
||||
logs: [
|
||||
makeUserLog({ timestamp: '2025-01-15T10:01:00.000Z' }),
|
||||
makeUserLog({ id: 'evt-c-2', timestamp: '2025-01-15T10:10:00.000Z', name: 'logout' }),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -129,32 +167,41 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
'2025-01-15T10:10:00.000Z',
|
||||
]);
|
||||
expect(result.entries.map(e => e.source)).toEqual(['log', 'metric', 'log']);
|
||||
expect(result.entries.map(e => e.id)).toEqual(['evt-c-1', 'evt-m-1', 'evt-c-2']);
|
||||
});
|
||||
|
||||
it('includes error logs as log entries named by their exception type, surfacing message and properties', async () => {
|
||||
mockSessionEventsAsync.mockResolvedValue({
|
||||
metrics: [],
|
||||
logs: [
|
||||
makeErrorLog({
|
||||
properties: [{ key: 'attr', value: 'v', type: 'STRING' }],
|
||||
} as Partial<AppObserveError>),
|
||||
],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].source).toBe('log');
|
||||
expect(result.entries[0].name).toBe('TypeError');
|
||||
expect(result.entries[0].properties).toEqual([
|
||||
{ key: 'message', value: 'undefined is not a function', type: 'STRING' },
|
||||
{ key: 'attr', value: 'v', type: 'STRING' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('derives session metadata from the entries (first/last timestamps, device, app version)', async () => {
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeMetricEvent({
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
appUpdateId: 'update-xyz',
|
||||
countryCode: 'US',
|
||||
}),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeCustomEvent({
|
||||
timestamp: '2025-01-15T10:05:00.000Z',
|
||||
appUpdateId: 'update-xyz',
|
||||
}),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
mockSessionEventsAsync.mockResolvedValue({
|
||||
metrics: [makeMetricEvent({ timestamp: '2025-01-15T10:00:00.000Z' })],
|
||||
logs: [makeUserLog({ timestamp: '2025-01-15T10:05:00.000Z', appUpdateId: 'update-xyz' })],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -184,13 +231,11 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
});
|
||||
|
||||
it('reports hasMore* flags from the underlying page info', async () => {
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cm' },
|
||||
});
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
mockSessionEventsAsync.mockResolvedValue({
|
||||
metrics: [],
|
||||
logs: [],
|
||||
metricsPageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cm' },
|
||||
logsPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -208,7 +253,7 @@ describe('fetchSessionMetricCandidatesAsync', () => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
pageInfo: noNextPage,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,8 +272,8 @@ describe('fetchSessionMetricCandidatesAsync', () => {
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
expect(options.orderBy).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveMetricsListOrderByField.Value,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,7 +284,7 @@ describe('fetchSessionMetricCandidatesAsync', () => {
|
||||
makeMetricEvent({ id: 'evt-2', sessionId: null }),
|
||||
makeMetricEvent({ id: 'evt-3', sessionId: 'session-b' }),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
pageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchSessionMetricCandidatesAsync({} as any, 'project-1', {
|
||||
@@ -259,7 +304,7 @@ describe('fetchSessionLogCandidatesAsync', () => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
pageInfo: noNextPage,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -289,8 +334,8 @@ describe('fetchSessionLogCandidatesAsync', () => {
|
||||
});
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].orderBy).toEqual({
|
||||
field: AppObserveCustomEventListOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
field: AppObserveUserEventListOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -304,19 +349,19 @@ describe('fetchSessionLogCandidatesAsync', () => {
|
||||
});
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].orderBy).toEqual({
|
||||
field: AppObserveCustomEventListOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
field: AppObserveUserEventListOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the server-provided order (no client-side re-sorting)', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeCustomEvent({ id: 'c', timestamp: '2025-01-15T10:10:00.000Z' }),
|
||||
makeCustomEvent({ id: 'b', timestamp: '2025-01-15T10:05:00.000Z' }),
|
||||
makeCustomEvent({ id: 'a', timestamp: '2025-01-15T10:00:00.000Z' }),
|
||||
makeUserEvent({ id: 'c', timestamp: '2025-01-15T10:10:00.000Z' }),
|
||||
makeUserEvent({ id: 'b', timestamp: '2025-01-15T10:05:00.000Z' }),
|
||||
makeUserEvent({ id: 'a', timestamp: '2025-01-15T10:00:00.000Z' }),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
pageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
|
||||
@@ -332,10 +377,10 @@ describe('fetchSessionLogCandidatesAsync', () => {
|
||||
it('filters out events without a sessionId', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeCustomEvent({ id: 'a', sessionId: 'session-a' }),
|
||||
makeCustomEvent({ id: 'b', sessionId: null }),
|
||||
makeUserEvent({ id: 'a', sessionId: 'session-a' }),
|
||||
makeUserEvent({ id: 'b', sessionId: null }),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
pageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppObserveCustomEvent, PageInfo } from '../../graphql/generated';
|
||||
import { AppObserveUserEvent, PageInfo } from '../../graphql/generated';
|
||||
import {
|
||||
buildObserveCustomEventNamesTable,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson,
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
buildObserveCustomEventsTable,
|
||||
} from '../formatCustomEvents';
|
||||
|
||||
function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
|
||||
function makeCustomEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent' as const,
|
||||
__typename: 'AppObserveUserEvent' as const,
|
||||
id: 'evt-1',
|
||||
eventName: 'my_event',
|
||||
name: 'my_event',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
@@ -21,7 +21,7 @@ function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObs
|
||||
easClientId: 'client-1',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveCustomEvent;
|
||||
} as AppObserveUserEvent;
|
||||
}
|
||||
|
||||
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
|
||||
@@ -35,7 +35,7 @@ describe(buildObserveCustomEventsTable, () => {
|
||||
it('formats events into aligned columns', () => {
|
||||
const events = [
|
||||
makeCustomEvent({
|
||||
eventName: 'login',
|
||||
name: 'login',
|
||||
appVersion: '1.2.0',
|
||||
appBuildNumber: '42',
|
||||
deviceOs: 'iOS',
|
||||
@@ -46,7 +46,7 @@ describe(buildObserveCustomEventsTable, () => {
|
||||
}),
|
||||
makeCustomEvent({
|
||||
id: 'evt-2',
|
||||
eventName: 'checkout',
|
||||
name: 'checkout',
|
||||
appVersion: '1.1.0',
|
||||
appBuildNumber: '38',
|
||||
deviceOs: 'Android',
|
||||
@@ -73,7 +73,7 @@ Jan 14, 2025, 08:15:00.000 AM checkout 1.1.0 (38) Android 14 Pixel 8 PL
|
||||
});
|
||||
|
||||
it('shows event name in summary header when an event name option is provided', () => {
|
||||
const events = [makeCustomEvent({ eventName: 'login' })];
|
||||
const events = [makeCustomEvent({ name: 'login' })];
|
||||
const output = buildObserveCustomEventsTable(events, noNextPage, {
|
||||
eventName: 'login',
|
||||
daysBack: 30,
|
||||
@@ -137,7 +137,7 @@ Jan 14, 2025, 08:15:00.000 AM checkout 1.1.0 (38) Android 14 Pixel 8 PL
|
||||
});
|
||||
|
||||
it('omits the Event column when an event name option is provided (single-event view)', () => {
|
||||
const events = [makeCustomEvent({ eventName: 'login' })];
|
||||
const events = [makeCustomEvent({ name: 'login' })];
|
||||
const output = buildObserveCustomEventsTable(events, noNextPage, {
|
||||
eventName: 'login',
|
||||
});
|
||||
@@ -212,7 +212,7 @@ describe(buildObserveCustomEventsJson, () => {
|
||||
const events = [
|
||||
makeCustomEvent({
|
||||
id: 'evt-1',
|
||||
eventName: 'login',
|
||||
name: 'login',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
severityText: 'INFO',
|
||||
@@ -239,7 +239,7 @@ describe(buildObserveCustomEventsJson, () => {
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
eventName: 'login',
|
||||
name: 'login',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
severityText: 'INFO',
|
||||
@@ -299,8 +299,8 @@ describe(buildObserveCustomEventNamesTable, () => {
|
||||
|
||||
it('shows event names with counts', () => {
|
||||
const output = buildObserveCustomEventNamesTable([
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'bar', count: 5 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
|
||||
]);
|
||||
expect(output).toContain('foo');
|
||||
expect(output).toContain('10');
|
||||
@@ -310,7 +310,7 @@ describe(buildObserveCustomEventNamesTable, () => {
|
||||
|
||||
it('appends a truncation notice when isTruncated is true', () => {
|
||||
const output = buildObserveCustomEventNamesTable(
|
||||
[{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 }],
|
||||
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
|
||||
{ isTruncated: true }
|
||||
);
|
||||
expect(output).toContain('Result is truncated');
|
||||
@@ -320,8 +320,8 @@ describe(buildObserveCustomEventNamesTable, () => {
|
||||
describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
|
||||
it('shows the filtered event name and the available event names', () => {
|
||||
const output = buildObserveCustomEventsEmptyWithSuggestionsTable('login', [
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'bar', count: 5 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
|
||||
]);
|
||||
|
||||
expect(output).toContain('No events found matching "login"');
|
||||
@@ -342,7 +342,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
|
||||
it('appends a truncation notice when isTruncated is set', () => {
|
||||
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
|
||||
'login',
|
||||
[{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 }],
|
||||
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
|
||||
{ isTruncated: true }
|
||||
);
|
||||
|
||||
@@ -352,7 +352,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
|
||||
it('includes the time range description in the message when provided', () => {
|
||||
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
|
||||
'login',
|
||||
[{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 }],
|
||||
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
|
||||
{ daysBack: 7 }
|
||||
);
|
||||
|
||||
@@ -365,8 +365,8 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsJson, () => {
|
||||
const result = buildObserveCustomEventsEmptyWithSuggestionsJson(
|
||||
'login',
|
||||
[
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'bar', count: 5 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
|
||||
],
|
||||
false
|
||||
);
|
||||
@@ -375,8 +375,8 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsJson, () => {
|
||||
filteredEventName: 'login',
|
||||
events: [],
|
||||
availableEventNames: [
|
||||
{ eventName: 'foo', count: 10 },
|
||||
{ eventName: 'bar', count: 5 },
|
||||
{ name: 'foo', count: 10 },
|
||||
{ name: 'bar', count: 5 },
|
||||
],
|
||||
availableEventNamesIsTruncated: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { AppObserveError, AppObserveErrorGroup, PageInfo } from '../../graphql/generated';
|
||||
import {
|
||||
buildObserveErrorGroupsJson,
|
||||
buildObserveErrorGroupsTable,
|
||||
buildObserveErrorOccurrencesJson,
|
||||
buildObserveErrorOccurrencesTable,
|
||||
} from '../formatErrors';
|
||||
|
||||
function makeErrorGroup(overrides: Partial<AppObserveErrorGroup> = {}): AppObserveErrorGroup {
|
||||
return {
|
||||
__typename: 'AppObserveErrorGroup' as const,
|
||||
fingerprint: 'fp-1',
|
||||
exceptionType: 'TypeError',
|
||||
exceptionMessage: 'undefined is not a function',
|
||||
errorSource: 'js',
|
||||
severity: 'FATAL',
|
||||
isFatal: true,
|
||||
eventCount: 42,
|
||||
uniqueUserCount: 10,
|
||||
affectedSessionCount: 8,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T00:00:00.000Z',
|
||||
platforms: ['IOS'],
|
||||
...overrides,
|
||||
} as AppObserveErrorGroup;
|
||||
}
|
||||
|
||||
function makeErrorOccurrence(overrides: Partial<AppObserveError> = {}): AppObserveError {
|
||||
return {
|
||||
__typename: 'AppObserveError' as const,
|
||||
id: 'err-1',
|
||||
type: 'TypeError',
|
||||
message: 'undefined is not a function',
|
||||
source: 'js',
|
||||
fingerprint: 'fp-1',
|
||||
severityText: 'fatal',
|
||||
severityNumber: null,
|
||||
isFatal: true,
|
||||
stacktrace: 'at foo (app.js:1:1)\nat bar (app.js:2:2)',
|
||||
body: null,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveError;
|
||||
}
|
||||
|
||||
const noNextPage = {
|
||||
__typename: 'PageInfo' as const,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
endCursor: null,
|
||||
} as PageInfo;
|
||||
|
||||
describe(buildObserveErrorGroupsTable, () => {
|
||||
it('renders a placeholder when there are no error groups', () => {
|
||||
expect(buildObserveErrorGroupsTable([])).toContain('No errors found');
|
||||
});
|
||||
|
||||
it('renders the fingerprint, exception type, message, and counts', () => {
|
||||
const output = buildObserveErrorGroupsTable([makeErrorGroup()]);
|
||||
expect(output).toContain('fp-1');
|
||||
expect(output).toContain('TypeError');
|
||||
expect(output).toContain('undefined is not a function');
|
||||
expect(output).toContain('42');
|
||||
expect(output).toContain('--fingerprint');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveErrorOccurrencesTable, () => {
|
||||
it('renders a placeholder when there are no occurrences', () => {
|
||||
const output = buildObserveErrorOccurrencesTable([], noNextPage, { fingerprint: 'fp-1' });
|
||||
expect(output).toContain('No occurrences found');
|
||||
expect(output).toContain('fp-1');
|
||||
});
|
||||
|
||||
it('renders occurrence details including the stack trace', () => {
|
||||
const output = buildObserveErrorOccurrencesTable([makeErrorOccurrence()], noNextPage, {
|
||||
fingerprint: 'fp-1',
|
||||
});
|
||||
expect(output).toContain('Stack trace');
|
||||
expect(output).toContain('at foo (app.js:1:1)');
|
||||
expect(output).toContain('TypeError');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveErrorOccurrencesJson, () => {
|
||||
it('includes the stack trace and properties in the JSON', () => {
|
||||
const result = buildObserveErrorOccurrencesJson([makeErrorOccurrence()], noNextPage);
|
||||
expect(result.occurrences[0].stacktrace).toBe('at foo (app.js:1:1)\nat bar (app.js:2:2)');
|
||||
expect(result.occurrences[0].id).toBe('err-1');
|
||||
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveErrorGroupsJson, () => {
|
||||
it('maps error-group fields into stable JSON keys', () => {
|
||||
const result = buildObserveErrorGroupsJson([makeErrorGroup()], false);
|
||||
expect(result).toEqual({
|
||||
isTruncated: false,
|
||||
errors: [
|
||||
{
|
||||
fingerprint: 'fp-1',
|
||||
type: 'TypeError',
|
||||
message: 'undefined is not a function',
|
||||
source: 'js',
|
||||
severity: 'FATAL',
|
||||
isFatal: true,
|
||||
eventCount: 42,
|
||||
uniqueUserCount: 10,
|
||||
affectedSessionCount: 8,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T00:00:00.000Z',
|
||||
platforms: ['IOS'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { AppObserveEvent, PageInfo } from '../../graphql/generated';
|
||||
import { AppObserveMetric, PageInfo } from '../../graphql/generated';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents';
|
||||
|
||||
function createMockEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
function createMockEvent(overrides: Partial<AppObserveMetric> = {}): AppObserveMetric {
|
||||
return {
|
||||
__typename: 'AppObserveEvent',
|
||||
__typename: 'AppObserveMetric',
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
@@ -35,8 +35,8 @@ describe(buildObserveEventsTable, () => {
|
||||
it('formats events into aligned columns', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 1.23,
|
||||
appVersion: '1.2.0',
|
||||
appBuildNumber: '42',
|
||||
deviceOs: 'iOS',
|
||||
@@ -47,8 +47,8 @@ describe(buildObserveEventsTable, () => {
|
||||
}),
|
||||
createMockEvent({
|
||||
id: 'evt-2',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.85,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 0.85,
|
||||
appVersion: '1.1.0',
|
||||
appBuildNumber: '38',
|
||||
deviceOs: 'Android',
|
||||
@@ -76,7 +76,7 @@ describe(buildObserveEventsTable, () => {
|
||||
});
|
||||
|
||||
it('shows metric name in summary header when options are provided', () => {
|
||||
const events = [createMockEvent({ metricName: 'expo.app_startup.tti' })];
|
||||
const events = [createMockEvent({ name: 'expo.app_startup.tti' })];
|
||||
const output = buildObserveEventsTable(events, noNextPage, {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
daysBack: 30,
|
||||
@@ -126,8 +126,8 @@ describe(buildObserveEventsJson, () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
@@ -146,8 +146,8 @@ describe(buildObserveEventsJson, () => {
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { AppObservePlatform } from '../../graphql/generated';
|
||||
import {
|
||||
type MetricValues,
|
||||
ObserveMetricsMap,
|
||||
@@ -40,7 +40,7 @@ function makeMetricValueWithDefaults(overrides: Partial<MetricValues>): MetricVa
|
||||
describe(buildObserveMetricsTable, () => {
|
||||
it('formats metrics grouped by version with metric columns', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios);
|
||||
const iosKey = makeMetricsKey('1.2.0', AppObservePlatform.Ios);
|
||||
metricsMap.set(
|
||||
iosKey,
|
||||
new Map([
|
||||
@@ -52,7 +52,7 @@ describe(buildObserveMetricsTable, () => {
|
||||
])
|
||||
);
|
||||
|
||||
const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android);
|
||||
const androidKey = makeMetricsKey('1.1.0', AppObservePlatform.Android);
|
||||
metricsMap.set(
|
||||
androidKey,
|
||||
new Map([
|
||||
@@ -84,7 +84,7 @@ describe(buildObserveMetricsTable, () => {
|
||||
|
||||
it('shows - for versions with no matching metric data', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
const key = makeMetricsKey('2.0.0', AppObservePlatform.Ios);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
@@ -108,7 +108,7 @@ describe(buildObserveMetricsTable, () => {
|
||||
describe(buildObserveMetricsJson, () => {
|
||||
it('produces JSON with all stats per metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
@@ -125,7 +125,7 @@ describe(buildObserveMetricsJson, () => {
|
||||
expect(result.versions).toHaveLength(1);
|
||||
expect(result.versions[0]).toEqual({
|
||||
appVersion: '1.0.0',
|
||||
platform: AppPlatform.Ios,
|
||||
platform: AppObservePlatform.Ios,
|
||||
buildNumbers: [],
|
||||
updateIds: [],
|
||||
metrics: {
|
||||
@@ -145,7 +145,7 @@ describe(buildObserveMetricsJson, () => {
|
||||
|
||||
it('produces null values when no observe data matches for a metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('3.0.0', AppPlatform.Android);
|
||||
const key = makeMetricsKey('3.0.0', AppObservePlatform.Android);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
@@ -171,8 +171,8 @@ describe(buildObserveMetricsJson, () => {
|
||||
|
||||
describe(makeMetricsKey, () => {
|
||||
it('creates a key from version and platform', () => {
|
||||
expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS');
|
||||
expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID');
|
||||
expect(makeMetricsKey('1.0.0', AppObservePlatform.Ios)).toBe('1.0.0:IOS');
|
||||
expect(makeMetricsKey('2.0.0', AppObservePlatform.Android)).toBe('2.0.0:ANDROID');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('DEFAULT_STATS_JSON', () => {
|
||||
describe('custom stats parameter', () => {
|
||||
it('table renders only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
@@ -259,7 +259,7 @@ describe('custom stats parameter', () => {
|
||||
|
||||
it("table formats eventCount as integer without 's' suffix", () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
@@ -288,7 +288,7 @@ describe('custom stats parameter', () => {
|
||||
|
||||
it('JSON includes only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
@@ -322,7 +322,7 @@ describe('custom stats parameter', () => {
|
||||
|
||||
it('JSON uses default stats when not specified', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
const key = makeMetricsKey('1.0.0', AppObservePlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { AppObservePlatform } from '../../graphql/generated';
|
||||
import { NavigationRouteWithPlatform } from '../fetchNavigationRoutes';
|
||||
import {
|
||||
buildObserveNavigationRoutesJson,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
function makeRoute(
|
||||
routeName: string,
|
||||
platform: AppPlatform,
|
||||
platform: AppObservePlatform,
|
||||
overrides?: Partial<{
|
||||
coldTtr: { count: number; median: number | null; p90: number | null };
|
||||
warmTtr: { count: number; median: number | null; p90: number | null };
|
||||
@@ -87,11 +87,11 @@ describe(buildObserveNavigationRoutesTable, () => {
|
||||
|
||||
it('formats routes grouped by platform with merged (med + count) cells', () => {
|
||||
const routes = [
|
||||
makeRoute('/home', AppPlatform.Ios),
|
||||
makeRoute('/profile', AppPlatform.Ios, {
|
||||
makeRoute('/home', AppObservePlatform.Ios),
|
||||
makeRoute('/profile', AppObservePlatform.Ios, {
|
||||
coldTtr: { count: 5, median: 0.7, p90: 1.0 },
|
||||
}),
|
||||
makeRoute('/home', AppPlatform.Android),
|
||||
makeRoute('/home', AppObservePlatform.Android),
|
||||
];
|
||||
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
@@ -114,7 +114,7 @@ describe(buildObserveNavigationRoutesTable, () => {
|
||||
});
|
||||
|
||||
it('renders separate columns when count is omitted from stats', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const routes = [makeRoute('/home', AppObservePlatform.Ios)];
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
@@ -129,7 +129,7 @@ describe(buildObserveNavigationRoutesTable, () => {
|
||||
});
|
||||
|
||||
it('renders count-only column when only count stat is requested', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const routes = [makeRoute('/home', AppObservePlatform.Ios)];
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
@@ -142,9 +142,9 @@ describe(buildObserveNavigationRoutesTable, () => {
|
||||
});
|
||||
|
||||
it('shows a next-page hint per platform when hasNextPage is true', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const routes = [makeRoute('/home', AppObservePlatform.Ios)];
|
||||
const pageInfoByPlatform = new Map([
|
||||
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
|
||||
[AppObservePlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
|
||||
]);
|
||||
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
@@ -160,9 +160,9 @@ describe(buildObserveNavigationRoutesTable, () => {
|
||||
|
||||
describe(buildObserveNavigationRoutesJson, () => {
|
||||
it('maps routes to the requested metrics and stats, including pageInfoByPlatform', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const routes = [makeRoute('/home', AppObservePlatform.Ios)];
|
||||
const pageInfoByPlatform = new Map([
|
||||
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
|
||||
[AppObservePlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
|
||||
]);
|
||||
|
||||
const result = buildObserveNavigationRoutesJson(
|
||||
@@ -175,7 +175,7 @@ describe(buildObserveNavigationRoutesJson, () => {
|
||||
expect(result.routes).toEqual([
|
||||
{
|
||||
routeName: '/home',
|
||||
platform: AppPlatform.Ios,
|
||||
platform: AppObservePlatform.Ios,
|
||||
metrics: {
|
||||
'expo.navigation.cold_ttr': { median: 0.5, p90: 0.9, count: 10 },
|
||||
'expo.navigation.tti': { median: 0.45, p90: 0.7, count: 12 },
|
||||
@@ -183,13 +183,13 @@ describe(buildObserveNavigationRoutesJson, () => {
|
||||
},
|
||||
]);
|
||||
expect(result.pageInfoByPlatform).toEqual({
|
||||
[AppPlatform.Ios]: { hasNextPage: true, endCursor: 'cursor-ios' },
|
||||
[AppObservePlatform.Ios]: { hasNextPage: true, endCursor: 'cursor-ios' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for stats when the underlying value is null', () => {
|
||||
const routes = [
|
||||
makeRoute('/home', AppPlatform.Ios, {
|
||||
makeRoute('/home', AppObservePlatform.Ios, {
|
||||
coldTtr: { count: 0, median: null, p90: null },
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppObserveCustomEvent, AppObserveEvent } from '../../graphql/generated';
|
||||
import { AppObserveMetric, AppObserveUserEvent } from '../../graphql/generated';
|
||||
import { SessionEventEntry, SessionMetadata } from '../fetchSessions';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
|
||||
return {
|
||||
source: 'metric',
|
||||
id: 'evt-m-1',
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
@@ -21,8 +22,8 @@ function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEve
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
easClientId: 'client-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.8,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 0.8,
|
||||
customParams: null,
|
||||
...overrides,
|
||||
};
|
||||
@@ -31,6 +32,7 @@ function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEve
|
||||
function makeLogEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
|
||||
return {
|
||||
source: 'log',
|
||||
id: 'evt-c-1',
|
||||
timestamp: '2025-01-15T10:01:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
@@ -41,7 +43,7 @@ function makeLogEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventE
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
easClientId: 'client-1',
|
||||
eventName: 'login_pressed',
|
||||
name: 'login_pressed',
|
||||
severityText: null,
|
||||
severityNumber: null,
|
||||
properties: [],
|
||||
@@ -71,8 +73,8 @@ describe(buildObserveSessionEventsTable, () => {
|
||||
it('appends the routeName to navigation metric rows', () => {
|
||||
const entries = [
|
||||
makeMetricEntry({
|
||||
metricName: 'expo.navigation.tti',
|
||||
metricValue: 0.32,
|
||||
name: 'expo.navigation.tti',
|
||||
value: 0.32,
|
||||
routeName: '/home',
|
||||
}),
|
||||
];
|
||||
@@ -150,7 +152,7 @@ describe(buildObserveSessionEventsTable, () => {
|
||||
|
||||
it('leaves the Value column as "-" for log entries (metric rows keep their value)', () => {
|
||||
const entries = [
|
||||
makeMetricEntry({ metricValue: 0.8 }),
|
||||
makeMetricEntry({ value: 0.8 }),
|
||||
makeLogEntry({
|
||||
properties: [{ key: 'user_id', value: 'abc', type: 'STRING' }],
|
||||
}),
|
||||
@@ -240,6 +242,7 @@ describe(buildObserveSessionEventsJson, () => {
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: true,
|
||||
});
|
||||
expect(result.entries[0].id).toBe('evt-m-1');
|
||||
});
|
||||
|
||||
it('returns null metadata when there were no entries', () => {
|
||||
@@ -259,12 +262,12 @@ describe(shortSessionId, () => {
|
||||
});
|
||||
});
|
||||
|
||||
function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
function makeMetricEvent(overrides: Partial<AppObserveMetric> = {}): AppObserveMetric {
|
||||
return {
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
__typename: 'AppObserveMetric' as const,
|
||||
id: 'evt-m-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
name: 'expo.app_startup.tti',
|
||||
value: 1.23,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
@@ -278,14 +281,14 @@ function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEv
|
||||
customParams: null,
|
||||
routeName: null,
|
||||
...overrides,
|
||||
} as AppObserveEvent;
|
||||
} as AppObserveMetric;
|
||||
}
|
||||
|
||||
function makeCustomEventEvt(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
|
||||
function makeCustomEventEvt(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent' as const,
|
||||
__typename: 'AppObserveUserEvent' as const,
|
||||
id: 'evt-c-1',
|
||||
eventName: 'login_pressed',
|
||||
name: 'login_pressed',
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-abcdefgh1234567890',
|
||||
severityNumber: null,
|
||||
@@ -302,7 +305,7 @@ function makeCustomEventEvt(overrides: Partial<AppObserveCustomEvent> = {}): App
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveCustomEvent;
|
||||
} as AppObserveUserEvent;
|
||||
}
|
||||
|
||||
describe(formatMetricCandidateTitle, () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveCustomEvent,
|
||||
AppObserveCustomEventListFilter,
|
||||
AppObserveCustomEventListOrderBy,
|
||||
AppObservePlatform,
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListFilter,
|
||||
AppObserveUserEventListOrderBy,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
@@ -14,15 +14,17 @@ interface FetchCustomEventsOptions {
|
||||
after?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
platform?: AppObservePlatform;
|
||||
platforms?: AppObservePlatform[];
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
updateId?: string;
|
||||
sessionId?: string;
|
||||
orderBy?: AppObserveCustomEventListOrderBy;
|
||||
environment?: string;
|
||||
orderBy?: AppObserveUserEventListOrderBy;
|
||||
}
|
||||
|
||||
interface FetchCustomEventsResult {
|
||||
events: AppObserveCustomEvent[];
|
||||
events: AppObserveUserEvent[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
@@ -31,14 +33,16 @@ export async function fetchObserveCustomEventsAsync(
|
||||
appId: string,
|
||||
options: FetchCustomEventsOptions
|
||||
): Promise<FetchCustomEventsResult> {
|
||||
const filter: AppObserveCustomEventListFilter = {
|
||||
const filter: AppObserveUserEventListFilter = {
|
||||
...(options.startTime && { startTime: options.startTime }),
|
||||
...(options.endTime && { endTime: options.endTime }),
|
||||
...(options.eventName && { eventName: options.eventName }),
|
||||
...(options.platform && { platform: options.platform }),
|
||||
...(options.eventName && { name: options.eventName }),
|
||||
...(options.platforms?.length && { platforms: options.platforms }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.sessionId && { sessionId: options.sessionId }),
|
||||
...(options.environment && { environment: options.environment }),
|
||||
};
|
||||
|
||||
return await ObserveQuery.customEventListAsync(graphqlClient, {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveError,
|
||||
AppObserveErrorGroup,
|
||||
AppObserveErrorOccurrencesOrderByField,
|
||||
AppObserveErrorSeverity,
|
||||
AppObserveOrderDirection,
|
||||
AppObservePlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
|
||||
export interface FetchObserveErrorGroupsOptions {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platforms?: AppObservePlatform[];
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
updateId?: string;
|
||||
environment?: string;
|
||||
severity?: AppObserveErrorSeverity;
|
||||
}
|
||||
|
||||
export async function fetchObserveErrorGroupsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchObserveErrorGroupsOptions
|
||||
): Promise<{ groups: AppObserveErrorGroup[]; isTruncated: boolean }> {
|
||||
return await ObserveQuery.errorGroupsAsync(graphqlClient, {
|
||||
appId,
|
||||
input: {
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platforms?.length && { platforms: options.platforms }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.environment && { environment: options.environment }),
|
||||
...(options.severity && { severity: options.severity }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface FetchObserveErrorOccurrencesOptions {
|
||||
fingerprint: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platforms?: AppObservePlatform[];
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
updateId?: string;
|
||||
environment?: string;
|
||||
limit: number;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
export async function fetchObserveErrorOccurrencesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchObserveErrorOccurrencesOptions
|
||||
): Promise<{ occurrences: AppObserveError[]; pageInfo: PageInfo }> {
|
||||
return await ObserveQuery.errorOccurrencesAsync(graphqlClient, {
|
||||
appId,
|
||||
filter: {
|
||||
fingerprint: options.fingerprint,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platforms?.length && { platforms: options.platforms }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.environment && { environment: options.environment }),
|
||||
},
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
orderBy: {
|
||||
field: AppObserveErrorOccurrencesOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user