mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb84c2b063 | ||
|
|
d48f931d77 | ||
|
|
99b18a2189 | ||
|
|
9ece20157f |
No files matched your search
@@ -8,10 +8,14 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
|
||||
|
||||
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { Env, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -129,6 +132,11 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_connection_mode',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
||||
@@ -166,7 +174,19 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
// Public docs (EAS workflows pre-packaged-jobs) document
|
||||
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
|
||||
// the uploaded dir.
|
||||
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
const spawnEnv: Env = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
|
||||
// If Android connection mode is to be dadb we need to:
|
||||
// - hide adb before maestro, or it will spin it up,
|
||||
// - kill adb so it's not running.
|
||||
if (inputs.android_connection_mode.value === 'dadb') {
|
||||
const adbOverrideDirectoryPath = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'maestro_tests-adb_override-')
|
||||
);
|
||||
await fs.writeFile(path.join(adbOverrideDirectoryPath, 'adb'), 'exit 1');
|
||||
await asyncResult(spawnAsync('adb', ['kill-server'], { env: spawnEnv }));
|
||||
spawnEnv.PATH = `${adbOverrideDirectoryPath}:${spawnEnv.PATH}`;
|
||||
}
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
|
||||
Generated
+12
@@ -28667,6 +28667,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "expoPackageVersion",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "fingerprintHash",
|
||||
"description": null,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../../../observe/fetchNavigationRoutes';
|
||||
import {
|
||||
buildObserveNavigationRoutesJson,
|
||||
buildObserveNavigationRoutesTable,
|
||||
} from '../../../observe/formatNavigationRoutes';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveRoutes from '../routes';
|
||||
|
||||
jest.mock('../../../observe/fetchNavigationRoutes');
|
||||
jest.mock('../../../observe/formatNavigationRoutes', () => {
|
||||
const actual = jest.requireActual('../../../observe/formatNavigationRoutes');
|
||||
return {
|
||||
...actual,
|
||||
buildObserveNavigationRoutesTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveNavigationRoutesJson: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveNavigationRoutesAsync = jest.mocked(fetchObserveNavigationRoutesAsync);
|
||||
const mockBuildObserveNavigationRoutesTable = jest.mocked(buildObserveNavigationRoutesTable);
|
||||
const mockBuildObserveNavigationRoutesJson = jest.mocked(buildObserveNavigationRoutesJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveRoutes, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveNavigationRoutesAsync.mockResolvedValue({
|
||||
routes: [],
|
||||
pageInfoByPlatform: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveRoutes {
|
||||
const command = new ObserveRoutes(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('queries both platforms by default with all three navigation metric full names', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveNavigationRoutesAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
expect(options.limit).toBe(50);
|
||||
|
||||
const tableCall = mockBuildObserveNavigationRoutesTable.mock.calls[0];
|
||||
expect(tableCall[1]).toEqual([
|
||||
'expo.navigation.cold_ttr',
|
||||
'expo.navigation.warm_ttr',
|
||||
'expo.navigation.tti',
|
||||
]);
|
||||
expect(tableCall[2]).toEqual(['median', 'count']);
|
||||
});
|
||||
|
||||
it('filters platform when --platform ios is provided', async () => {
|
||||
const command = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.platforms).toEqual([AppPlatform.Ios]);
|
||||
});
|
||||
|
||||
it('resolves --metric short aliases to navigation metric full names and deduplicates', async () => {
|
||||
const command = createCommand([
|
||||
'--metric',
|
||||
'cold_ttr',
|
||||
'--metric',
|
||||
'cold_ttr',
|
||||
'--metric',
|
||||
'nav_tti',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveNavigationRoutesTable.mock.calls[0][1]).toEqual([
|
||||
'expo.navigation.cold_ttr',
|
||||
'expo.navigation.tti',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes --limit and --after through to the fetcher', async () => {
|
||||
const command = createCommand(['--limit', '25', '--after', 'cursor-abc']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.limit).toBe(25);
|
||||
expect(options.after).toBe('cursor-abc');
|
||||
});
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes app-version, update-id, and build-number filters', async () => {
|
||||
const command = createCommand([
|
||||
'--app-version',
|
||||
'2.1.0',
|
||||
'--update-id',
|
||||
'update-xyz',
|
||||
'--build-number',
|
||||
'42',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
expect(options.updateId).toBe('update-xyz');
|
||||
expect(options.buildNumber).toBe('42');
|
||||
});
|
||||
|
||||
it('resolves --stat aliases and passes them through', async () => {
|
||||
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveNavigationRoutesTable.mock.calls[0][2]).toEqual(['p90', 'count']);
|
||||
});
|
||||
|
||||
it('uses default JSON stats (median, p90, count) when --json is set without --stat', async () => {
|
||||
const command = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveNavigationRoutesJson.mock.calls[0][2]).toEqual([
|
||||
'median',
|
||||
'p90',
|
||||
'count',
|
||||
]);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigationRoutes';
|
||||
import {
|
||||
NAVIGATION_METRIC_NAMES,
|
||||
NavigationStatKey,
|
||||
buildObserveNavigationRoutesJson,
|
||||
buildObserveNavigationRoutesTable,
|
||||
resolveNavigationStatKey,
|
||||
} from '../../observe/formatNavigationRoutes';
|
||||
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
|
||||
import { allowedPlatformFlagValues, appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_ROUTES_LIMIT = 50;
|
||||
|
||||
const STAT_OPTIONS = ['median', 'med', 'p90', 'count', 'event_count', 'eventCount'];
|
||||
|
||||
const DEFAULT_STATS_TABLE: NavigationStatKey[] = ['median', 'count'];
|
||||
const DEFAULT_STATS_JSON: NavigationStatKey[] = ['median', 'p90', 'count'];
|
||||
|
||||
export default class ObserveRoutes extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
metric: Flags.option({
|
||||
description:
|
||||
'Navigation metric to display (can be specified multiple times). Defaults to all three.',
|
||||
multiple: true,
|
||||
options: Object.keys(NAVIGATION_METRIC_ALIASES),
|
||||
})(),
|
||||
stat: Flags.option({
|
||||
description: 'Statistic to display per metric (can be specified multiple times)',
|
||||
multiple: true,
|
||||
options: STAT_OPTIONS,
|
||||
})(),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_ROUTES_LIMIT,
|
||||
limit: 200,
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show routes from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'app-version': Flags.string({
|
||||
description: 'Filter by app version',
|
||||
}),
|
||||
'update-id': Flags.string({
|
||||
description: 'Filter by EAS update ID',
|
||||
}),
|
||||
'build-number': Flags.string({
|
||||
description: 'Filter by app build number',
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...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(ObserveRoutes);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveRoutes,
|
||||
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
const metricNames = flags.metric?.length
|
||||
? Array.from(new Set(flags.metric.map(resolveNavigationMetricName)))
|
||||
: NAVIGATION_METRIC_NAMES;
|
||||
|
||||
const argumentsStat = flags.stat?.length
|
||||
? Array.from(new Set(flags.stat.map(resolveNavigationStatKey)))
|
||||
: undefined;
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
|
||||
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
{
|
||||
startTime,
|
||||
endTime,
|
||||
platforms,
|
||||
limit: flags.limit ?? DEFAULT_ROUTES_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
buildNumber: flags['build-number'],
|
||||
}
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
const stats = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(
|
||||
buildObserveNavigationRoutesJson(routes, metricNames, stats, pageInfoByPlatform)
|
||||
);
|
||||
} else {
|
||||
const stats = argumentsStat ?? DEFAULT_STATS_TABLE;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveNavigationRoutesTable(routes, metricNames, stats, {
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
pageInfoByPlatform,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -4029,6 +4029,7 @@ export type BuildMetadataInput = {
|
||||
developmentClient?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
distribution?: InputMaybe<DistributionType>;
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
expoPackageVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
fingerprintHash?: InputMaybe<Scalars['String']['input']>;
|
||||
fingerprintSource?: InputMaybe<FingerprintSourceInput>;
|
||||
gitCommitHash?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -12903,6 +12904,17 @@ export type AppObserveCustomEventNamesQueryVariables = Exact<{
|
||||
|
||||
export type AppObserveCustomEventNamesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', customEventNames: { __typename?: 'AppObserveCustomEventNames', isTruncated: boolean, names: Array<{ __typename?: 'AppObserveCustomEventName', eventName: string, count: number }> } } } } };
|
||||
|
||||
export type AppObserveNavigationRoutesQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
filter: AppObserveNavigationRoutesFilter;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveNavigationRoutesOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveNavigationRoutesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', navigationRoutes: { __typename?: 'AppObserveNavigationRoutesConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveNavigationRouteEdge', cursor: string, node: { __typename?: 'AppObserveNavigationRoute', routeName: string, coldTtr: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null }, warmTtr: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null }, tti: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null } } }> } } } } };
|
||||
|
||||
export type GetAssetMetadataQueryVariables = Exact<{
|
||||
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObserveNavigationRoute,
|
||||
AppObserveNavigationRoutesFilter,
|
||||
AppObserveNavigationRoutesOrderBy,
|
||||
AppObservePlatform,
|
||||
AppObserveReleasesInput,
|
||||
AppObserveTimeSeriesInput,
|
||||
@@ -133,6 +136,31 @@ type AppObserveCustomEventNamesQueryVariables = {
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
type AppObserveNavigationRoutesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
navigationRoutes: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveNavigationRoute;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveNavigationRoutesQueryVariables = {
|
||||
appId: string;
|
||||
filter: AppObserveNavigationRoutesFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveNavigationRoutesOrderBy;
|
||||
};
|
||||
|
||||
export const ObserveQuery = {
|
||||
async timeSeriesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -398,4 +426,68 @@ export const ObserveQuery = {
|
||||
|
||||
return data.app.byId.observe.customEventNames;
|
||||
},
|
||||
|
||||
async navigationRoutesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveNavigationRoutesQueryVariables
|
||||
): Promise<{ routes: AppObserveNavigationRoute[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveNavigationRoutesQuery, AppObserveNavigationRoutesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveNavigationRoutes(
|
||||
$appId: String!
|
||||
$filter: AppObserveNavigationRoutesFilter!
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveNavigationRoutesOrderBy
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.navigationRoutes;
|
||||
return {
|
||||
routes: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveNavigationRoute,
|
||||
AppObserveNavigationRoutesOrderByField,
|
||||
AppObservePlatform,
|
||||
AppPlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../fetchNavigationRoutes';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
|
||||
function makeRoute(routeName: string): AppObserveNavigationRoute {
|
||||
return {
|
||||
__typename: 'AppObserveNavigationRoute' as const,
|
||||
routeName,
|
||||
coldTtr: { __typename: 'AppObserveNavigationStat' as const, count: 10, median: 0.5, p90: 0.9 },
|
||||
warmTtr: { __typename: 'AppObserveNavigationStat' as const, count: 8, median: 0.3, p90: 0.6 },
|
||||
tti: { __typename: 'AppObserveNavigationStat' as const, count: 12, median: 0.45, p90: 0.7 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
const mockNavigationRoutesAsync = jest.mocked(ObserveQuery.navigationRoutesAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigationRoutesAsync.mockClear();
|
||||
mockNavigationRoutesAsync.mockResolvedValue({
|
||||
routes: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes appId, time range, limit, and filter values for each platform', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 25,
|
||||
appVersion: '2.1.0',
|
||||
updateId: 'update-xyz',
|
||||
buildNumber: '42',
|
||||
});
|
||||
|
||||
expect(mockNavigationRoutesAsync).toHaveBeenCalledTimes(1);
|
||||
const call = mockNavigationRoutesAsync.mock.calls[0][1];
|
||||
expect(call.appId).toBe('project-123');
|
||||
expect(call.first).toBe(25);
|
||||
expect(call.filter).toEqual({
|
||||
platform: AppObservePlatform.Ios,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
appVersion: '2.1.0',
|
||||
appUpdateId: 'update-xyz',
|
||||
appBuildNumber: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults orderBy to NAVIGATION_COUNT DESC', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const call = mockNavigationRoutesAsync.mock.calls[0][1];
|
||||
expect(call.orderBy).toEqual({
|
||||
field: AppObserveNavigationRoutesOrderByField.NavigationCount,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send optional filters when not provided', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const filter = mockNavigationRoutesAsync.mock.calls[0][1].filter;
|
||||
expect(filter).not.toHaveProperty('appVersion');
|
||||
expect(filter).not.toHaveProperty('appUpdateId');
|
||||
expect(filter).not.toHaveProperty('appBuildNumber');
|
||||
});
|
||||
|
||||
it('forwards after cursor when provided', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 50,
|
||||
after: 'cursor-abc',
|
||||
});
|
||||
|
||||
expect(mockNavigationRoutesAsync.mock.calls[0][1].after).toBe('cursor-abc');
|
||||
});
|
||||
|
||||
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) {
|
||||
return {
|
||||
routes: [makeRoute('/android-home')],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
};
|
||||
}
|
||||
return {
|
||||
routes: [makeRoute('/ios-home')],
|
||||
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'ios-cursor' },
|
||||
};
|
||||
});
|
||||
|
||||
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
{
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios, AppPlatform.Android],
|
||||
limit: 50,
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
|
||||
if (vars.filter.platform === AppObservePlatform.Android) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
return {
|
||||
routes: [makeRoute('/home')],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
};
|
||||
});
|
||||
|
||||
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
{
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios, AppPlatform.Android],
|
||||
limit: 50,
|
||||
}
|
||||
);
|
||||
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0].platform).toBe(AppPlatform.Ios);
|
||||
expect(pageInfoByPlatform.has(AppPlatform.Android)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -71,12 +71,12 @@ describe(buildObserveMetricsTable, () => {
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- ----------
|
||||
1.2.0 0.35s (110) 1.32s (90)
|
||||
[1mApp Version Cold Launch Startup TTI[22m
|
||||
----------- ----------- -----------
|
||||
1.2.0 0.35s (110) 1.32s (90)
|
||||
|
||||
[1mAndroid[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
[1mApp Version Cold Launch Startup TTI[22m
|
||||
----------- ----------- -----------
|
||||
1.1.0 0.25s (120) 1.12s (100)"
|
||||
`);
|
||||
@@ -93,9 +93,9 @@ describe(buildObserveMetricsTable, () => {
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- -----
|
||||
2.0.0 - (-) - (-)"
|
||||
[1mApp Version Cold Launch Startup TTI[22m
|
||||
----------- ----------- -----------
|
||||
2.0.0 - (-) - (-) "
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { NavigationRouteWithPlatform } from '../fetchNavigationRoutes';
|
||||
import {
|
||||
buildObserveNavigationRoutesJson,
|
||||
buildObserveNavigationRoutesTable,
|
||||
isNavigationMetric,
|
||||
resolveNavigationStatKey,
|
||||
} from '../formatNavigationRoutes';
|
||||
|
||||
function makeRoute(
|
||||
routeName: string,
|
||||
platform: AppPlatform,
|
||||
overrides?: Partial<{
|
||||
coldTtr: { count: number; median: number | null; p90: number | null };
|
||||
warmTtr: { count: number; median: number | null; p90: number | null };
|
||||
tti: { count: number; median: number | null; p90: number | null };
|
||||
}>
|
||||
): NavigationRouteWithPlatform {
|
||||
return {
|
||||
platform,
|
||||
route: {
|
||||
__typename: 'AppObserveNavigationRoute' as const,
|
||||
routeName,
|
||||
coldTtr: {
|
||||
__typename: 'AppObserveNavigationStat' as const,
|
||||
count: 10,
|
||||
median: 0.5,
|
||||
p90: 0.9,
|
||||
...overrides?.coldTtr,
|
||||
},
|
||||
warmTtr: {
|
||||
__typename: 'AppObserveNavigationStat' as const,
|
||||
count: 8,
|
||||
median: 0.3,
|
||||
p90: 0.6,
|
||||
...overrides?.warmTtr,
|
||||
},
|
||||
tti: {
|
||||
__typename: 'AppObserveNavigationStat' as const,
|
||||
count: 12,
|
||||
median: 0.45,
|
||||
p90: 0.7,
|
||||
...overrides?.tti,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(isNavigationMetric, () => {
|
||||
it('returns true for each known navigation metric full name', () => {
|
||||
expect(isNavigationMetric('expo.navigation.cold_ttr')).toBe(true);
|
||||
expect(isNavigationMetric('expo.navigation.warm_ttr')).toBe(true);
|
||||
expect(isNavigationMetric('expo.navigation.tti')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for app-startup metrics or unknown names', () => {
|
||||
expect(isNavigationMetric('expo.app_startup.tti')).toBe(false);
|
||||
expect(isNavigationMetric('cold_ttr')).toBe(false);
|
||||
expect(isNavigationMetric('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveNavigationStatKey, () => {
|
||||
it('resolves aliases', () => {
|
||||
expect(resolveNavigationStatKey('med')).toBe('median');
|
||||
expect(resolveNavigationStatKey('median')).toBe('median');
|
||||
expect(resolveNavigationStatKey('p90')).toBe('p90');
|
||||
expect(resolveNavigationStatKey('count')).toBe('count');
|
||||
expect(resolveNavigationStatKey('eventCount')).toBe('count');
|
||||
expect(resolveNavigationStatKey('event_count')).toBe('count');
|
||||
});
|
||||
|
||||
it('throws on unknown stat', () => {
|
||||
expect(() => resolveNavigationStatKey('min')).toThrow('Unknown statistic');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveNavigationRoutesTable, () => {
|
||||
it('returns a yellow warning when no routes are present', () => {
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
[],
|
||||
['expo.navigation.cold_ttr'],
|
||||
['median', 'count']
|
||||
);
|
||||
expect(output).toContain('No navigation routes found.');
|
||||
});
|
||||
|
||||
it('formats routes grouped by platform with merged (med + count) cells', () => {
|
||||
const routes = [
|
||||
makeRoute('/home', AppPlatform.Ios),
|
||||
makeRoute('/profile', AppPlatform.Ios, {
|
||||
coldTtr: { count: 5, median: 0.7, p90: 1.0 },
|
||||
}),
|
||||
makeRoute('/home', AppPlatform.Android),
|
||||
];
|
||||
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr', 'expo.navigation.warm_ttr', 'expo.navigation.tti'],
|
||||
['median', 'count'],
|
||||
{ daysBack: 7 }
|
||||
);
|
||||
|
||||
expect(output).toContain('Med values (navigation count) for the last 7 days');
|
||||
expect(output).toContain('Cold TTR');
|
||||
expect(output).toContain('Warm TTR');
|
||||
expect(output).toContain('Nav TTI');
|
||||
expect(output).toContain('/home');
|
||||
expect(output).toContain('/profile');
|
||||
expect(output).toContain('0.50s (10)');
|
||||
expect(output).toContain('0.70s (5)');
|
||||
expect(output).toContain('iOS');
|
||||
expect(output).toContain('Android');
|
||||
});
|
||||
|
||||
it('renders separate columns when count is omitted from stats', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
['median', 'p90']
|
||||
);
|
||||
|
||||
expect(output).toContain('Med, P90 values');
|
||||
expect(output).toContain('Cold TTR Med');
|
||||
expect(output).toContain('Cold TTR P90');
|
||||
expect(output).toContain('0.50s');
|
||||
expect(output).toContain('0.90s');
|
||||
});
|
||||
|
||||
it('renders count-only column when only count stat is requested', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
['count']
|
||||
);
|
||||
|
||||
expect(output).toContain('Navigation count values');
|
||||
expect(output).toContain('Cold TTR Count');
|
||||
expect(output).toContain('10');
|
||||
});
|
||||
|
||||
it('shows a next-page hint per platform when hasNextPage is true', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const pageInfoByPlatform = new Map([
|
||||
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
|
||||
]);
|
||||
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
['median', 'count'],
|
||||
{ pageInfoByPlatform }
|
||||
);
|
||||
|
||||
expect(output).toContain('Next page (iOS): --after cursor-ios');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveNavigationRoutesJson, () => {
|
||||
it('maps routes to the requested metrics and stats, including pageInfoByPlatform', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const pageInfoByPlatform = new Map([
|
||||
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
|
||||
]);
|
||||
|
||||
const result = buildObserveNavigationRoutesJson(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr', 'expo.navigation.tti'],
|
||||
['median', 'p90', 'count'],
|
||||
pageInfoByPlatform
|
||||
);
|
||||
|
||||
expect(result.routes).toEqual([
|
||||
{
|
||||
routeName: '/home',
|
||||
platform: AppPlatform.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 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.pageInfoByPlatform).toEqual({
|
||||
[AppPlatform.Ios]: { hasNextPage: true, endCursor: 'cursor-ios' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for stats when the underlying value is null', () => {
|
||||
const routes = [
|
||||
makeRoute('/home', AppPlatform.Ios, {
|
||||
coldTtr: { count: 0, median: null, p90: null },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveNavigationRoutesJson(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
['median', 'p90', 'count'],
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(result.routes[0].metrics['expo.navigation.cold_ttr']).toEqual({
|
||||
median: null,
|
||||
p90: null,
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
|
||||
import {
|
||||
getMetricDisplayName,
|
||||
resolveMetricName,
|
||||
resolveNavigationMetricName,
|
||||
} from '../metricNames';
|
||||
|
||||
describe(resolveMetricName, () => {
|
||||
it('resolves short alias "tti" to full metric name', () => {
|
||||
@@ -37,15 +41,46 @@ describe(resolveMetricName, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveNavigationMetricName, () => {
|
||||
it('resolves short aliases to navigation metric full names', () => {
|
||||
expect(resolveNavigationMetricName('cold_ttr')).toBe('expo.navigation.cold_ttr');
|
||||
expect(resolveNavigationMetricName('warm_ttr')).toBe('expo.navigation.warm_ttr');
|
||||
expect(resolveNavigationMetricName('nav_tti')).toBe('expo.navigation.tti');
|
||||
});
|
||||
|
||||
it('passes through full navigation metric names unchanged', () => {
|
||||
expect(resolveNavigationMetricName('expo.navigation.cold_ttr')).toBe(
|
||||
'expo.navigation.cold_ttr'
|
||||
);
|
||||
expect(resolveNavigationMetricName('expo.navigation.warm_ttr')).toBe(
|
||||
'expo.navigation.warm_ttr'
|
||||
);
|
||||
expect(resolveNavigationMetricName('expo.navigation.tti')).toBe('expo.navigation.tti');
|
||||
});
|
||||
|
||||
it('throws on unknown alias or non-navigation metric', () => {
|
||||
expect(() => resolveNavigationMetricName('unknown')).toThrow('Unknown navigation metric');
|
||||
expect(() => resolveNavigationMetricName('expo.app_startup.tti')).toThrow(
|
||||
'Unknown navigation metric'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getMetricDisplayName, () => {
|
||||
it('returns short display name for known metrics', () => {
|
||||
it('returns short display name for known app-startup metrics', () => {
|
||||
expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI');
|
||||
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR');
|
||||
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('Startup TTI');
|
||||
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('Startup TTR');
|
||||
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
|
||||
});
|
||||
|
||||
it('returns short display name for known navigation metrics', () => {
|
||||
expect(getMetricDisplayName('expo.navigation.cold_ttr')).toBe('Nav Cold TTR');
|
||||
expect(getMetricDisplayName('expo.navigation.warm_ttr')).toBe('Nav Warm TTR');
|
||||
expect(getMetricDisplayName('expo.navigation.tti')).toBe('Nav TTI');
|
||||
});
|
||||
|
||||
it('returns the full metric name for unknown metrics', () => {
|
||||
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppObservePlatform, AppPlatform } from '../graphql/generated';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
UpdateIdsMap,
|
||||
makeMetricsKey,
|
||||
} from './formatMetrics';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
import { appPlatformToObservePlatform } from './platforms';
|
||||
|
||||
export function validateDateFlag(value: string, flagName: string): void {
|
||||
const parsed = new Date(value);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveNavigationRoute,
|
||||
AppObserveNavigationRoutesOrderBy,
|
||||
AppObserveNavigationRoutesOrderByField,
|
||||
AppPlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import { appPlatformToObservePlatform } from './platforms';
|
||||
|
||||
export interface NavigationRouteWithPlatform {
|
||||
platform: AppPlatform;
|
||||
route: AppObserveNavigationRoute;
|
||||
}
|
||||
|
||||
export interface FetchNavigationRoutesOptions {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platforms: AppPlatform[];
|
||||
limit: number;
|
||||
after?: string;
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
buildNumber?: string;
|
||||
orderBy?: AppObserveNavigationRoutesOrderBy;
|
||||
}
|
||||
|
||||
export interface FetchNavigationRoutesResult {
|
||||
routes: NavigationRouteWithPlatform[];
|
||||
pageInfoByPlatform: Map<AppPlatform, PageInfo>;
|
||||
}
|
||||
|
||||
export async function fetchObserveNavigationRoutesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchNavigationRoutesOptions
|
||||
): Promise<FetchNavigationRoutesResult> {
|
||||
const orderBy: AppObserveNavigationRoutesOrderBy = options.orderBy ?? {
|
||||
field: AppObserveNavigationRoutesOrderByField.NavigationCount,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
|
||||
const queries = options.platforms.map(async appPlatform => {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
try {
|
||||
const result = await ObserveQuery.navigationRoutesAsync(graphqlClient, {
|
||||
appId,
|
||||
filter: {
|
||||
platform: observePlatform,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
|
||||
},
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
orderBy,
|
||||
});
|
||||
return { appPlatform, ...result };
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to fetch navigation routes on ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
|
||||
const routes: NavigationRouteWithPlatform[] = [];
|
||||
const pageInfoByPlatform = new Map<AppPlatform, PageInfo>();
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
pageInfoByPlatform.set(result.appPlatform, result.pageInfo);
|
||||
for (const route of result.routes) {
|
||||
routes.push({ platform: result.appPlatform, route });
|
||||
}
|
||||
}
|
||||
|
||||
return { routes, pageInfoByPlatform };
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
import { NavigationRouteWithPlatform } from './fetchNavigationRoutes';
|
||||
import { buildTimeRangeDescription } from './formatUtils';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
export type NavigationStatKey = 'median' | 'p90' | 'count';
|
||||
|
||||
export const NAVIGATION_STAT_ALIASES: Record<string, NavigationStatKey> = {
|
||||
med: 'median',
|
||||
median: 'median',
|
||||
p90: 'p90',
|
||||
count: 'count',
|
||||
event_count: 'count',
|
||||
eventCount: 'count',
|
||||
};
|
||||
|
||||
export const NAVIGATION_STAT_DISPLAY_NAMES: Record<NavigationStatKey, string> = {
|
||||
median: 'Med',
|
||||
p90: 'P90',
|
||||
count: 'Count',
|
||||
};
|
||||
|
||||
export function resolveNavigationStatKey(input: string): NavigationStatKey {
|
||||
const resolved = NAVIGATION_STAT_ALIASES[input];
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown statistic: "${input}". Valid options: ${Object.keys(NAVIGATION_STAT_ALIASES).join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
type NavigationRouteField = 'coldTtr' | 'warmTtr' | 'tti';
|
||||
|
||||
const FULL_METRIC_NAME_TO_FIELD: Record<string, NavigationRouteField> = {
|
||||
'expo.navigation.cold_ttr': 'coldTtr',
|
||||
'expo.navigation.warm_ttr': 'warmTtr',
|
||||
'expo.navigation.tti': 'tti',
|
||||
};
|
||||
|
||||
export const NAVIGATION_METRIC_NAMES = Object.keys(FULL_METRIC_NAME_TO_FIELD);
|
||||
|
||||
export function isNavigationMetric(metricName: string): boolean {
|
||||
return metricName in FULL_METRIC_NAME_TO_FIELD;
|
||||
}
|
||||
|
||||
function formatStatValue(stat: NavigationStatKey, value: number | null | undefined): string {
|
||||
if (value == null) {
|
||||
return '-';
|
||||
}
|
||||
if (stat === 'count') {
|
||||
return String(value);
|
||||
}
|
||||
return `${value.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function formatMergedCell(
|
||||
stat: NavigationStatKey,
|
||||
statValue: number | null | undefined,
|
||||
count: number | null | undefined
|
||||
): string {
|
||||
const formatted = formatStatValue(stat, statValue);
|
||||
const countStr = count != null ? String(count) : '-';
|
||||
return `${formatted} (${countStr})`;
|
||||
}
|
||||
|
||||
export interface NavigationRouteValuesJson {
|
||||
routeName: string;
|
||||
platform: AppPlatform;
|
||||
metrics: Record<string, Partial<Record<NavigationStatKey, number | null>>>;
|
||||
}
|
||||
|
||||
export interface ObserveNavigationRoutesJsonOutput {
|
||||
routes: NavigationRouteValuesJson[];
|
||||
pageInfoByPlatform: Record<string, { hasNextPage: boolean; endCursor: string | null }>;
|
||||
}
|
||||
|
||||
function metricStat(
|
||||
node: NavigationRouteWithPlatform,
|
||||
metricName: string
|
||||
): { count: number; median: number | null; p90: number | null } {
|
||||
const field = FULL_METRIC_NAME_TO_FIELD[metricName];
|
||||
if (!field) {
|
||||
throw new EasCommandError(`Unknown navigation metric: "${metricName}"`);
|
||||
}
|
||||
const stat = node.route[field];
|
||||
return {
|
||||
count: stat.count,
|
||||
median: stat.median ?? null,
|
||||
p90: stat.p90 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildObserveNavigationRoutesJson(
|
||||
routes: NavigationRouteWithPlatform[],
|
||||
metricNames: string[],
|
||||
stats: NavigationStatKey[],
|
||||
pageInfoByPlatform: Map<AppPlatform, { hasNextPage: boolean; endCursor?: string | null }>
|
||||
): ObserveNavigationRoutesJsonOutput {
|
||||
const jsonRoutes: NavigationRouteValuesJson[] = routes.map(node => {
|
||||
const metrics: Record<string, Partial<Record<NavigationStatKey, number | null>>> = {};
|
||||
for (const metricName of metricNames) {
|
||||
const values = metricStat(node, metricName);
|
||||
const statValues: Partial<Record<NavigationStatKey, number | null>> = {};
|
||||
for (const stat of stats) {
|
||||
statValues[stat] = values[stat] ?? null;
|
||||
}
|
||||
metrics[metricName] = statValues;
|
||||
}
|
||||
return {
|
||||
routeName: node.route.routeName,
|
||||
platform: node.platform,
|
||||
metrics,
|
||||
};
|
||||
});
|
||||
|
||||
const pageInfoByPlatformOutput: Record<
|
||||
string,
|
||||
{ hasNextPage: boolean; endCursor: string | null }
|
||||
> = {};
|
||||
for (const [platform, pageInfo] of pageInfoByPlatform) {
|
||||
pageInfoByPlatformOutput[platform] = {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return { routes: jsonRoutes, pageInfoByPlatform: pageInfoByPlatformOutput };
|
||||
}
|
||||
|
||||
export interface BuildNavigationRoutesTableOptions {
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
pageInfoByPlatform?: Map<AppPlatform, { hasNextPage: boolean; endCursor?: string | null }>;
|
||||
}
|
||||
|
||||
export function buildObserveNavigationRoutesTable(
|
||||
routes: NavigationRouteWithPlatform[],
|
||||
metricNames: string[],
|
||||
stats: NavigationStatKey[],
|
||||
options?: BuildNavigationRoutesTableOptions
|
||||
): string {
|
||||
if (routes.length === 0) {
|
||||
return chalk.yellow('No navigation routes found.');
|
||||
}
|
||||
|
||||
const displayStats = stats.filter(s => s !== 'count');
|
||||
const hasCount = stats.includes('count');
|
||||
|
||||
const statsDesc =
|
||||
displayStats.length > 0
|
||||
? displayStats.map(s => NAVIGATION_STAT_DISPLAY_NAMES[s]).join(', ')
|
||||
: 'Navigation count';
|
||||
const timeDesc = buildTimeRangeDescription({
|
||||
daysBack: options?.daysBack,
|
||||
startTime: options?.startTime,
|
||||
endTime: options?.endTime,
|
||||
});
|
||||
const countSuffix = hasCount && displayStats.length > 0 ? ' (navigation count)' : '';
|
||||
const summaryLine = `${statsDesc} values${countSuffix}${timeDesc ? ` ${timeDesc}` : ''}`;
|
||||
|
||||
const byPlatform = new Map<AppPlatform, NavigationRouteWithPlatform[]>();
|
||||
for (const node of routes) {
|
||||
if (!byPlatform.has(node.platform)) {
|
||||
byPlatform.set(node.platform, []);
|
||||
}
|
||||
byPlatform.get(node.platform)!.push(node);
|
||||
}
|
||||
|
||||
const metricHeaders: string[] = [];
|
||||
for (const metricName of metricNames) {
|
||||
const displayName = getMetricDisplayName(metricName);
|
||||
if (displayStats.length > 0 && hasCount) {
|
||||
for (const stat of displayStats) {
|
||||
metricHeaders.push(
|
||||
displayStats.length > 1
|
||||
? `${displayName} ${NAVIGATION_STAT_DISPLAY_NAMES[stat]}`
|
||||
: displayName
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const stat of displayStats.length > 0
|
||||
? displayStats
|
||||
: (['count'] as NavigationStatKey[])) {
|
||||
metricHeaders.push(
|
||||
displayStats.length === 0
|
||||
? `${displayName} Count`
|
||||
: `${displayName} ${NAVIGATION_STAT_DISPLAY_NAMES[stat]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const headers = ['Route', ...metricHeaders];
|
||||
|
||||
const sections: string[] = [chalk.bold(summaryLine)];
|
||||
|
||||
for (const [platform, platformRoutes] of byPlatform) {
|
||||
sections.push('');
|
||||
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
|
||||
|
||||
const rows: string[][] = platformRoutes.map(node => {
|
||||
const cells: string[] = [];
|
||||
for (const metricName of metricNames) {
|
||||
const values = metricStat(node, metricName);
|
||||
if (displayStats.length > 0 && hasCount) {
|
||||
for (const stat of displayStats) {
|
||||
cells.push(formatMergedCell(stat, values[stat], values.count));
|
||||
}
|
||||
} else {
|
||||
for (const stat of displayStats.length > 0
|
||||
? displayStats
|
||||
: (['count'] as NavigationStatKey[])) {
|
||||
cells.push(formatStatValue(stat, values[stat]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [node.route.routeName, ...cells];
|
||||
});
|
||||
|
||||
sections.push(renderTextTable(headers, rows));
|
||||
|
||||
const pageInfo = options?.pageInfoByPlatform?.get(platform);
|
||||
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
|
||||
sections.push(
|
||||
`Next page (${appPlatformDisplayNames[platform]}): --after ${pageInfo.endCursor}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
@@ -8,14 +8,24 @@ export const METRIC_ALIASES: Record<string, string> = {
|
||||
bundle_load: 'expo.app_startup.bundle_load_time',
|
||||
};
|
||||
|
||||
export const NAVIGATION_METRIC_ALIASES: Record<string, string> = {
|
||||
cold_ttr: 'expo.navigation.cold_ttr',
|
||||
warm_ttr: 'expo.navigation.warm_ttr',
|
||||
nav_tti: 'expo.navigation.tti',
|
||||
};
|
||||
|
||||
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
|
||||
const KNOWN_FULL_NAVIGATION_NAMES = new Set(Object.values(NAVIGATION_METRIC_ALIASES));
|
||||
|
||||
export const METRIC_SHORT_NAMES: Record<string, string> = {
|
||||
'expo.app_startup.cold_launch_time': 'Cold Launch',
|
||||
'expo.app_startup.warm_launch_time': 'Warm Launch',
|
||||
'expo.app_startup.tti': 'TTI',
|
||||
'expo.app_startup.ttr': 'TTR',
|
||||
'expo.app_startup.tti': 'Startup TTI',
|
||||
'expo.app_startup.ttr': 'Startup TTR',
|
||||
'expo.app_startup.bundle_load_time': 'Bundle Load',
|
||||
'expo.navigation.cold_ttr': 'Nav Cold TTR',
|
||||
'expo.navigation.warm_ttr': 'Nav Warm TTR',
|
||||
'expo.navigation.tti': 'Nav TTI',
|
||||
};
|
||||
|
||||
export function resolveMetricName(input: string): string {
|
||||
@@ -30,6 +40,20 @@ export function resolveMetricName(input: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveNavigationMetricName(input: string): string {
|
||||
if (NAVIGATION_METRIC_ALIASES[input]) {
|
||||
return NAVIGATION_METRIC_ALIASES[input];
|
||||
}
|
||||
if (KNOWN_FULL_NAVIGATION_NAMES.has(input)) {
|
||||
return input;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown navigation metric: "${input}". Use a full metric name (e.g. expo.navigation.cold_ttr) or a short alias: ${Object.keys(
|
||||
NAVIGATION_METRIC_ALIASES
|
||||
).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getMetricDisplayName(metricName: string): string {
|
||||
return METRIC_SHORT_NAMES[metricName] ?? metricName;
|
||||
}
|
||||
@@ -51,3 +51,8 @@ export function appPlatformsFromFlag(flag: PlatformFlagValue | undefined): AppPl
|
||||
}
|
||||
return [defaultAppPlatform];
|
||||
}
|
||||
|
||||
export const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
@@ -1,75 +1,12 @@
|
||||
import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
Platform,
|
||||
Workflow,
|
||||
errors,
|
||||
} from '@expo/eas-build-job';
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { build } from '../build';
|
||||
import { createBuildContext } from '../context';
|
||||
import BuildService, { getExpoPackageVersionAsync } from '../service';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
import { getExpoPackageVersionAsync } from '../service';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../build', () => ({
|
||||
build: jest.fn(),
|
||||
}));
|
||||
jest.mock('../context', () => ({
|
||||
createBuildContext: jest.fn(),
|
||||
}));
|
||||
jest.mock('../external/analytics', () => ({
|
||||
Analytics: jest.fn().mockImplementation(() => ({
|
||||
flushEventsAsync: jest.fn(),
|
||||
logEvent: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../logger', () => {
|
||||
const logger = {
|
||||
child: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
level: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
logger.child.mockReturnValue(logger);
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: logger,
|
||||
createBuildLoggerWithSecretsFilter: jest.fn(async () => ({
|
||||
cleanUp: jest.fn(),
|
||||
logBuffer: { getLogs: jest.fn(() => []), getPhaseLogs: jest.fn(() => []) },
|
||||
logger,
|
||||
})),
|
||||
};
|
||||
});
|
||||
jest.mock('../sentry', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../utils/turtleFetch', () => ({
|
||||
turtleFetch: jest.fn(),
|
||||
}));
|
||||
jest.mock('../config', () => {
|
||||
const config = jest.requireActual('../config').default;
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
...config,
|
||||
buildId: 'build-id',
|
||||
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe(getExpoPackageVersionAsync, () => {
|
||||
const projectRoot = '/test-project';
|
||||
@@ -122,136 +59,3 @@ describe(getExpoPackageVersionAsync, () => {
|
||||
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toBeInstanceOf(errors.UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(BuildService, () => {
|
||||
const projectRoot = '/test-project';
|
||||
const job = {
|
||||
appId: 'app-id',
|
||||
cache: {
|
||||
clear: false,
|
||||
disabled: false,
|
||||
paths: [],
|
||||
},
|
||||
mode: BuildMode.BUILD,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'https://example.com/project.tar.gz',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
secrets: {
|
||||
robotAccessToken: 'robot-token',
|
||||
},
|
||||
triggeredBy: BuildTrigger.EAS_CLI,
|
||||
type: Workflow.GENERIC,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockResolvedValue({
|
||||
stdout: Buffer.from(path.join(projectRoot, 'node_modules/expo/package.json')),
|
||||
} as any);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as any);
|
||||
jest.mocked(build).mockRejectedValue(new Error('raw build failure'));
|
||||
jest.mocked(createBuildContext).mockReturnValue({
|
||||
env: { PATH: '/usr/bin' },
|
||||
getReactNativeProjectDirectory: () => projectRoot,
|
||||
job,
|
||||
logger: {},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('sends expo_package_version to Datadog error logs without changing sdk_version', async () => {
|
||||
vol.fromJSON({
|
||||
[path.join(projectRoot, 'node_modules/expo/package.json')]: JSON.stringify({
|
||||
version: '55.0.17',
|
||||
}),
|
||||
});
|
||||
const service = new BuildService();
|
||||
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
|
||||
|
||||
await (service as any).startBuildInternal({
|
||||
initiatingUserId: 'user-id',
|
||||
job,
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
reactNativeVersion: '0.83.0',
|
||||
sdkVersion: '55.0.0',
|
||||
},
|
||||
projectId: 'project-id',
|
||||
});
|
||||
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://api.expo.test/v2/turtle-builds/logs',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
expo_package_version: '55.0.17',
|
||||
sdk_version: '55.0.0',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends null expo_package_version to Datadog error logs when expo package version resolution fails', async () => {
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('Cannot find module expo/package.json'));
|
||||
const service = new BuildService();
|
||||
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
|
||||
|
||||
await (service as any).startBuildInternal({
|
||||
initiatingUserId: 'user-id',
|
||||
job,
|
||||
metadata: {
|
||||
sdkVersion: '55.0.0',
|
||||
},
|
||||
projectId: 'project-id',
|
||||
});
|
||||
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://api.expo.test/v2/turtle-builds/logs',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
expo_package_version: null,
|
||||
sdk_version: '55.0.0',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not try to resolve expo_package_version when build context setup fails', async () => {
|
||||
jest.mocked(createBuildContext).mockImplementation(() => {
|
||||
throw new Error('context setup failure');
|
||||
});
|
||||
const service = new BuildService();
|
||||
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
|
||||
|
||||
await (service as any).startBuildInternal({
|
||||
initiatingUserId: 'user-id',
|
||||
job,
|
||||
metadata: {
|
||||
sdkVersion: '55.0.0',
|
||||
},
|
||||
projectId: 'project-id',
|
||||
});
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://api.expo.test/v2/turtle-builds/logs',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
expo_package_version: null,
|
||||
sdk_version: '55.0.0',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ import logger, { createBuildLoggerWithSecretsFilter } from './logger';
|
||||
import sentry from './sentry';
|
||||
import State from './state';
|
||||
import { WebSocketServer } from './utils/WebSocketServer';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
export const HANGING_WORKER_CHECK_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -334,60 +333,6 @@ export default class BuildService {
|
||||
},
|
||||
});
|
||||
|
||||
const robotAccessToken = job.secrets?.robotAccessToken;
|
||||
if (robotAccessToken) {
|
||||
const expoPackageVersionResult = this.buildContext
|
||||
? await asyncResult(getExpoPackageVersionAsync(this.buildContext))
|
||||
: null;
|
||||
const expoPackageVersion =
|
||||
expoPackageVersionResult?.ok === true ? expoPackageVersionResult.value : null;
|
||||
let rawErrorMessage: string = '';
|
||||
if (maybeRawError?.stderr) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
|
||||
}
|
||||
if (maybeRawError?.stdout) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
|
||||
}
|
||||
if (!rawErrorMessage) {
|
||||
rawErrorMessage = maybeRawError?.message ?? err.message;
|
||||
}
|
||||
|
||||
try {
|
||||
await turtleFetch(
|
||||
new URL('turtle-builds/logs', config.wwwApiV2BaseUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
buildId: this.buildId,
|
||||
message: rawErrorMessage,
|
||||
level: 'error',
|
||||
tags: {
|
||||
build_phase: err.buildPhase ?? null,
|
||||
error_code: err.errorCode,
|
||||
platform: job.platform,
|
||||
workflow: job.type,
|
||||
sdk_version: metadata?.sdkVersion ?? null,
|
||||
expo_package_version: expoPackageVersion,
|
||||
react_native_version: metadata?.reactNativeVersion ?? null,
|
||||
app_id: job.appId ?? null,
|
||||
build_profile: metadata?.buildProfile ?? null,
|
||||
app_name: metadata?.appName ?? null,
|
||||
app_identifier: metadata?.appIdentifier ?? null,
|
||||
distribution: metadata?.distribution ?? null,
|
||||
cli_version: metadata?.cliVersion ?? null,
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
}
|
||||
);
|
||||
} catch (fetchError: any) {
|
||||
logger.warn({ err: fetchError }, 'Failed to send build error log');
|
||||
}
|
||||
}
|
||||
|
||||
await this.finishError(err, maybeArtifacts);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user