Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela ba41af60b9 [eas-cli] Polish env:pull diff output 2026-07-17 15:13:47 +02:00
Piotr Szeremeta 537e3be30f Add diff log to env:pull command 2026-07-17 15:03:54 +02:00
Douglas Lowder f81efbacde [eas-cli] Support drill-down from event lists in observe:session (#3987)
# Why

When session ID is omitted and the `observe:session` command is running interactively, walk the user through picking an event whose session they want to inspect. Non-interactive without a session ID still errors as before.

The goal is to support a flow similar to that already available on our website, where a user can select an event from a displayed list, and navigate to the session timeline that contains that event.

# How

Add new flags `--event-name` and `--sort-order`. If these are not provided, user will be prompted first to select from a list of event names (including custom events, if applicable), then select a sort order. The command also now takes the same time period flags as the other commands (`--days`, `--start`, `--end`).

Once the user has made the above selections, the user can choose from the list of events shown (up to 25), and the session for that event will be shown.

# Test Plan

- Tested manually with an SDK 57 project
- New unit tests added
2026-07-17 05:39:37 -07:00
Expo CI 06c6a8416d update CHANGELOG.md 2026-07-17 06:03:55 +00:00
23 changed files with 1932 additions and 61 deletions

No files matched your search

+10
View File
@@ -8,6 +8,16 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add diff output to `eas env:pull`. ([#4036](https://github.com/expo/eas-cli/pull/4036) by [@khamilowicz](https://github.com/khamilowicz))
- [eas-cli] Support drill-down from event lists in observe:session. ([#3987](https://github.com/expo/eas-cli/pull/3987) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
### 🧹 Chores
## [21.0.2](https://github.com/expo/eas-cli/releases/tag/v21.0.2) - 2026-07-17
### 🐛 Bug fixes
- [eas-cli] Remove temporary working directories after `eas build:inspect` copies inspect output, avoiding leftover disk usage after failed builds. ([#3981](https://github.com/expo/eas-cli/pull/3981) by [@szdziedzic](https://github.com/szdziedzic))
+501
View File
@@ -12174,6 +12174,87 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "workflowCachesPaginated",
"description": "Caches associated with this app, ordered by last access time (most recent first).",
"args": [
{
"name": "after",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "before",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "first",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "last",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppWorkflowCachesConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "workflowCachingConfig",
"description": "Per-type caching configuration for this app.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "WorkflowCachingConfig",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "workflowDeviceTestCaseHistory",
"description": null,
@@ -13802,6 +13883,55 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "setWorkflowCachingConfig",
"description": "Set per-type caching configuration for the app.",
"args": [
{
"name": "appId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "config",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "WorkflowCachingConfigInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "App",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -23454,6 +23584,100 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppWorkflowCacheEdge",
"description": null,
"fields": [
{
"name": "cursor",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "WorkflowCache",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppWorkflowCachesConnection",
"description": null,
"fields": [
{
"name": "edges",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppWorkflowCacheEdge",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AppWorkflowFilterInput",
@@ -64215,6 +64439,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "workflowCache",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "WorkflowCacheMutation",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "workflowDeviceTestCaseResult",
"description": null,
@@ -75243,6 +75483,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "searchTerm",
"description": "Case-insensitive substring match on update group message and branch name.\nEmbedded updates are matched on channel and runtime version.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "types",
"description": null,
@@ -86280,6 +86532,255 @@
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "WorkflowCache",
"description": null,
"fields": [
{
"name": "createdAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "creatingActor",
"description": "Actor who created this cache. Null if the creating user no longer exists.",
"args": [],
"type": {
"kind": "INTERFACE",
"name": "Actor",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "gitBranchName",
"description": "Git branch name the cache was created from. Null for user-scoped caches.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "hydratingBuild",
"description": "Build that created this cache. Null if the cache was created by a job run\nor the build no longer exists.",
"args": [],
"type": {
"kind": "OBJECT",
"name": "Build",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "hydratingJobRun",
"description": "Job run that created this cache. Null if the cache was created by a build\nor the job run no longer exists.",
"args": [],
"type": {
"kind": "OBJECT",
"name": "JobRun",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "key",
"description": "Cache key used to identify the cache entry.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastAccessedAt",
"description": "Time the cache was last used to restore, or the creation time if it has\nnever been restored.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sizeBytes",
"description": "Size of the cache archive in bytes. Null if the cache has not been hydrated yet.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "Float",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "WorkflowCacheMutation",
"description": null,
"fields": [
{
"name": "deleteWorkflowCache",
"description": null,
"args": [
{
"name": "cacheId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "WorkflowCache",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "WorkflowCachingConfig",
"description": null,
"fields": [
{
"name": "ccacheEnabled",
"description": "Whether the ccache compiler cache is enabled. Null if not explicitly\nconfigured, in which case the account-level default applies.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "gradleCacheEnabled",
"description": "Whether the Gradle build cache is enabled. Null if not explicitly\nconfigured, in which case it is disabled.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "WorkflowCachingConfigInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "ccacheEnabled",
"description": "Pass true or false to explicitly enable or disable, null to reset to the account-level\ndefault, or omit the field to leave it unchanged.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "gradleCacheEnabled",
"description": "Pass true or false to explicitly enable or disable, null to reset to the default\n(disabled), or omit the field to leave it unchanged.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "WorkflowDeviceTestCaseErrorPattern",
@@ -352,8 +352,8 @@ describe(EnvPull, () => {
});
describe('existing .env.local file handling', () => {
it('preserves existing secret values when they exist', async () => {
const existingEnvContent = 'SECRET_KEY=existing-secret-value\nOTHER_VAR=other-value';
it.each(['existing-secret-value', ''])('preserves an existing secret value %j', async value => {
const existingEnvContent = `SECRET_KEY=${value}\nOTHER_VAR=other-value`;
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(existingEnvContent));
jest.mocked(confirmAsync).mockResolvedValue(true);
@@ -376,7 +376,7 @@ describe(EnvPull, () => {
const fileContent = envFileCall![1] as string;
// Should use existing secret value instead of placeholder
expect(fileContent).toContain('SECRET_KEY=existing-secret-value');
expect(fileContent).toContain(`SECRET_KEY=${value}`);
expect(fileContent).not.toContain('# SECRET_KEY=***** (secret)');
// Should log that it reused the local value
+33 -9
View File
@@ -1,5 +1,4 @@
import { Args, Flags } from '@oclif/core';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
@@ -12,6 +11,10 @@ import {
} from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import {
formatEnvironmentVariableDiffAsync,
getEnvironmentVariableNamesFromEnvFile,
} from '../../utils/environmentVariableDiff';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
export default class EnvPull extends EasCommand {
@@ -76,7 +79,8 @@ export default class EnvPull extends EasCommand {
}
);
if (!nonInteractive && (await fs.exists(targetPath))) {
const targetExists = await fs.exists(targetPath);
if (!nonInteractive && targetExists) {
const result = await confirmAsync({
message: `File ${targetPath} already exists. Do you want to overwrite it?`,
});
@@ -87,9 +91,12 @@ export default class EnvPull extends EasCommand {
}
let currentEnvLocal: Record<string, string> = {};
let existingVariableNames = new Set<string>();
if (await fs.exists(targetPath)) {
currentEnvLocal = dotenv.parse(await fs.readFile(targetPath, 'utf8'));
if (targetExists) {
const currentEnvFile = await fs.readFile(targetPath, 'utf8');
({ values: currentEnvLocal, variableNames: existingVariableNames } =
getEnvironmentVariableNamesFromEnvFile(currentEnvFile));
}
const filePrefix = `# Environment: ${environment.toLocaleLowerCase()}\n\n`;
@@ -103,14 +110,22 @@ export default class EnvPull extends EasCommand {
await fs.mkdir(envDir, { recursive: true });
}
const diffLog = await formatEnvironmentVariableDiffAsync({
environmentVariables,
currentEnvValues: currentEnvLocal,
existingVariableNames,
envDir,
targetExists,
});
const skippedSecretVariables: string[] = [];
const overridenSecretVariables: string[] = [];
const overriddenSecretVariables: string[] = [];
const envFileContentLines = await Promise.all(
environmentVariables.map(async (variable: EnvironmentVariableWithFileContent) => {
if (variable.visibility === EnvironmentVariableVisibility.Secret) {
if (currentEnvLocal[variable.name]) {
overridenSecretVariables.push(variable.name);
if (Object.hasOwn(currentEnvLocal, variable.name)) {
overriddenSecretVariables.push(variable.name);
return `${variable.name}=${currentEnvLocal[variable.name]}`;
}
skippedSecretVariables.push(variable.name);
@@ -131,9 +146,11 @@ export default class EnvPull extends EasCommand {
`Pulled plain text and sensitive environment variables from "${environment.toLowerCase()}" environment to ${targetPath}.`
);
if (overridenSecretVariables.length > 0) {
if (overriddenSecretVariables.length > 0) {
Log.addNewLineIfNone();
Log.log(`Reused local values for following secrets: ${overridenSecretVariables.join('\n')}.`);
Log.log(
`Reused local values for following secrets: ${overriddenSecretVariables.join('\n')}.`
);
}
if (skippedSecretVariables.length > 0) {
@@ -144,5 +161,12 @@ export default class EnvPull extends EasCommand {
)}.`
);
}
if (diffLog.length > 0) {
Log.addNewLineIfNone();
for (const line of diffLog) {
Log.log(line);
}
}
}
}
@@ -264,6 +264,14 @@ describe(ObserveMetrics, () => {
'metric argument is required in non-interactive mode'
);
});
it('treats --json as non-interactive when no metric is provided', async () => {
const command = createCommand(['--json']);
await expect(command.runAsync()).rejects.toThrow(
'metric argument is required in non-interactive mode'
);
});
});
describe(resolveOrderBy, () => {
@@ -1,27 +1,99 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { fetchObserveSessionEventsAsync } from '../../../observe/fetchSessions';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import {
fetchObserveSessionEventsAsync,
fetchSessionLogCandidatesAsync,
fetchSessionMetricCandidatesAsync,
} from '../../../observe/fetchSessions';
import {
buildObserveSessionEventsJson,
buildObserveSessionEventsTable,
} from '../../../observe/formatSessions';
import { selectAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveSession from '../session';
jest.mock('../../../observe/fetchSessions');
jest.mock('../../../observe/formatSessions', () => ({
buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'),
buildObserveSessionEventsJson: jest.fn().mockReturnValue({}),
jest.mock('../../../observe/formatSessions', () => {
const actual = jest.requireActual('../../../observe/formatSessions');
return {
...actual,
buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'),
buildObserveSessionEventsJson: jest.fn().mockReturnValue({}),
};
});
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
ObserveQuery: {
customEventNamesAsync: jest.fn(),
},
}));
jest.mock('../../../prompts', () => {
const actual = jest.requireActual('../../../prompts');
return {
...actual,
selectAsync: jest.fn(),
};
});
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveSessionEventsAsync = jest.mocked(fetchObserveSessionEventsAsync);
const mockFetchSessionMetricCandidatesAsync = jest.mocked(fetchSessionMetricCandidatesAsync);
const mockFetchSessionLogCandidatesAsync = jest.mocked(fetchSessionLogCandidatesAsync);
const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync);
const mockSelectAsync = jest.mocked(selectAsync);
const mockBuildObserveSessionEventsTable = jest.mocked(buildObserveSessionEventsTable);
const mockBuildObserveSessionEventsJson = jest.mocked(buildObserveSessionEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeMetricEvent(overrides: any = {}): any {
return {
__typename: 'AppObserveEvent',
id: 'evt-m-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-metric-1',
easClientId: 'client-1',
customParams: null,
routeName: null,
...overrides,
};
}
function makeCustomEvent(overrides: any = {}): any {
return {
__typename: 'AppObserveCustomEvent',
id: 'evt-c-1',
eventName: 'login_pressed',
timestamp: '2025-01-15T10:00:00.000Z',
sessionId: 'session-log-1',
severityNumber: null,
severityText: 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,
};
}
describe(ObserveSession, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
@@ -35,6 +107,9 @@ describe(ObserveSession, () => {
hasMoreMetricEvents: false,
hasMoreLogEvents: false,
});
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
mockFetchSessionLogCandidatesAsync.mockResolvedValue([]);
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
});
function createCommand(argv: string[]): ObserveSession {
@@ -47,11 +122,7 @@ describe(ObserveSession, () => {
return command;
}
it('requires a session ID argument', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
});
// ---- existing single-session behavior ----
it('passes the session ID argument to the fetcher', async () => {
const command = createCommand(['session-abc']);
@@ -76,7 +147,7 @@ describe(ObserveSession, () => {
expect(mockBuildObserveSessionEventsJson).not.toHaveBeenCalled();
});
it('emits JSON output when --json is set', async () => {
it('emits JSON output when --json is set with a session ID', async () => {
const command = createCommand(['session-abc', '--json', '--non-interactive']);
await command.runAsync();
@@ -90,4 +161,212 @@ describe(ObserveSession, () => {
const command = createCommand(['session-abc', '--platform', 'ios']);
await expect(command.runAsync()).rejects.toThrow();
});
// ---- new picker-mode behavior ----
it('errors when no session ID is provided in non-interactive mode', async () => {
const command = createCommand(['--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(/session ID argument is required/);
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
});
it('treats --json as non-interactive (requires a session ID)', async () => {
const command = createCommand(['--json']);
await expect(command.runAsync()).rejects.toThrow(/session ID argument is required/);
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
});
it('rejects picker flags when a session ID is also provided', async () => {
const command = createCommand(['session-abc', '--event-name', 'tti']);
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
});
it('rejects --days when a session ID is also provided', async () => {
const command = createCommand(['session-abc', '--days', '7']);
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' }),
]);
// 1) sort prompt (no --sort flag), 2) event picker
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-session-1');
const command = createCommand(['--event-name', 'tti']);
await command.runAsync();
expect(mockFetchSessionMetricCandidatesAsync).toHaveBeenCalledTimes(1);
const options = mockFetchSessionMetricCandidatesAsync.mock.calls[0][2];
expect(options.metricName).toBe('expo.app_startup.tti');
expect(options.limit).toBe(25);
expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled();
expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledWith(
graphqlClient,
projectId,
expect.objectContaining({ sessionId: 'picked-session-1' })
);
});
it('picker mode with an unknown --event-name treats it as a log event and fetches custom events', async () => {
mockFetchSessionLogCandidatesAsync.mockResolvedValue([
makeCustomEvent({ sessionId: 'picked-log-session-1' }),
]);
// 1) sort prompt (no --sort flag), 2) event picker
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-log-session-1');
const command = createCommand(['--event-name', 'login_pressed']);
await command.runAsync();
expect(mockFetchSessionLogCandidatesAsync).toHaveBeenCalledTimes(1);
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].eventName).toBe('login_pressed');
expect(mockFetchSessionMetricCandidatesAsync).not.toHaveBeenCalled();
expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledWith(
graphqlClient,
projectId,
expect.objectContaining({ sessionId: 'picked-log-session-1' })
);
});
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,
],
isTruncated: false,
});
// 1) event-name picker → metric choice
// 2) sort picker → newest
// 3) event picker → sessionId
mockSelectAsync
.mockResolvedValueOnce({ name: 'expo.app_startup.tti', isMetric: true } as any)
.mockResolvedValueOnce('newest')
.mockResolvedValueOnce('picked-session-2');
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
makeMetricEvent({ sessionId: 'picked-session-2' }),
]);
const command = createCommand([]);
await command.runAsync();
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
expect(mockSelectAsync).toHaveBeenCalledTimes(3);
expect(mockFetchSessionMetricCandidatesAsync).toHaveBeenCalledTimes(1);
expect(mockFetchSessionMetricCandidatesAsync.mock.calls[0][2].metricName).toBe(
'expo.app_startup.tti'
);
expect(mockFetchObserveSessionEventsAsync.mock.calls[0][2].sessionId).toBe('picked-session-2');
});
it('prompts for sort order when --sort is not provided and offers metric-only options for metric events', async () => {
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
makeMetricEvent({ sessionId: 'picked-session-3' }),
]);
mockSelectAsync.mockResolvedValueOnce('slowest').mockResolvedValueOnce('picked-session-3');
const command = createCommand(['--event-name', 'tti']);
await command.runAsync();
// First selectAsync is the sort picker; verify choice values
const sortChoices = mockSelectAsync.mock.calls[0][1];
expect(sortChoices.map((c: any) => c.value)).toEqual([
'newest',
'oldest',
'slowest',
'fastest',
]);
});
it('prompts for sort order without fastest/slowest for log events', async () => {
mockFetchSessionLogCandidatesAsync.mockResolvedValue([
makeCustomEvent({ sessionId: 'picked-log-session-2' }),
]);
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-log-session-2');
const command = createCommand(['--event-name', 'login_pressed']);
await command.runAsync();
const sortChoices = mockSelectAsync.mock.calls[0][1];
expect(sortChoices.map((c: any) => c.value)).toEqual(['newest', 'oldest']);
});
it('skips the sort prompt when --sort is provided explicitly', async () => {
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
makeMetricEvent({ sessionId: 'picked-session-4' }),
]);
mockSelectAsync.mockResolvedValueOnce('picked-session-4');
const command = createCommand(['--event-name', 'tti', '--sort', 'oldest']);
await command.runAsync();
// Only the event picker was called, not the sort picker
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
});
it('forwards --sort=slowest to the metric candidate fetcher', async () => {
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([makeMetricEvent()]);
mockSelectAsync.mockResolvedValueOnce('session-metric-1');
const command = createCommand(['--event-name', 'tti', '--sort', 'slowest']);
await command.runAsync();
expect(mockFetchSessionMetricCandidatesAsync.mock.calls[0][2].sort).toBe('slowest');
});
it('throws when --sort=slowest is combined with a log event name', async () => {
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'slowest']);
await expect(command.runAsync()).rejects.toThrow(/only supported for metric events/);
expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled();
});
it('passes orderAscending=true to the log candidate fetcher for --sort=oldest', async () => {
mockFetchSessionLogCandidatesAsync.mockResolvedValue([makeCustomEvent()]);
mockSelectAsync.mockResolvedValueOnce('session-log-1');
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'oldest']);
await command.runAsync();
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].orderAscending).toBe(true);
});
it('passes orderAscending=false to the log candidate fetcher for --sort=newest', async () => {
mockFetchSessionLogCandidatesAsync.mockResolvedValue([makeCustomEvent()]);
mockSelectAsync.mockResolvedValueOnce('session-log-1');
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'newest']);
await command.runAsync();
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].orderAscending).toBe(false);
});
it('throws when the metric picker returns no candidate events', async () => {
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
mockSelectAsync.mockResolvedValueOnce('newest');
const command = createCommand(['--event-name', 'tti']);
await expect(command.runAsync()).rejects.toThrow(/No events found/);
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
});
it('offers each returned candidate as a choice in the event picker', async () => {
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
makeMetricEvent({ sessionId: 'session-a' }),
makeMetricEvent({ id: 'evt-m-2', sessionId: 'session-b' }),
]);
// 1) sort prompt, 2) event picker
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('session-a');
const command = createCommand(['--event-name', 'tti']);
await command.runAsync();
// Second selectAsync call is the event picker
const eventChoices = mockSelectAsync.mock.calls[1][1];
expect(eventChoices).toHaveLength(2);
expect(eventChoices.map((c: any) => c.value)).toEqual(['session-a', 'session-b']);
});
it('rejects --sort when a session ID is also provided', async () => {
const command = createCommand(['session-abc', '--sort', 'slowest']);
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
});
});
@@ -1,7 +1,10 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
@@ -73,6 +76,7 @@ export default class ObserveEvents extends EasCommand {
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvents);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (args.eventName && flags['all-events']) {
throw new Error(
@@ -85,10 +89,10 @@ export default class ObserveEvents extends EasCommand {
commandClass: ObserveEvents,
loggedInOnlyContextDefinition: ObserveEvents.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
nonInteractive,
});
if (flags.json) {
if (json) {
enableJsonOutput();
}
@@ -104,7 +108,7 @@ export default class ObserveEvents extends EasCommand {
platform,
});
if (flags.json) {
if (json) {
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
} else {
Log.addNewLineIfNone();
@@ -140,7 +144,7 @@ export default class ObserveEvents extends EasCommand {
platform,
});
if (flags.json) {
if (json) {
printJsonOnlyOutput(
buildObserveCustomEventsEmptyWithSuggestionsJson(args.eventName, names, isTruncated)
);
@@ -158,7 +162,7 @@ export default class ObserveEvents extends EasCommand {
return;
}
if (flags.json) {
if (json) {
printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
@@ -1,7 +1,10 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
@@ -73,16 +76,17 @@ export default class ObserveMetricsSummary extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetricsSummary);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveMetricsSummary,
loggedInOnlyContextDefinition: ObserveMetricsSummary.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
nonInteractive,
});
if (flags.json) {
if (json) {
enableJsonOutput();
}
@@ -108,7 +112,7 @@ export default class ObserveMetricsSummary extends EasCommand {
? Array.from(new Set(flags.stat.map(resolveStatKey)))
: undefined;
if (flags.json) {
if (json) {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(
buildObserveMetricsJson(
@@ -2,7 +2,10 @@ import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import {
@@ -71,23 +74,24 @@ export default class ObserveMetrics extends EasCommand {
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveMetrics);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveMetrics,
loggedInOnlyContextDefinition: ObserveMetrics.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
nonInteractive,
});
if (flags.json) {
if (json) {
enableJsonOutput();
}
let metricName: string;
if (args.metric) {
metricName = resolveMetricName(args.metric);
} else if (flags['non-interactive']) {
} else if (nonInteractive) {
throw new EasCommandError(
'A metric argument is required in non-interactive mode. Available metrics: ' +
Object.keys(METRIC_ALIASES).join(', ')
@@ -128,7 +132,7 @@ export default class ObserveMetrics extends EasCommand {
),
]);
if (flags.json) {
if (json) {
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
@@ -1,7 +1,10 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import Log from '../../log';
import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigationRoutes';
@@ -81,16 +84,17 @@ export default class ObserveRoutes extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveRoutes);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveRoutes,
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
nonInteractive,
});
if (flags.json) {
if (json) {
enableJsonOutput();
}
@@ -125,7 +129,7 @@ export default class ObserveRoutes extends EasCommand {
}
);
if (flags.json) {
if (json) {
const stats = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(
buildObserveNavigationRoutesJson(routes, metricNames, stats, pageInfoByPlatform)
+217 -11
View File
@@ -1,15 +1,35 @@
import { Args } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import Log from '../../log';
import { fetchObserveSessionEventsAsync } from '../../observe/fetchSessions';
import { ObserveProjectIdFlag } from '../../observe/flags';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import { EventsOrderPreset } from '../../observe/fetchEvents';
import {
fetchObserveSessionEventsAsync,
fetchSessionLogCandidatesAsync,
fetchSessionMetricCandidatesAsync,
} from '../../observe/fetchSessions';
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
import {
buildObserveSessionEventsJson,
buildObserveSessionEventsTable,
formatLogCandidateTitle,
formatMetricCandidateTitle,
} from '../../observe/formatSessions';
import {
METRIC_SHORT_NAMES,
isKnownMetricName,
resolveMetricName,
} from '../../observe/metricNames';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { ExpoChoice, selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
// Fixed at 100 — the maximum page size accepted by the underlying events and
@@ -17,18 +37,33 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
// command pulls one page of each and merges client-side.
const SESSION_PAGE_SIZE = 100;
// How many candidate events to present in the picker. Small enough to browse;
// users narrow further with --days / --sort.
const PICKER_CANDIDATE_LIMIT = 25;
export default class ObserveSession extends EasCommand {
static override description =
'display the timeline of metric and log events for a specific session';
static override args = {
sessionId: Args.string({
description: 'Session ID to inspect',
required: true,
description: 'Session ID to inspect (omit in interactive mode to pick one from a list)',
required: false,
}),
};
static override flags = {
sort: Flags.option({
description:
'Sort order for candidate events when picking a session (if omitted in interactive mode, you will be prompted)',
options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()),
required: false,
})(),
'event-name': Flags.string({
description:
'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,
...ObserveProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -44,30 +79,59 @@ export default class ObserveSession extends EasCommand {
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveSession);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveSession,
loggedInOnlyContextDefinition: ObserveSession.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
nonInteractive,
});
if (flags.json) {
if (json) {
enableJsonOutput();
}
let sessionId: string;
if (args.sessionId) {
const pickerFlagsProvided =
flags['event-name'] !== undefined ||
flags.sort !== undefined ||
flags.days !== undefined ||
flags.start !== undefined ||
flags.end !== 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.'
);
}
sessionId = args.sessionId;
} else if (nonInteractive) {
throw new EasCommandError(
'A session ID argument is required in non-interactive mode. In interactive mode, you can omit the session ID to pick one from a list of events.'
);
} else {
sessionId = await pickSessionIdInteractivelyAsync({
graphqlClient,
projectId,
eventNameFlag: flags['event-name'],
sort: flags.sort,
timeRangeFlags: { days: flags.days, start: flags.start, end: flags.end },
});
}
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
await fetchObserveSessionEventsAsync(graphqlClient, projectId, {
sessionId: args.sessionId,
sessionId,
limit: SESSION_PAGE_SIZE,
});
if (flags.json) {
if (json) {
printJsonOnlyOutput(
buildObserveSessionEventsJson(
entries,
args.sessionId,
sessionId,
metadata,
hasMoreMetricEvents,
hasMoreLogEvents
@@ -85,3 +149,145 @@ export default class ObserveSession extends EasCommand {
}
}
}
interface EventNameChoice {
name: string;
isMetric: boolean;
}
async function pickSessionIdInteractivelyAsync({
graphqlClient,
projectId,
eventNameFlag,
sort,
timeRangeFlags,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
eventNameFlag: string | undefined;
sort: string | undefined;
timeRangeFlags: { days: number | undefined; start: string | undefined; end: string | undefined };
}): Promise<string> {
const { startTime, endTime } = resolveTimeRange(timeRangeFlags);
const eventNameChoice: EventNameChoice = eventNameFlag
? { name: eventNameFlag, isMetric: isKnownMetricName(eventNameFlag) }
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime });
let sortValue: string;
if (sort) {
const preset = sort.toUpperCase() as EventsOrderPreset;
const sortIsValueBased =
preset === EventsOrderPreset.Slowest || preset === EventsOrderPreset.Fastest;
if (!eventNameChoice.isMetric && sortIsValueBased) {
throw new EasCommandError(
`--sort=${sort} is only supported for metric events. Use newest or oldest for log events.`
);
}
sortValue = sort;
} else {
sortValue = await promptForSortOrderAsync(eventNameChoice.isMetric);
}
const candidates: CandidateEvent[] = eventNameChoice.isMetric
? (
await fetchSessionMetricCandidatesAsync(graphqlClient, projectId, {
metricName: resolveMetricName(eventNameChoice.name),
sort: sortValue,
startTime,
endTime,
limit: PICKER_CANDIDATE_LIMIT,
})
).map(event => ({ sessionId: event.sessionId, title: formatMetricCandidateTitle(event) }))
: (
await fetchSessionLogCandidatesAsync(graphqlClient, projectId, {
eventName: eventNameChoice.name,
orderAscending: sortValue.toUpperCase() === EventsOrderPreset.Oldest,
startTime,
endTime,
limit: PICKER_CANDIDATE_LIMIT,
})
).map(event => ({ sessionId: event.sessionId, title: formatLogCandidateTitle(event) }));
if (candidates.length === 0) {
throw new EasCommandError(
`No events found for "${eventNameChoice.name}" in the selected time range. Try widening the window with --days or picking a different --event-name.`
);
}
return await promptForSessionIdAsync(candidates);
}
async function promptForSortOrderAsync(isMetric: boolean): Promise<string> {
const choices: ExpoChoice<string>[] = [
{ title: 'Newest first', value: EventsOrderPreset.Newest.valueOf().toLowerCase() },
{ title: 'Oldest first', value: EventsOrderPreset.Oldest.valueOf().toLowerCase() },
...(isMetric
? [
{
title: 'Slowest first (highest metric value)',
value: EventsOrderPreset.Slowest.valueOf().toLowerCase(),
},
{
title: 'Fastest first (lowest metric value)',
value: EventsOrderPreset.Fastest.valueOf().toLowerCase(),
},
]
: []),
];
return await selectAsync('Sort candidate events by', choices);
}
async function promptForEventNameAsync({
graphqlClient,
projectId,
startTime,
endTime,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
startTime: string;
endTime: string;
}): Promise<EventNameChoice> {
const { names: customEventNames } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
appId: projectId,
startTime,
endTime,
});
const metricChoices: ExpoChoice<EventNameChoice>[] = Object.entries(METRIC_SHORT_NAMES).map(
([fullName, displayName]) => ({
title: `${displayName} (metric)`,
value: { name: fullName, isMetric: true },
})
);
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(
({ eventName, count }) => ({
title: `${eventName} (${count} log event${count === 1 ? '' : 's'})`,
value: { name: eventName, isMetric: false },
})
);
const choices = [...metricChoices, ...logChoices];
if (choices.length === 0) {
throw new EasCommandError(
'No metric or log events found for the selected time range. Widen the window with --days or wait for data to arrive.'
);
}
return await selectAsync('Select an event name', choices);
}
interface CandidateEvent {
sessionId: string;
title: string;
}
async function promptForSessionIdAsync(candidates: CandidateEvent[]): Promise<string> {
const choices: ExpoChoice<string>[] = candidates.map(c => ({
title: c.title,
value: c.sessionId,
}));
return await selectAsync('Select an event (its session will be shown)', choices);
}
@@ -1,5 +1,8 @@
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
import {
@@ -34,16 +37,17 @@ export default class ObserveVersions extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveVersions);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveVersions,
loggedInOnlyContextDefinition: ObserveVersions.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
nonInteractive: flags['non-interactive'],
nonInteractive,
});
if (flags.json) {
if (json) {
enableJsonOutput();
}
@@ -59,7 +63,7 @@ export default class ObserveVersions extends EasCommand {
endTime
);
if (flags.json) {
if (json) {
printJsonOnlyOutput(buildObserveVersionsJson(results));
} else {
Log.addNewLineIfNone();
+106
View File
@@ -1563,6 +1563,10 @@ export type App = Project & {
workerDeploymentsCrashes?: Maybe<WorkerDeploymentCrashes>;
workerDeploymentsRequest: WorkerDeploymentRequestEdge;
workerDeploymentsRequests?: Maybe<WorkerDeploymentRequests>;
/** Caches associated with this app, ordered by last access time (most recent first). */
workflowCachesPaginated: AppWorkflowCachesConnection;
/** Per-type caching configuration for this app. */
workflowCachingConfig: WorkflowCachingConfig;
workflowDeviceTestCaseHistory: WorkflowDeviceTestCaseHistory;
workflowDeviceTestCaseInsights: WorkflowDeviceTestCaseInsights;
workflowRunGitBranchesPaginated: AppWorkflowRunGitBranchesConnection;
@@ -1892,6 +1896,15 @@ export type AppWorkerDeploymentsRequestsArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWorkflowCachesPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWorkflowDeviceTestCaseHistoryArgs = {
filters?: InputMaybe<WorkflowDeviceTestCaseHistoryFiltersInput>;
@@ -2096,6 +2109,8 @@ export type AppMutation = {
setPushSecurityEnabled: App;
/** Set resource class experiment for app */
setResourceClassExperiment: App;
/** Set per-type caching configuration for the app. */
setWorkflowCachingConfig: App;
};
@@ -2131,6 +2146,12 @@ export type AppMutationSetResourceClassExperimentArgs = {
resourceClassExperiment?: InputMaybe<ResourceClassExperiment>;
};
export type AppMutationSetWorkflowCachingConfigArgs = {
appId: Scalars['ID']['input'];
config: WorkflowCachingConfigInput;
};
/** App-level notification preference */
export type AppNotificationPreference = NotificationPreference & {
__typename?: 'AppNotificationPreference';
@@ -3361,6 +3382,18 @@ export type AppWithGithubRepositoryInput = {
projectName: Scalars['String']['input'];
};
export type AppWorkflowCacheEdge = {
__typename?: 'AppWorkflowCacheEdge';
cursor: Scalars['String']['output'];
node: WorkflowCache;
};
export type AppWorkflowCachesConnection = {
__typename?: 'AppWorkflowCachesConnection';
edges: Array<AppWorkflowCacheEdge>;
pageInfo: PageInfo;
};
export type AppWorkflowFilterInput = {
requestedGitRef?: InputMaybe<Scalars['String']['input']>;
};
@@ -9051,6 +9084,7 @@ export type RootMutation = {
webhook: WebhookMutation;
/** Mutations that modify a websiteNotification */
websiteNotifications: WebsiteNotificationMutation;
workflowCache: WorkflowCacheMutation;
workflowDeviceTestCaseResult: WorkflowDeviceTestCaseResultMutation;
workflowJobAppleDeviceRegistrationRequest: WorkflowJobAppleDeviceRegistrationRequestMutation;
workflowJobApproval: WorkflowJobApprovalMutation;
@@ -10589,6 +10623,11 @@ export type UpdatesTimelineFilter = {
channel?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppPlatform>;
runtimeVersions?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Case-insensitive substring match on update group message and branch name.
* Embedded updates are matched on channel and runtime version.
*/
searchTerm?: InputMaybe<Scalars['String']['input']>;
types?: InputMaybe<Array<UpdatesTimelineItemType>>;
};
@@ -12013,6 +12052,72 @@ export enum WorkflowArtifactStorageType {
R2 = 'R2'
}
export type WorkflowCache = {
__typename?: 'WorkflowCache';
createdAt: Scalars['DateTime']['output'];
/** Actor who created this cache. Null if the creating user no longer exists. */
creatingActor?: Maybe<Actor>;
/** Git branch name the cache was created from. Null for user-scoped caches. */
gitBranchName?: Maybe<Scalars['String']['output']>;
/**
* Build that created this cache. Null if the cache was created by a job run
* or the build no longer exists.
*/
hydratingBuild?: Maybe<Build>;
/**
* Job run that created this cache. Null if the cache was created by a build
* or the job run no longer exists.
*/
hydratingJobRun?: Maybe<JobRun>;
id: Scalars['ID']['output'];
/** Cache key used to identify the cache entry. */
key: Scalars['String']['output'];
/**
* Time the cache was last used to restore, or the creation time if it has
* never been restored.
*/
lastAccessedAt: Scalars['DateTime']['output'];
/** Size of the cache archive in bytes. Null if the cache has not been hydrated yet. */
sizeBytes?: Maybe<Scalars['Float']['output']>;
};
export type WorkflowCacheMutation = {
__typename?: 'WorkflowCacheMutation';
deleteWorkflowCache: WorkflowCache;
};
export type WorkflowCacheMutationDeleteWorkflowCacheArgs = {
cacheId: Scalars['ID']['input'];
};
export type WorkflowCachingConfig = {
__typename?: 'WorkflowCachingConfig';
/**
* Whether the ccache compiler cache is enabled. Null if not explicitly
* configured, in which case the account-level default applies.
*/
ccacheEnabled?: Maybe<Scalars['Boolean']['output']>;
/**
* Whether the Gradle build cache is enabled. Null if not explicitly
* configured, in which case it is disabled.
*/
gradleCacheEnabled?: Maybe<Scalars['Boolean']['output']>;
};
export type WorkflowCachingConfigInput = {
/**
* Pass true or false to explicitly enable or disable, null to reset to the account-level
* default, or omit the field to leave it unchanged.
*/
ccacheEnabled?: InputMaybe<Scalars['Boolean']['input']>;
/**
* Pass true or false to explicitly enable or disable, null to reset to the default
* (disabled), or omit the field to leave it unchanged.
*/
gradleCacheEnabled?: InputMaybe<Scalars['Boolean']['input']>;
};
/**
* Grouping key from the [shard N] prefix-stripped error_message. `count` is
* computed with uniqExact(test_case_result_id) so RMT pre-merge duplicates do
@@ -14511,6 +14616,7 @@ export type AppObserveCustomEventListQueryVariables = Exact<{
filter?: InputMaybe<AppObserveCustomEventListFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
orderBy?: InputMaybe<AppObserveCustomEventListOrderBy>;
}>;
@@ -6,6 +6,7 @@ import {
AppObserveAppVersion,
AppObserveCustomEvent,
AppObserveCustomEventListFilter,
AppObserveCustomEventListOrderBy,
AppObserveCustomEventName,
AppObserveEvent,
AppObserveEventsFilter,
@@ -112,6 +113,7 @@ type AppObserveCustomEventListQueryVariables = {
filter?: AppObserveCustomEventListFilter;
first?: number;
after?: string;
orderBy?: AppObserveCustomEventListOrderBy;
};
type AppObserveCustomEventNamesQuery = {
@@ -328,12 +330,18 @@ export const ObserveQuery = {
$filter: AppObserveCustomEventListFilter
$first: Int
$after: String
$orderBy: AppObserveCustomEventListOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
customEventList(filter: $filter, first: $first, after: $after) {
customEventList(
filter: $filter
first: $first
after: $after
orderBy: $orderBy
) {
pageInfo {
hasNextPage
hasPreviousPage
@@ -1,15 +1,26 @@
import {
AppObserveCustomEvent,
AppObserveCustomEventListOrderByField,
AppObserveEvent,
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
} from '../../graphql/generated';
import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents';
import { fetchObserveEventsAsync } from '../fetchEvents';
import { fetchObserveSessionEventsAsync } from '../fetchSessions';
import {
fetchObserveSessionEventsAsync,
fetchSessionLogCandidatesAsync,
fetchSessionMetricCandidatesAsync,
} from '../fetchSessions';
jest.mock('../fetchCustomEvents');
jest.mock('../fetchEvents');
jest.mock('../fetchEvents', () => {
const actual = jest.requireActual('../fetchEvents');
return {
...actual,
fetchObserveEventsAsync: jest.fn(),
};
});
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
@@ -191,3 +202,149 @@ describe('fetchObserveSessionEventsAsync', () => {
expect(result.hasMoreLogEvents).toBe(false);
});
});
describe('fetchSessionMetricCandidatesAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
it('forwards metricName, sort, window, and limit into fetchObserveEventsAsync', async () => {
await fetchSessionMetricCandidatesAsync({} as any, 'project-1', {
metricName: 'expo.app_startup.tti',
sort: 'slowest',
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-02-01T00:00:00.000Z',
limit: 25,
});
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.metricName).toBe('expo.app_startup.tti');
expect(options.limit).toBe(25);
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,
});
});
it('filters out events without a sessionId', async () => {
mockFetchObserveEventsAsync.mockResolvedValue({
events: [
makeMetricEvent({ id: 'evt-1', sessionId: 'session-a' }),
makeMetricEvent({ id: 'evt-2', sessionId: null }),
makeMetricEvent({ id: 'evt-3', sessionId: 'session-b' }),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const result = await fetchSessionMetricCandidatesAsync({} as any, 'project-1', {
metricName: 'expo.app_startup.tti',
sort: 'newest',
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-02-01T00:00:00.000Z',
limit: 25,
});
expect(result.map(e => e.id)).toEqual(['evt-1', 'evt-3']);
});
});
describe('fetchSessionLogCandidatesAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
it('forwards eventName, window, and limit into fetchObserveCustomEventsAsync', async () => {
await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
eventName: 'login_pressed',
orderAscending: false,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-02-01T00:00:00.000Z',
limit: 25,
});
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
expect(options.eventName).toBe('login_pressed');
expect(options.limit).toBe(25);
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('requests descending timestamp order when orderAscending is false', async () => {
await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
eventName: 'login_pressed',
orderAscending: false,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-02-01T00:00:00.000Z',
limit: 25,
});
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].orderBy).toEqual({
field: AppObserveCustomEventListOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('requests ascending timestamp order when orderAscending is true', async () => {
await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
eventName: 'login_pressed',
orderAscending: true,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-02-01T00:00:00.000Z',
limit: 25,
});
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].orderBy).toEqual({
field: AppObserveCustomEventListOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.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' }),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
eventName: 'login_pressed',
orderAscending: false,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-02-01T00:00:00.000Z',
limit: 25,
});
expect(result.map(e => e.id)).toEqual(['c', 'b', 'a']);
});
it('filters out events without a sessionId', async () => {
mockFetchObserveCustomEventsAsync.mockResolvedValue({
events: [
makeCustomEvent({ id: 'a', sessionId: 'session-a' }),
makeCustomEvent({ id: 'b', sessionId: null }),
],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
eventName: 'login_pressed',
orderAscending: false,
startTime: '2025-01-01T00:00:00.000Z',
endTime: '2025-02-01T00:00:00.000Z',
limit: 25,
});
expect(result.map(e => e.id)).toEqual(['a']);
});
});
@@ -1,5 +1,12 @@
import { AppObserveCustomEvent, AppObserveEvent } from '../../graphql/generated';
import { SessionEventEntry, SessionMetadata } from '../fetchSessions';
import { buildObserveSessionEventsJson, buildObserveSessionEventsTable } from '../formatSessions';
import {
buildObserveSessionEventsJson,
buildObserveSessionEventsTable,
formatLogCandidateTitle,
formatMetricCandidateTitle,
shortSessionId,
} from '../formatSessions';
function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
return {
@@ -240,3 +247,91 @@ describe(buildObserveSessionEventsJson, () => {
expect(result.metadata).toBeNull();
});
});
describe(shortSessionId, () => {
it('returns the full id when it is 12 characters or fewer', () => {
expect(shortSessionId('abc')).toBe('abc');
expect(shortSessionId('abcdefghijkl')).toBe('abcdefghijkl');
});
it('collapses long ids to <first-8>…<last-4>', () => {
expect(shortSessionId('abcdefgh1234567890zyxw')).toBe('abcdefgh…zyxw');
});
});
function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
return {
__typename: 'AppObserveEvent' as const,
id: 'evt-m-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:00:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
appUpdateId: null,
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-abcdefgh1234567890',
easClientId: 'client-1',
customParams: null,
routeName: null,
...overrides,
} as AppObserveEvent;
}
function makeCustomEventEvt(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
return {
__typename: 'AppObserveCustomEvent' as const,
id: 'evt-c-1',
eventName: 'login_pressed',
timestamp: '2025-01-15T10:00:00.000Z',
sessionId: 'session-abcdefgh1234567890',
severityNumber: null,
severityText: 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 AppObserveCustomEvent;
}
describe(formatMetricCandidateTitle, () => {
it('includes timestamp, display name, value, version, device, and truncated session id', () => {
const title = formatMetricCandidateTitle(makeMetricEvent());
expect(title).toContain('Startup TTI 1.23s');
expect(title).toContain('1.0.0');
expect(title).toContain('iOS 17.0');
expect(title).toContain('session session-…7890');
});
});
describe(formatLogCandidateTitle, () => {
it('includes timestamp, event name, and severity (using severityText when set)', () => {
const title = formatLogCandidateTitle(makeCustomEventEvt({ severityText: 'WARN' }));
expect(title).toContain('login_pressed');
expect(title).toContain('WARN');
expect(title).toContain('1.0.0');
});
it('falls back to severityNumber when severityText is null', () => {
const title = formatLogCandidateTitle(
makeCustomEventEvt({ severityText: null, severityNumber: 13 })
);
expect(title).toContain('13');
});
it('shows "-" for severity when both fields are null', () => {
const title = formatLogCandidateTitle(makeCustomEventEvt());
expect(title).toContain('login_pressed · -');
});
});
@@ -1,5 +1,6 @@
import {
getMetricDisplayName,
isKnownMetricName,
resolveMetricName,
resolveNavigationMetricName,
} from '../metricNames';
@@ -110,3 +111,31 @@ describe(getMetricDisplayName, () => {
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
});
});
describe(isKnownMetricName, () => {
it('returns true for app-startup short aliases', () => {
expect(isKnownMetricName('tti')).toBe(true);
expect(isKnownMetricName('cold_launch')).toBe(true);
expect(isKnownMetricName('bundle_load')).toBe(true);
});
it('returns true for navigation short aliases', () => {
expect(isKnownMetricName('nav_cold_ttr')).toBe(true);
expect(isKnownMetricName('nav_warm_ttr')).toBe(true);
expect(isKnownMetricName('nav_tti')).toBe(true);
});
it('returns true for full metric names', () => {
expect(isKnownMetricName('expo.app_startup.tti')).toBe(true);
expect(isKnownMetricName('expo.navigation.cold_ttr')).toBe(true);
});
it('returns false for unknown names (including custom event names)', () => {
expect(isKnownMetricName('login_pressed')).toBe(false);
expect(isKnownMetricName('some_random_event')).toBe(false);
expect(isKnownMetricName('')).toBe(false);
// A dotted name that isn't in the known-full set is still not "known"
// for picker purposes; the picker treats it as a log event.
expect(isKnownMetricName('custom.metric.name')).toBe(false);
});
});
@@ -2,6 +2,7 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
import {
AppObserveCustomEvent,
AppObserveCustomEventListFilter,
AppObserveCustomEventListOrderBy,
AppObservePlatform,
PageInfo,
} from '../graphql/generated';
@@ -17,6 +18,7 @@ interface FetchCustomEventsOptions {
appVersion?: string;
updateId?: string;
sessionId?: string;
orderBy?: AppObserveCustomEventListOrderBy;
}
interface FetchCustomEventsResult {
@@ -44,5 +46,6 @@ export async function fetchObserveCustomEventsAsync(
filter,
first: options.limit,
...(options.after && { after: options.after }),
...(options.orderBy && { orderBy: options.orderBy }),
});
}
+76 -1
View File
@@ -1,12 +1,13 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppObserveCustomEvent,
AppObserveCustomEventListOrderByField,
AppObserveEvent,
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
} from '../graphql/generated';
import { fetchObserveCustomEventsAsync } from './fetchCustomEvents';
import { fetchObserveEventsAsync } from './fetchEvents';
import { fetchObserveEventsAsync, resolveOrderBy } from './fetchEvents';
export interface SessionEventEntry {
source: 'metric' | 'log';
@@ -146,3 +147,77 @@ export async function fetchObserveSessionEventsAsync(
hasMoreLogEvents: logResult.pageInfo.hasNextPage,
};
}
/**
* A metric event that is guaranteed to belong to a session — used as a
* candidate when picking a session to inspect via `observe:session`.
*/
export type SessionMetricCandidate = AppObserveEvent & { sessionId: string };
export interface FetchSessionMetricCandidatesOptions {
metricName: string;
/** One of EventsOrderPreset (case-insensitive). */
sort: string;
startTime: string;
endTime: string;
limit: number;
}
/**
* Fetch a page of metric events for the given metricName + window, ordered
* per `sort`, and filtered to events that have a sessionId. The events query
* supports server-side ordering, so `sort` is passed straight through.
*/
export async function fetchSessionMetricCandidatesAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options: FetchSessionMetricCandidatesOptions
): Promise<SessionMetricCandidate[]> {
const { events } = await fetchObserveEventsAsync(graphqlClient, appId, {
metricName: options.metricName,
orderBy: resolveOrderBy(options.sort),
limit: options.limit,
startTime: options.startTime,
endTime: options.endTime,
});
return events.filter((e): e is SessionMetricCandidate => !!e.sessionId);
}
/**
* A custom log event that is guaranteed to belong to a session — used as a
* candidate when picking a session to inspect via `observe:session`.
*/
export type SessionLogCandidate = AppObserveCustomEvent & { sessionId: string };
export interface FetchSessionLogCandidatesOptions {
eventName: string;
/** True → oldest-first (ascending timestamp); false → newest-first. */
orderAscending: boolean;
startTime: string;
endTime: string;
limit: number;
}
/**
* Fetch a page of custom log events for the given eventName + window, ordered
* by timestamp server-side, and filtered to events that have a sessionId.
*/
export async function fetchSessionLogCandidatesAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
options: FetchSessionLogCandidatesOptions
): Promise<SessionLogCandidate[]> {
const { events } = await fetchObserveCustomEventsAsync(graphqlClient, appId, {
eventName: options.eventName,
limit: options.limit,
startTime: options.startTime,
endTime: options.endTime,
orderBy: {
field: AppObserveCustomEventListOrderByField.Timestamp,
direction: options.orderAscending
? AppObserveEventsOrderByDirection.Asc
: AppObserveEventsOrderByDirection.Desc,
},
});
return events.filter((e): e is SessionLogCandidate => !!e.sessionId);
}
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import { AppObserveCustomEvent, AppObserveEvent } from '../graphql/generated';
import { SessionEventEntry, SessionMetadata } from './fetchSessions';
import { formatLogTimestamp } from './formatUtils';
import { getMetricDisplayName } from './metricNames';
@@ -142,3 +143,40 @@ export function buildObserveSessionEventsJson(
hasMoreLogEvents,
};
}
/**
* Compact session ID for display in candidate lists — long UUIDs are
* truncated to `<first-8>…<last-4>` so a row stays scannable.
*/
export function shortSessionId(sessionId: string): string {
if (sessionId.length <= 12) {
return sessionId;
}
return `${sessionId.slice(0, 8)}…${sessionId.slice(-4)}`;
}
/**
* One-line title for a metric event shown in the observe:session candidate
* picker, e.g. `Jan 15, 10:00:00.000 AM · Startup TTI 1.23s · 1.0.0 · iOS 17.0 · session abc…1234`.
*/
export function formatMetricCandidateTitle(event: AppObserveEvent): string {
const displayName = getMetricDisplayName(event.metricName);
const value = `${event.metricValue.toFixed(2)}s`;
const timestamp = formatLogTimestamp(event.timestamp);
const shortSession = shortSessionId(event.sessionId ?? '');
const device = `${event.deviceOs} ${event.deviceOsVersion}`;
return `${timestamp} · ${displayName} ${value} · ${event.appVersion} · ${device} · session ${shortSession}`;
}
/**
* One-line title for a custom log event shown in the observe:session
* candidate picker.
*/
export function formatLogCandidateTitle(event: AppObserveCustomEvent): string {
const timestamp = formatLogTimestamp(event.timestamp);
const severity =
event.severityText ?? (event.severityNumber != null ? String(event.severityNumber) : '-');
const shortSession = shortSessionId(event.sessionId ?? '');
const device = `${event.deviceOs} ${event.deviceOsVersion}`;
return `${timestamp} · ${event.eventName} · ${severity} · ${event.appVersion} · ${device} · session ${shortSession}`;
}
@@ -60,3 +60,18 @@ export function resolveNavigationMetricName(input: string): string {
export function getMetricDisplayName(metricName: string): string {
return METRIC_SHORT_NAMES[metricName] ?? metricName;
}
/**
* Non-throwing predicate: true when `input` is a known metric alias (either
* app-startup or navigation) or a known full metric name. Used to decide
* whether an event name should be treated as a metric or as a custom log
* event without forcing the caller into a try/catch around `resolveMetricName`.
*/
export function isKnownMetricName(input: string): boolean {
return (
input in METRIC_ALIASES ||
input in NAVIGATION_METRIC_ALIASES ||
KNOWN_FULL_NAMES.has(input) ||
KNOWN_FULL_NAVIGATION_NAMES.has(input)
);
}
@@ -0,0 +1,194 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../graphql/generated';
import { EnvironmentVariableWithFileContent } from '../../graphql/queries/EnvironmentVariablesQuery';
import {
formatEnvironmentVariableDiffAsync,
getEnvironmentVariableNamesFromEnvFile,
} from '../environmentVariableDiff';
jest.mock('fs-extra');
const envDir = '/project/.eas/.env';
function variable(
name: string,
value: string | null,
overrides: Partial<EnvironmentVariableWithFileContent> = {}
): EnvironmentVariableWithFileContent {
return {
id: name,
name,
value,
environments: ['development'],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
...overrides,
};
}
describe(formatEnvironmentVariableDiffAsync, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('renders a fresh pull as a neutral baseline', async () => {
const result = await formatEnvironmentVariableDiffAsync({
environmentVariables: [variable('ONE', '1'), variable('TWO', '2')],
currentEnvValues: {},
existingVariableNames: new Set(),
envDir,
targetExists: false,
});
expect(result).toEqual([' ONE', ' TWO']);
});
it('renders variables as added when the target file exists but is empty', async () => {
const result = await formatEnvironmentVariableDiffAsync({
environmentVariables: [variable('ONE', '1')],
currentEnvValues: {},
existingVariableNames: new Set(),
envDir,
targetExists: true,
});
expect(result).toEqual([chalk.green('+ ONE')]);
});
it('renders added, changed, unchanged, and removed string variables', async () => {
const result = await formatEnvironmentVariableDiffAsync({
environmentVariables: [
variable('ADDED', 'new'),
variable('CHANGED', 'new'),
variable('UNCHANGED', 'same'),
],
currentEnvValues: { CHANGED: 'old', UNCHANGED: 'same', REMOVED: 'old' },
existingVariableNames: new Set(['CHANGED', 'UNCHANGED', 'REMOVED']),
envDir,
targetExists: true,
});
expect(result).toEqual([
chalk.green('+ ADDED'),
chalk.yellow('~ CHANGED'),
' UNCHANGED',
chalk.red('- REMOVED'),
]);
});
it('keeps existing secrets neutral without claiming their values match', async () => {
const secret = variable('SECRET', null, {
visibility: EnvironmentVariableVisibility.Secret,
});
const result = await formatEnvironmentVariableDiffAsync({
environmentVariables: [
secret,
variable('NEW_SECRET', null, {
visibility: EnvironmentVariableVisibility.Secret,
}),
],
currentEnvValues: {},
existingVariableNames: new Set(['SECRET']),
envDir,
targetExists: true,
});
expect(result).toEqual([' SECRET', chalk.green('+ NEW_SECRET')]);
});
it('only considers a file unchanged when its output path and contents match', async () => {
const fileContents = Buffer.from('contents').toString('base64');
const fileVariable = variable('FILE', null, {
type: EnvironmentSecretType.FileBase64,
valueWithFileContent: fileContents,
});
const expectedFilePath = path.join(envDir, fileVariable.name);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.pathExists).mockResolvedValue(true);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.readFile).mockResolvedValue(fileContents);
await expect(
formatEnvironmentVariableDiffAsync({
environmentVariables: [fileVariable],
currentEnvValues: { FILE: expectedFilePath },
existingVariableNames: new Set(['FILE']),
envDir,
targetExists: true,
})
).resolves.toEqual([' FILE']);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.readFile).mockResolvedValue(Buffer.from('different').toString('base64'));
await expect(
formatEnvironmentVariableDiffAsync({
environmentVariables: [fileVariable],
currentEnvValues: { FILE: expectedFilePath },
existingVariableNames: new Set(['FILE']),
envDir,
targetExists: true,
})
).resolves.toEqual([chalk.yellow('~ FILE')]);
await expect(
formatEnvironmentVariableDiffAsync({
environmentVariables: [fileVariable],
currentEnvValues: { FILE: '/some/other/file' },
existingVariableNames: new Set(['FILE']),
envDir,
targetExists: true,
})
).resolves.toEqual([chalk.yellow('~ FILE')]);
});
it('reports missing or unreadable file variables as changed instead of failing', async () => {
const fileVariable = variable('FILE', null, {
type: EnvironmentSecretType.FileBase64,
valueWithFileContent: Buffer.from('contents').toString('base64'),
});
const expectedFilePath = path.join(envDir, fileVariable.name);
const options = {
environmentVariables: [fileVariable],
currentEnvValues: { FILE: expectedFilePath },
existingVariableNames: new Set(['FILE']),
envDir,
targetExists: true,
};
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.pathExists).mockResolvedValue(false);
await expect(formatEnvironmentVariableDiffAsync(options)).resolves.toEqual([
chalk.yellow('~ FILE'),
]);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.pathExists).mockResolvedValue(true);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.readFile).mockRejectedValue(new Error('EACCES'));
await expect(formatEnvironmentVariableDiffAsync(options)).resolves.toEqual([
chalk.yellow('~ FILE'),
]);
});
});
describe(getEnvironmentVariableNamesFromEnvFile, () => {
it('includes secret placeholders in the existing variable names', () => {
const result = getEnvironmentVariableNamesFromEnvFile(
['PLAIN=value', '# SECRET=***** (secret)', '# unrelated comment'].join('\n')
);
expect(result.values).toEqual({ PLAIN: 'value' });
expect(result.variableNames).toEqual(new Set(['PLAIN', 'SECRET']));
});
});
@@ -0,0 +1,103 @@
import chalk from 'chalk';
import dotenv from 'dotenv';
import fs from 'fs-extra';
import path from 'path';
import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../graphql/generated';
import { EnvironmentVariableWithFileContent } from '../graphql/queries/EnvironmentVariablesQuery';
interface EnvironmentVariableDiffOptions {
environmentVariables: EnvironmentVariableWithFileContent[];
currentEnvValues: Record<string, string>;
existingVariableNames: Set<string>;
envDir: string;
targetExists: boolean;
}
export function getEnvironmentVariableNamesFromEnvFile(contents: string): {
values: Record<string, string>;
variableNames: Set<string>;
} {
const values = dotenv.parse(contents);
const variableNames = new Set(Object.keys(values));
const secretPlaceholderPattern = /^#\s+([^=\s]+)=\*{5}\s+\(secret\)\s*$/gm;
for (const match of contents.matchAll(secretPlaceholderPattern)) {
variableNames.add(match[1]);
}
return { values, variableNames };
}
async function isFileVariableUnchangedAsync(
currentEnvValue: string | undefined,
newVariable: EnvironmentVariableWithFileContent,
envDir: string
): Promise<boolean> {
const expectedFilePath = path.join(envDir, newVariable.name);
if (currentEnvValue !== expectedFilePath || !newVariable.valueWithFileContent) {
return false;
}
try {
if (!(await fs.pathExists(currentEnvValue))) {
return false;
}
return (await fs.readFile(currentEnvValue, 'base64')) === newVariable.valueWithFileContent;
} catch {
// Diff rendering should never prevent env:pull from replacing an unreadable file.
return false;
}
}
async function isEnvironmentVariableUnchangedAsync(
currentEnvValue: string | undefined,
newVariable: EnvironmentVariableWithFileContent,
envDir: string
): Promise<boolean> {
if (newVariable.visibility === EnvironmentVariableVisibility.Secret) {
// env:pull preserves an existing local secret; it cannot compare it with the remote value.
return true;
}
if (newVariable.type === EnvironmentSecretType.FileBase64) {
return await isFileVariableUnchangedAsync(currentEnvValue, newVariable, envDir);
}
return currentEnvValue === newVariable.value;
}
export async function formatEnvironmentVariableDiffAsync({
environmentVariables,
currentEnvValues,
existingVariableNames,
envDir,
targetExists,
}: EnvironmentVariableDiffOptions): Promise<string[]> {
if (!targetExists) {
return environmentVariables.map(variable => ` ${variable.name}`);
}
const newVariablesByName = new Map(
environmentVariables.map(variable => [variable.name, variable])
);
const diffLog: string[] = [];
for (const variable of environmentVariables) {
if (!existingVariableNames.has(variable.name)) {
diffLog.push(chalk.green(`+ ${variable.name}`));
} else if (
await isEnvironmentVariableUnchangedAsync(currentEnvValues[variable.name], variable, envDir)
) {
diffLog.push(` ${variable.name}`);
} else {
diffLog.push(chalk.yellow(`~ ${variable.name}`));
}
}
for (const variableName of existingVariableNames) {
if (!newVariablesByName.has(variableName)) {
diffLog.push(chalk.red(`- ${variableName}`));
}
}
return diffLog;
}