mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba41af60b9 | ||
|
|
537e3be30f | ||
|
|
f81efbacde | ||
|
|
06c6a8416d |
No files matched your search
@@ -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))
|
||||
|
||||
Generated
+501
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user