Compare commits

...
17 Commits
Author SHA1 Message Date
Expo CI 97553334d4 v24.2.0 2026-09-11 10:52:46 +00:00
Stanisław Chmiela 31d12b9888 [eas-build-job] Remove repository URLs from Git job inputs (#4360)
* [eas-build-job] Allow jobs to omit the Git repository URL

* [build-tools] Separate resolved archive sources from job inputs

* [eas-build-job] Remove repository URLs from Git job inputs

* [eas-build-job] Remove the legacy repository URL schema field

* [eas-build-job] Move changelog entry to unreleased section

* [eas-build-job] Correct unreleased changelog placement
2026-09-11 12:19:58 +02:00
Ash Wu dc2658f63b [eas-cli] add eas workflow:insights:maestro command (#4370)
Signed-off-by: Ash Wu <[email protected]>
2026-09-11 17:57:30 +08:00
Ash Wu b8b9de78fc [eas-cli] add eas workflow:insights command (#4367)
* [eas-cli] add eas workflow:insights command

Signed-off-by: Ash Wu <[email protected]>

* [eas-cli] address review feedback on workflow:insights

- Show workflow file names in the workflows table, which is the value
  --workflow accepts, and add fileName to the JSON output
- Omit buckets with no runs from the runs-over-time table; the JSON output
  keeps every bucket
- Replace the per-command --project-id context resolvers with a protected
  EasCommand helper shared with the observe commands

Signed-off-by: Ash Wu <[email protected]>

* [eas-cli] send a bucket-aligned time range from workflow:insights

The server widens only the runs-over-time query to whole buckets and leaves the
overview and per-workflow queries on the exact bounds, so an unaligned window
made those sections count different runs and the bucket totals could exceed the
overview's total. Widen the window client-side instead, the way the dashboard
does, so the server's own widening is a no-op and every section covers the same
period.

Widening lengthens the window, which can push it past the threshold of the
bucket size it was widened by, so the bucket size is chosen by checking the
widened window rather than the requested one. Without that, --days 4 would ask
for hourly buckets over a 97-hour window and the server would reject it.

- Reject a range that does not run forwards in resolveTimeRange, next to the
  existing flag checks; widening would otherwise turn a backwards range into a
  valid one-bucket query
- Enable JSON output before resolving the time range and the project context,
  both of which can write prose to stdout
- Show a metric's change in parentheses, matching how the observe formatters
  pair a statistic with its secondary figure

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-09-11 17:57:30 +08:00
Stanisław Chmiela 8ff599b1f2 [build-tools] Require PATH sources for local builds (#4362)
* [build-tools] Require PATH sources for local builds

* [build-tools] Document local source validation

* [build-tools] Report invalid local sources as user errors

* [build-tools] Track invalid local sources as system errors
2026-09-11 11:03:45 +02:00
Expo CI e6f02af973 bump STAGING and PRODUCTION to 24.1.2 in cli-versions.json 2026-09-11 00:59:22 +00:00
Expo CI a163b5a76d update CHANGELOG.md for v24.1.2 2026-09-11 00:57:25 +00:00
Expo CI cc37c1c360 v24.1.2 2026-09-11 00:55:13 +00:00
Brent Vatne e78771e0f4 [eas-cli] drop the duplicate non-interactive egress hint and the disconnect warning on stop (#4384) 2026-09-10 17:52:34 -07:00
Brent Vatne 288bc44dfc [eas-cli] shorten the local egress banner and drop the run-it-yourself instruction in interactive mode (#4383) 2026-09-10 17:42:22 -07:00
Expo CI d3db3926e3 bump STAGING and PRODUCTION to 24.1.1 in cli-versions.json 2026-09-11 00:15:23 +00:00
Expo CI 5b87ba0adb update CHANGELOG.md for v24.1.1 2026-09-11 00:13:05 +00:00
Expo CI 0b2cea7bb7 v24.1.1 2026-09-11 00:10:40 +00:00
Brent Vatne eab4fc3cd5 [eas-cli] describe the inline egress client instead of asking to start one (#4382) 2026-09-10 17:07:16 -07:00
Brent Vatne e2e8cd245c [eas-cli] improve EasCommand test reliability (#4381) 2026-09-10 17:05:16 -07:00
Expo CI 80d46f0783 bump STAGING and PRODUCTION to 24.1.0 in cli-versions.json 2026-09-10 23:27:17 +00:00
Expo CI 3b6b63a273 update CHANGELOG.md for v24.1.0 2026-09-10 23:24:59 +00:00
85 changed files with 4776 additions and 540 deletions

No files matched your search

+30 -2
View File
@@ -8,6 +8,36 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-build-job] Remove repository URLs from Git job inputs and fetch them when resolving project sources. ([#4360](https://github.com/expo/eas-cli/pull/4360) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Add `eas workflow:insights` to display Workflows Insights for a time range: run counts and success rate compared with the previous period, runs over time, and a per-workflow breakdown, with `--json` output for scripts. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Add `eas workflow:insights:maestro` to display Maestro test insights for a time range: pass and flake rates, a sortable per-flow table, and a single flow's history with error patterns and recent runs, with `--json` output for scripts. ([#4370](https://github.com/expo/eas-cli/pull/4370) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [eas-cli] Reject `--end` without `--start` in the `observe:*`, `update:insights`, `channel:insights`, and `update:view --insights` commands instead of silently falling back to the default time range. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Reject a time range that does not run forwards in the `observe:*`, `update:insights`, `channel:insights`, `update:view --insights`, and `workflow:insights` commands instead of sending it to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Reject non-integer values for `--limit` and `--offset` instead of sending them to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
### 🧹 Chores
- [build-tools] Require PATH project sources for local builds. ([#4362](https://github.com/expo/eas-cli/pull/4362) by [@sjchmiela](https://github.com/sjchmiela))
## [24.1.2](https://github.com/expo/eas-cli/releases/tag/v24.1.2) - 2026-09-11
### 🐛 Bug fixes
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
### 🐛 Bug fixes
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
### 🎉 New features
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
@@ -18,8 +48,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
### 🛠 Breaking changes
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.0.0",
"PRODUCTION": "24.0.0"
"STAGING": "24.1.2",
"PRODUCTION": "24.1.2"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.1.0",
"version": "24.2.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.1.0",
"version": "24.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -40,14 +40,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.0.0",
"@expo/eas-build-job": "24.2.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.0.0",
"@expo/steps": "24.2.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -47,7 +47,7 @@ describe('projectSources', () => {
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
});
it.each([false, true])('uses the correct repository URL (local: %s)', async isLocal => {
it.each([true, false])('uses the refreshed URL (legacy URL: %s)', async includeRepositoryUrl => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
@@ -63,7 +63,9 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
...(includeRepositoryUrl
? { repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git' }
: {}),
gitRef: 'refs/heads/main',
gitCommitHash,
},
@@ -78,7 +80,7 @@ describe('projectSources', () => {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: isLocal ? 'local-build-plugin' : 'eas-build',
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
@@ -107,18 +109,13 @@ describe('projectSources', () => {
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: isLocal
? 'https://x-access-token:[email protected]/expo/eas-cli.git'
: 'https://x-access-token:[email protected]/expo/eas-cli.git',
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
if (isLocal) {
expect(fetchMock).not.toHaveBeenCalled();
}
});
it.each(['http', 'network', 'json', 'schema'])(
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
'throws a system error if refresh fails (%s)',
async failure => {
const robotAccessToken = randomUUID();
@@ -134,7 +131,6 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
@@ -170,6 +166,18 @@ describe('projectSources', () => {
if (failure === 'json') {
throw cause;
}
if (['PATH', 'GCS', 'R2'].includes(failure)) {
return { data: { type: failure, path: '/project.tar.gz', bucketKey: 'project' } };
}
if (failure === 'missing-url') {
return {
data: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
};
}
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
},
} as Response;
@@ -206,7 +214,6 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash,
},
@@ -277,7 +284,6 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
@@ -588,3 +594,55 @@ describe('projectSources', () => {
});
});
});
describe('local project sources', () => {
function createContext(type: ArchiveSourceType): BuildContext<Job> {
return new BuildContext(
{
platform: Platform.IOS,
projectArchive: { type, path: '/source.tar.gz' },
} as Job,
{
env: { EAS_BUILD_RUNNER: 'local-build-plugin', __API_SERVER_URL: 'https://api.expo.dev' },
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
it('copies and unpacks a PATH source without fetching from www', async () => {
await vol.promises.mkdir('/workingdir', { recursive: true });
await vol.promises.writeFile('/source.tar.gz', 'local archive');
const ctx = createContext(ArchiveSourceType.PATH);
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.toEqual({
handled: true,
});
expect(await vol.promises.readFile('/workingdir/project.tar.gz', 'utf8')).toBe('local archive');
expect(spawn).toHaveBeenCalledWith(
'tar',
['-C', ctx.buildDirectory, '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
{ logger: ctx.logger }
);
expect(fetch).not.toHaveBeenCalled();
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
});
it.each(Object.values(ArchiveSourceType).filter(type => type !== ArchiveSourceType.PATH))(
'rejects local %s sources before fetching or unpacking',
async type => {
const ctx = createContext(type);
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toMatchObject({
errorCode: 'SERVER_ERROR',
trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE',
message: `Expected a PATH project source for a local build, received ${type}.`,
});
expect(fetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
}
);
});
+1 -1
View File
@@ -10,7 +10,7 @@ export async function shallowCloneRepositoryAsync({
destinationDirectory,
}: {
logger: bunyan;
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT };
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT; repositoryUrl: string };
destinationDirectory: string;
}): Promise<void> {
const { repositoryUrl } = archiveSource;
@@ -1,11 +1,5 @@
import downloadFile from '@expo/downloader';
import {
ArchiveSource,
ArchiveSourceSchemaZ,
ArchiveSourceType,
Job,
SystemError,
} from '@expo/eas-build-job';
import { ArchiveSourceType, Job, SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
@@ -25,32 +19,26 @@ export async function prepareProjectSourcesAsync<TJob extends Job>(
destinationDirectory: string
): // Return type required to make switch exhaustive.
Promise<{ handled: boolean }> {
let projectArchive: ArchiveSource = ctx.job.projectArchive;
if (ctx.isLocal) {
console.warn('Local build, skipping project archive refresh');
} else {
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
if (!projectArchiveResult.ok) {
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
cause: projectArchiveResult.reason,
});
if (ctx.job.projectArchive.type !== ArchiveSourceType.PATH) {
throw new SystemError(
`Expected a PATH project source for a local build, received ${ctx.job.projectArchive.type}.`,
{ trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE' }
);
}
projectArchive = projectArchiveResult.value;
await prepareProjectSourcesLocallyAsync(ctx, ctx.job.projectArchive.path, destinationDirectory);
return { handled: true };
}
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
if (!projectArchiveResult.ok) {
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
cause: projectArchiveResult.reason,
});
}
const projectArchive = projectArchiveResult.value;
switch (projectArchive.type) {
case ArchiveSourceType.R2:
case ArchiveSourceType.GCS: {
throw new Error('Remote project sources should be resolved earlier to URL');
}
case ArchiveSourceType.PATH: {
await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local
return { handled: true };
}
case ArchiveSourceType.NONE: {
// May be used in no-sources jobs like submission jobs.
return { handled: true };
@@ -254,7 +242,7 @@ async function uploadProjectMetadataAsync(
}
}
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<ArchiveSource> {
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
@@ -292,7 +280,20 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<A
);
}
const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value);
const dataResult = z
.object({
data: z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
]),
})
.safeParse(jsonResult.value);
if (!dataResult.success) {
throw new Error(
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
@@ -14,7 +14,6 @@ const projectTargetDirectory = '/workingdir/project';
const GIT_PROJECT_ARCHIVE = {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/app.git',
gitRef: 'refs/heads/main',
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
};
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "24.0.0",
"version": "24.2.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "24.0.0"
"@expo/steps": "24.2.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "24.0.0",
"version": "24.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -240,7 +240,6 @@ describe('Android.JobSchema', () => {
username: 'turtle-tutorial',
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'http://localhost:3000',
gitRef: 'master',
gitCommitHash: '1b57db5b1cd12638aba0d12da71a2d691416700d',
},
@@ -1,4 +1,8 @@
import {
ArchiveSource,
ArchiveSourceSchema,
ArchiveSourceSchemaZ,
ArchiveSourceType,
EasCliVersionsFetchTimeoutError,
EnvSchema,
SshSettingsZ,
@@ -675,3 +679,36 @@ describe('fetchEasCliVersionsAsync', () => {
}
});
});
describe('Git archive sources', () => {
const source: ArchiveSource = {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '1234567890',
};
it('accepts Git job sources without a repository URL', () => {
expect(ArchiveSourceSchema.validate(source)).toMatchObject({ value: source });
expect(ArchiveSourceSchema.validate(source).error).toBeUndefined();
expect(ArchiveSourceSchemaZ.parse(source)).toEqual(source);
});
it('strips the legacy repository URL with the job validation options', () => {
const archive = {
...source,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
};
const result = ArchiveSourceSchema.validate(archive, { stripUnknown: true });
expect(result.error).toBeUndefined();
expect(result.value).toEqual(source);
expect(ArchiveSourceSchemaZ.parse(archive)).toEqual(source);
});
it('still requires the commit and ref', () => {
for (const field of ['gitCommitHash', 'gitRef'] as const) {
const archive = { ...source, [field]: undefined };
expect(ArchiveSourceSchema.validate(archive).error).toBeDefined();
expect(ArchiveSourceSchemaZ.safeParse(archive).success).toBe(false);
}
});
});
@@ -9,7 +9,6 @@ describe('Generic.JobZ', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -51,7 +50,6 @@ describe('Generic.JobZ', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -96,7 +94,6 @@ describe('Generic.JobZ', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -131,7 +128,6 @@ describe('Generic.JobZ', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -183,7 +179,6 @@ describe('Generic.JobZ', () => {
const job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
@@ -232,7 +227,6 @@ describe('Generic.JobZ', () => {
const job: Omit<Generic.Job, 'customBuildConfig' | 'steps'> = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
-7
View File
@@ -45,11 +45,6 @@ export type ArchiveSource =
| { type: ArchiveSourceType.PATH; path: string }
| {
type: ArchiveSourceType.GIT;
/**
* Url that can be used to clone repository.
* It should contain embedded credentials for private registries.
*/
repositoryUrl: string;
/** A Git ref - points to a branch head, tag head or a branch name. */
gitRef: string | null;
/**
@@ -79,7 +74,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
.when(Joi.object({ type: ArchiveSourceType.GIT }).unknown(), {
then: Joi.object({
type: Joi.string().valid(ArchiveSourceType.GIT).required(),
repositoryUrl: Joi.string().required(),
gitCommitHash: Joi.string().required(),
gitRef: Joi.string().allow(null).required(),
}),
@@ -94,7 +88,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
+237 -131
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -42640,6 +42640,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "IS_WORKFLOW_BUILD",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "LOCAL_BUILDS_NOT_RETRYABLE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "NOT_COMPLETED_YET",
"description": null,
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.1.0",
"version": "24.2.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "24.0.0",
"@expo/eas-json": "24.0.0",
"@expo/eas-build-job": "24.2.0",
"@expo/eas-json": "24.2.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "24.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.0.0",
"@expo/steps": "24.2.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import { ChannelRuntimeInsights } from '../../graphql/queries/ChannelInsightsQuery';
import { formatTimespan } from '../../insights/formatTimespan';
import { formatTimespan, toTimespanJson } from '../../insights/formatTimespan';
import formatFields from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
@@ -90,11 +90,7 @@ export function buildChannelInsightsJson(summary: ChannelInsightsSummary): objec
return {
channel: summary.channelName,
runtimeVersion: summary.runtimeVersion,
timespan: {
start: summary.startTime,
end: summary.endTime,
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
},
timespan: toTimespanJson(summary),
embeddedUpdateTotalUniqueUsers: summary.embeddedUpdateTotalUniqueUsers,
otaTotalUniqueUsers: summary.otaTotalUniqueUsers,
mostPopularUpdates: summary.mostPopularUpdates,
@@ -57,6 +57,7 @@ const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
interface BaseGetContextAsyncArgs {
nonInteractive: boolean;
vcsClientOverride?: Client;
projectIdOverride?: string;
}
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
@@ -183,6 +184,7 @@ export default abstract class EasCommand extends Command {
{
nonInteractive,
vcsClientOverride,
projectIdOverride,
// if specified and not null, the env vars from the selected environment will be fetched from the server
// to resolve dynamic config (if dynamic config context is used) and enable getServerSideEnvironmentVariablesAsync function (if server side environment variables context is used)
withServerSideEnvironment,
@@ -206,6 +208,7 @@ export default abstract class EasCommand extends Command {
sessionManager: this.sessionManager,
analytics: this.analytics,
vcsClientOverride,
projectIdOverride,
withServerSideEnvironment,
}),
]);
@@ -1,3 +1,4 @@
import { Config } from '@oclif/core';
import { v4 as uuidv4 } from 'uuid';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
@@ -24,17 +25,24 @@ jest.mock('../../analytics/AnalyticsManager', () => {
});
jest.mock('../../log');
let originalProcessArgv: string[];
const mockRequestId = uuidv4();
beforeAll(() => {
originalProcessArgv = process.argv;
process.argv = [];
});
/**
* A Config that is never loaded. Letting oclif load one discovers and requires
* every command in the package, which takes seconds under coverage with a cold
* transform cache and has timed out on slow CI runners. Nothing here needs the
* command table or hooks.
*/
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
afterAll(() => {
process.argv = originalProcessArgv;
});
let mockConfig: Config;
const analytics: AnalyticsWithOrchestration = {
logEvent: jest.fn((): void => {}),
@@ -44,42 +52,57 @@ const analytics: AnalyticsWithOrchestration = {
beforeEach(() => {
jest.resetAllMocks();
mockConfig = getMockOclifConfig();
});
/**
* The config oclif handed to the most recently run command. Command.run returns
* a pre-built Config untouched only when it was created by the same @oclif/core
* copy the command extends; otherwise it silently builds and fully loads a new
* one, bringing back the command discovery this suite avoids.
*/
let lastCommandConfig: Config | undefined;
const createTestEasCommand = (): any => {
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
createAnalyticsAsync.mockResolvedValue(analytics);
const EasCommand = require('../EasCommand').default;
class TestEasCommand extends EasCommand {
async runAsync(): Promise<void> {}
async runAsync(): Promise<void> {
lastCommandConfig = this.config;
}
}
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
return TestEasCommand;
};
// These tests go through the static Command.run rather than constructing an
// instance and calling run() like other command tests, because they cover
// oclif's catch and finally wiring, which only runs inside the static path.
describe('EasCommand', () => {
describe('without exceptions', () => {
// The first test in this suite should have an increased timeout
// because of the implementation of Command from @oclif/command.
// It seems that loading config takes significant amount of time
// and I'm not sure how to mock it.
//
// See https://github.com/oclif/command/blob/master/src/command.ts#L80
// and look for "Config.load"
it('ensures the user data is read', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
const SessionManager = jest.requireMock('../../user/SessionManager').default;
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
expect(sessionManagerSpy).toBeCalledTimes(1);
}, 60_000);
});
it('runs with the unloaded config instead of loading one', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run([], mockConfig);
expect(lastCommandConfig).toBe(mockConfig);
expect(mockConfig.plugins.size).toBe(0);
});
it('initializes analytics', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
expect(createAnalyticsAsync).toHaveBeenCalled();
@@ -87,14 +110,14 @@ describe('EasCommand', () => {
it('flushes analytics', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
expect(analytics.flushAsync).toHaveBeenCalled();
});
it('flushes Sentry', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
const Sentry = jest.requireMock('../../sentry').default;
expect(Sentry.flush).toHaveBeenCalled();
@@ -102,7 +125,7 @@ describe('EasCommand', () => {
it('logs events', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
expect(analytics.logEvent).toHaveBeenCalledWith('action', {
action: `eas ${TestEasCommand.id}`,
@@ -114,7 +137,7 @@ describe('EasCommand', () => {
it('flushes analytics', async () => {
const TestEasCommand = createTestEasCommand();
try {
await TestEasCommand.run().then(() => {
await TestEasCommand.run([], mockConfig).then(() => {
throw new Error('foo');
});
} catch {}
@@ -140,7 +163,7 @@ describe('EasCommand', () => {
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(sentryScope.setTag).toHaveBeenCalledWith('command', TestEasCommand.id);
@@ -167,7 +190,7 @@ describe('EasCommand', () => {
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
@@ -184,7 +207,7 @@ describe('EasCommand', () => {
throw error;
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).toBeCalledWith('Unexpected, internal error message');
@@ -204,7 +227,7 @@ describe('EasCommand', () => {
throw error;
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).toBeCalledWith('Unexpected GraphQL error message');
@@ -237,7 +260,7 @@ describe('EasCommand', () => {
throw error;
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).toBeCalledWith(
@@ -273,7 +296,7 @@ describe('EasCommand', () => {
throw error;
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch {}
expect(logErrorSpy).toBeCalledWith(
@@ -294,7 +317,7 @@ describe('EasCommand', () => {
throw new Error('Error message');
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('testEasCommand command failed.');
@@ -310,7 +333,7 @@ describe('EasCommand', () => {
throw new CombinedError({ graphQLErrors });
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
@@ -340,7 +363,7 @@ describe('EasCommand', () => {
throw new CombinedError({ graphQLErrors });
});
try {
await TestEasCommand.run();
await TestEasCommand.run([], mockConfig);
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
@@ -0,0 +1,33 @@
import { EasPaginatedQueryFlags, getLimitFlagWithCustomValues } from '../pagination';
async function parseFlagAsync(flag: { parse?: any }, input: string): Promise<number> {
return await flag.parse(input, {}, {});
}
describe(getLimitFlagWithCustomValues, () => {
const limit = getLimitFlagWithCustomValues({ defaultTo: 50, limit: 100 });
it('accepts integers within the range', async () => {
await expect(parseFlagAsync(limit, '1')).resolves.toBe(1);
await expect(parseFlagAsync(limit, '100')).resolves.toBe(100);
});
it('rejects values outside the range', async () => {
await expect(parseFlagAsync(limit, '0')).rejects.toThrow('--limit must be between 1 and 100');
await expect(parseFlagAsync(limit, '101')).rejects.toThrow('--limit must be between 1 and 100');
});
it('rejects non-integer input instead of passing it to the server', async () => {
await expect(parseFlagAsync(limit, '1.5')).rejects.toThrow('Unable to parse 1.5 as an integer');
await expect(parseFlagAsync(limit, 'ten')).rejects.toThrow('Unable to parse ten as an integer');
});
});
describe('EasPaginatedQueryFlags.offset', () => {
it('accepts zero and rejects non-integers', async () => {
await expect(parseFlagAsync(EasPaginatedQueryFlags.offset, '0')).resolves.toBe(0);
await expect(parseFlagAsync(EasPaginatedQueryFlags.offset, '2.5')).rejects.toThrow(
'Unable to parse 2.5 as an integer'
);
});
});
@@ -7,6 +7,8 @@ export interface ContextOptions {
analytics: Analytics;
nonInteractive: boolean;
vcsClientOverride?: Client;
/** Short-circuits project ID resolution. */
projectIdOverride?: string;
/**
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
*/
@@ -4,7 +4,14 @@ import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
export class ProjectIdContextField extends ContextField<string> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<string> {
async getValueAsync({
nonInteractive,
sessionManager,
projectIdOverride,
}: ContextOptions): Promise<string> {
if (projectIdOverride) {
return projectIdOverride;
}
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
const expBefore = await getPrivateExpoConfigAsync(projectDir);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
@@ -0,0 +1,16 @@
import { ContextOptions } from '../ContextField';
import { ProjectIdContextField } from '../ProjectIdContextField';
import { findProjectDirAndVerifyProjectSetupAsync } from '../contextUtils/findProjectDirAndVerifyProjectSetupAsync';
jest.mock('../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
describe(ProjectIdContextField, () => {
it('returns the override without reading the project directory', async () => {
const projectId = await new ProjectIdContextField().getValueAsync({
projectIdOverride: 'override-project-id',
} as ContextOptions);
expect(projectId).toBe('override-project-id');
expect(findProjectDirAndVerifyProjectSetupAsync).not.toHaveBeenCalled();
});
});
@@ -17,6 +17,12 @@ export const EasNonInteractiveAndJsonFlags = {
}),
};
export const EasProjectIdFlag = {
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
};
export function resolveNonInteractiveAndJsonFlags(flags: {
json?: boolean;
'non-interactive'?: boolean;
@@ -23,8 +23,8 @@ const parseFlagInputStringAsInteger = (
upperLimit: number
): number => {
const inputAsNumber = Number(input);
if (isNaN(inputAsNumber)) {
throw new Error(`Unable to parse ${input} as a number`);
if (!Number.isInteger(inputAsNumber)) {
throw new Error(`Unable to parse ${input} as an integer`);
}
if (inputAsNumber < lowerLimit || inputAsNumber > upperLimit) {
throw new Error(`--${flagName} must be between ${lowerLimit} and ${upperLimit}`);
@@ -0,0 +1,13 @@
import { GraphQLError } from 'graphql';
import { GraphqlError } from '../graphql/client';
export function findPlanGateError(
error: unknown,
errorCodes: ReadonlySet<string>
): GraphQLError | undefined {
if (!(error instanceof GraphqlError)) {
return undefined;
}
return error.graphQLErrors.find(e => errorCodes.has(e?.extensions?.errorCode as string));
}
@@ -11,7 +11,7 @@ import {
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -28,20 +28,7 @@ export default class ChannelInsights extends EasCommand {
description: 'Runtime version to query insights for.',
required: true,
}),
days: Flags.integer({
description:
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...InsightsTimeRangeFlags,
...EasNonInteractiveAndJsonFlags,
};
@@ -40,11 +40,12 @@ describe(ObserveVersions, () => {
function createCommand(argv: string[]): ObserveVersions {
const command = new ObserveVersions(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
jest
.spyOn(command as any, 'getContextAsync')
.mockImplementation(async (_commandClass: any, { projectIdOverride }: any) => ({
projectId: projectIdOverride ?? projectId,
loggedIn: { graphqlClient },
}));
return command;
}
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
@@ -18,7 +19,6 @@ import {
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
@@ -30,7 +30,6 @@ import {
} from '../../observe/formatErrors';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -67,7 +66,7 @@ export default class ObserveErrors extends EasCommand {
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -76,20 +75,16 @@ export default class ObserveErrors extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveErrors);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveErrors,
loggedInOnlyContextDefinition: ObserveErrors.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveErrors, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (!flags.fingerprint && (flags.after || flags.limit)) {
+7 -12
View File
@@ -5,6 +5,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { GraphqlError } from '../../graphql/client';
@@ -16,9 +17,7 @@ import {
} from '../../observe/formatCustomEvents';
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../observe/formatErrors';
import { buildObserveEventDetail, buildObserveEventJson } from '../../observe/formatEvents';
import { ObserveProjectIdFlag } from '../../observe/flags';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ObserveEvent extends EasCommand {
@@ -33,7 +32,7 @@ export default class ObserveEvent extends EasCommand {
};
static override flags = {
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -42,20 +41,16 @@ export default class ObserveEvent extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvent);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveEvent,
loggedInOnlyContextDefinition: ObserveEvent.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvent, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -3,6 +3,7 @@ import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
@@ -15,7 +16,6 @@ import {
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
@@ -29,7 +29,6 @@ import {
} from '../../observe/formatCustomEvents';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -67,7 +66,7 @@ export default class ObserveEvents extends EasCommand {
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
default: false,
}),
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -76,10 +75,6 @@ export default class ObserveEvents extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvents);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
@@ -90,12 +85,12 @@ export default class ObserveEvents extends EasCommand {
);
}
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
command: this,
commandClass: ObserveEvents,
loggedInOnlyContextDefinition: ObserveEvents.loggedInOnlyContextDefinition,
projectIdOverride: flags['project-id'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvents, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
@@ -10,7 +11,6 @@ import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import {
@@ -22,7 +22,6 @@ import {
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -64,7 +63,7 @@ export default class ObserveMetricsSummary extends EasCommand {
})(),
...ObserveTimeRangeFlags,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -73,20 +72,16 @@ export default class ObserveMetricsSummary extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
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'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetricsSummary, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
@@ -20,7 +21,6 @@ import {
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
@@ -28,7 +28,6 @@ import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../obs
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { observePlatformTargetsFromFlag, observePlatformsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -64,7 +63,7 @@ export default class ObserveMetrics extends EasCommand {
...ObserveBuildNumberFlag,
...ObserveUpdateIdFlag,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -73,20 +72,16 @@ export default class ObserveMetrics extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
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'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetrics, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
@@ -14,7 +15,6 @@ import {
ObserveBuildNumberFlag,
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
ObserveUpdateIdFlag,
} from '../../observe/flags';
@@ -28,7 +28,6 @@ import {
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -71,7 +70,7 @@ export default class ObserveRoutes extends EasCommand {
multiple: true,
}),
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -80,20 +79,16 @@ export default class ObserveRoutes extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
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'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveRoutes, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -16,11 +17,7 @@ import {
fetchSessionMetricCandidatesAsync,
verifyObserveSessionAccessAsync,
} from '../../observe/fetchSessions';
import {
ObserveEnvironmentFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import { ObserveEnvironmentFlag, ObserveTimeRangeFlags } from '../../observe/flags';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import {
buildObserveSessionEventsJson,
@@ -33,7 +30,6 @@ import {
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';
@@ -71,7 +67,7 @@ export default class ObserveSession extends EasCommand {
}),
...ObserveTimeRangeFlags,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -80,20 +76,16 @@ export default class ObserveSession extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
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'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveSession, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -1,6 +1,7 @@
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
@@ -8,12 +9,10 @@ import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
import {
ObserveEnvironmentFlag,
ObservePlatformFlag,
ObserveProjectIdFlag,
ObserveTimeRangeFlags,
} from '../../observe/flags';
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -24,7 +23,7 @@ export default class ObserveVersions extends EasCommand {
...ObservePlatformFlag,
...ObserveTimeRangeFlags,
...ObserveEnvironmentFlag,
...ObserveProjectIdFlag,
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -33,20 +32,16 @@ export default class ObserveVersions extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
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'],
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveVersions, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
@@ -976,11 +976,17 @@ describe(Simulator, () => {
await command.runAsync();
expect(fs.writeFile).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Start `eas simulator:egress --config-type env` in another process')
expect.stringContaining(
'Run the egress client to connect the tunnel:\n\neas simulator:egress --config-type env\n'
)
);
expect(Log.log).not.toHaveBeenCalledWith(
expect.stringContaining('Start `eas simulator:egress` in another process')
expect.stringContaining(
'Run the egress client to connect the tunnel:\n\neas simulator:egress\n'
)
);
// The instructions block is the only place the command is mentioned.
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('in another process'));
});
it.each(localEgressSessions)(
@@ -59,9 +59,7 @@ export default class SimulatorEgress extends EasCommand {
...egress,
signal: abortController.signal,
onConnected: () => {
Log.succeed(
"Egress tunnel connected. Proxied HTTP(S) requests can use this machine's network."
);
Log.succeed('Egress tunnel connected.');
},
onDisconnected: () => {
Log.warn(
@@ -419,7 +419,10 @@ export default class Simulator extends EasCommand {
Log.newLine();
Log.log(
formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], { egressAllow })
formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], {
egressAllow,
egressClientRunsInline: !nonInteractive,
})
);
Log.newLine();
@@ -427,11 +430,7 @@ export default class Simulator extends EasCommand {
if (nonInteractive) {
sessionInterrupt.dispose();
if (localEgress) {
Log.log(
`Start \`eas simulator:egress${flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Env ? ' --config-type env' : ''}\` in another process to connect the tunnel for proxied HTTP(S) requests.`
);
}
// The instructions above already tell the reader to run eas simulator:egress.
Log.log(
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
);
@@ -444,11 +443,6 @@ export default class Simulator extends EasCommand {
let egressError: Error | undefined;
const egressAbortController = new AbortController();
if (localEgress) {
Log.log(
"🔀 Running the egress client in this terminal. When connected, proxied HTTP(S) requests can use this machine's network."
);
Log.log('Press Ctrl+C to stop both the egress client and the simulator session.');
Log.newLine();
sessionInterrupt.signal.addEventListener(
'abort',
() => {
@@ -460,9 +454,7 @@ export default class Simulator extends EasCommand {
...localEgress,
signal: egressAbortController.signal,
onConnected: () => {
Log.succeed(
"Egress tunnel connected. Proxied HTTP(S) requests can use this machine's network."
);
Log.succeed('Egress tunnel connected.');
},
onDisconnected: () => {
Log.warn(
@@ -6,7 +6,7 @@ import {
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
import { INSIGHTS_DEFAULT_DAYS_BACK, resolveInsightsTimeRange } from '../../insights/timeRange';
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import {
buildUpdateInsightsJson,
@@ -31,19 +31,7 @@ export default class UpdateInsights extends EasCommand {
description: 'Filter to a single platform.',
options: ['ios', 'android'] as const,
})(),
days: Flags.integer({
description: `Show insights from the last N days (default ${INSIGHTS_DEFAULT_DAYS_BACK}, mutually exclusive with --start/--end).`,
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...InsightsTimeRangeFlags,
...EasNonInteractiveAndJsonFlags,
};
@@ -0,0 +1,305 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { WorkflowDeviceTestCaseStatus } from '../../../graphql/generated';
import { WorkflowDeviceTestCaseInsightsQuery } from '../../../graphql/queries/WorkflowDeviceTestCaseInsightsQuery';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import WorkflowInsightsMaestro from '../insights/maestro';
jest.mock('../../../graphql/queries/WorkflowDeviceTestCaseInsightsQuery', () => ({
WorkflowDeviceTestCaseInsightsQuery: {
insightsByAppIdAsync: jest.fn(),
historyByAppIdAsync: jest.fn(),
},
}));
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockInsightsByAppIdAsync = jest.mocked(
WorkflowDeviceTestCaseInsightsQuery.insightsByAppIdAsync
);
const mockHistoryByAppIdAsync = jest.mocked(
WorkflowDeviceTestCaseInsightsQuery.historyByAppIdAsync
);
const mockByIdWorkflowFileNamesAsync = jest.mocked(AppQuery.byIdWorkflowFileNamesAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function metric(currentValue: number, previousValue: number): any {
return { __typename: 'WorkflowDeviceTestCaseInsightsMetric', currentValue, previousValue };
}
const totals: any = {
__typename: 'WorkflowDeviceTestCaseInsightsTotals',
totalRuns: metric(200, 160),
passedCleanCount: metric(170, 140),
flakyCount: metric(10, 4),
failedCount: metric(20, 16),
distinctFlakyTestCount: metric(3, 2),
avgDurationMs: { currentValue: 42100, previousValue: 45000 },
p90DurationMs: { currentValue: 72000, previousValue: null },
};
const timeSeries: any[] = [
{ bucketStartAt: '2026-09-01T00:00:00.000Z', passedClean: 100, flaky: 5, failed: 10 },
];
const insightsResponse: any = {
__typename: 'App',
id: 'app-1',
fullName: '@acme/app',
workflowDeviceTestCaseInsights: {
totals,
timeSeries,
tests: {
edges: [
{
node: {
path: 'flows/login.yaml',
name: 'Login',
totalRuns: 120,
passedCleanCount: 100,
flakyCount: 8,
failedCount: 12,
avgDurationMs: 40000,
p90DurationMs: 70000,
lastRunAt: '2026-09-02T10:30:00.000Z',
lastRunStatus: WorkflowDeviceTestCaseStatus.Failed,
lastRunIsFlaky: false,
},
},
],
pageInfo: { hasNextPage: false },
totalCount: 1,
},
},
};
const historyResponse: any = {
__typename: 'App',
id: 'app-1',
fullName: '@acme/app',
workflowDeviceTestCaseHistory: {
totals,
timeSeries,
errorPatterns: [
{
sampleMessage: 'Element not found',
count: 7,
},
],
recentRuns: {
edges: [
{
node: {
id: 'r1',
status: WorkflowDeviceTestCaseStatus.Failed,
durationMs: 41000,
isFlaky: false,
createdAt: '2026-09-02T10:30:00.000Z',
workflowRunId: 'wr-1',
workflowRunName: 'E2E #42',
gitRef: 'refs/heads/main',
commitSha: 'abcdef1234567',
},
},
],
pageInfo: { hasNextPage: false },
totalCount: 1,
},
},
};
describe(WorkflowInsightsMaestro, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const now = new Date('2026-09-08T12:00:00.000Z');
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({ now });
mockInsightsByAppIdAsync.mockResolvedValue(insightsResponse);
mockHistoryByAppIdAsync.mockResolvedValue(historyResponse);
mockByIdWorkflowFileNamesAsync.mockResolvedValue([
{ id: 'id-of-e2e.yml', fileName: 'e2e.yml' },
]);
});
afterEach(() => {
jest.useRealTimers();
});
function createCommand(argv: string[]): {
command: WorkflowInsightsMaestro;
getContextAsync: jest.SpyInstance;
} {
const command = new WorkflowInsightsMaestro(argv, mockConfig);
const getContextAsync = jest
.spyOn(command as any, 'getContextAsync')
.mockImplementation(async (_commandClass: any, { projectIdOverride }: any) => ({
projectId: projectIdOverride ?? 'app-1',
loggedIn: { graphqlClient },
}));
return { command, getContextAsync };
}
it('queries the overview for the last 7 days sorted by fails with the default limit', async () => {
const { command } = createCommand([]);
await command.runAsync();
expect(mockInsightsByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'app-1',
startTime: '2026-09-01T00:00:00.000Z',
endTime: '2026-09-09T00:00:00.000Z',
filters: undefined,
granularity: 'DAY',
sortField: 'FAILS',
sortDirection: 'DESC',
search: undefined,
first: 50,
});
expect(mockHistoryByAppIdAsync).not.toHaveBeenCalled();
});
it('passes filters, search, sort, and limit to the server', async () => {
const { command } = createCommand([
'--workflow',
'e2e.yml',
'--status',
'PASSED',
'--status',
'FLAKY',
'--tag',
'smoke',
'--git-ref',
'main',
'--search',
'login',
'--sort',
'pass-rate',
'--sort-direction',
'asc',
'--limit',
'10',
]);
await command.runAsync();
expect(mockByIdWorkflowFileNamesAsync).toHaveBeenCalledWith(graphqlClient, 'app-1');
expect(mockInsightsByAppIdAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
filters: {
workflowIds: ['id-of-e2e.yml'],
statuses: ['PASSED_CLEAN', 'FLAKY'],
tags: ['smoke'],
gitRefs: ['refs/heads/main'],
},
search: 'login',
sortField: 'PASS_RATE',
sortDirection: 'ASC',
first: 10,
})
);
});
it('queries one flow history with --flow, keeping only the workflow and git ref filters', async () => {
const { command } = createCommand([
'--flow',
'flows/login.yaml',
'--workflow',
'e2e.yml',
'--git-ref',
'main',
]);
await command.runAsync();
expect(mockHistoryByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'app-1',
path: 'flows/login.yaml',
startTime: '2026-09-01T00:00:00.000Z',
endTime: '2026-09-09T00:00:00.000Z',
filters: { workflowIds: ['id-of-e2e.yml'], gitRefs: ['refs/heads/main'] },
granularity: 'DAY',
errorPatternsFirst: 5,
recentRunsFirst: 50,
});
expect(mockInsightsByAppIdAsync).not.toHaveBeenCalled();
});
it('rejects overview-only flags together with --flow', async () => {
const { command } = createCommand(['--flow', 'flows/login.yaml', '--status', 'FAILED']);
await expect(command.runAsync()).rejects.toThrow();
expect(mockHistoryByAppIdAsync).not.toHaveBeenCalled();
});
it('rejects unknown statuses', async () => {
const { command } = createCommand(['--status', 'BROKEN']);
await expect(command.runAsync()).rejects.toThrow();
});
it('skips the project directory when --project-id is given', async () => {
const { command, getContextAsync } = createCommand(['--project-id', 'other-app']);
await command.runAsync();
expect(getContextAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ projectIdOverride: 'other-app' })
);
expect(mockInsightsByAppIdAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ appId: 'other-app' })
);
});
it('emits the overview as JSON', async () => {
const { command } = createCommand(['--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(json.project).toBe('@acme/app');
expect(json.totals.passRatePercent).toEqual({ current: 90, previous: 90 });
expect(json.totals.avgDurationMs).toEqual({ current: 42100, previous: 45000 });
expect(json.flows[0].path).toBe('flows/login.yaml');
expect(json.sort).toEqual({ field: 'fails', direction: 'desc' });
});
it('emits a flow history as JSON', async () => {
const { command } = createCommand([
'--flow',
'flows/login.yaml',
'--json',
'--non-interactive',
]);
await command.runAsync();
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(json.flow).toBe('flows/login.yaml');
expect(json.errorPatterns).toHaveLength(1);
expect(json.recentRuns[0].workflowRunId).toBe('wr-1');
});
it('turns a plan-gate rejection into a readable error', async () => {
const graphQLError = new GraphQLError(
'Maestro insights are not available for your current plan.',
null,
null,
null,
null,
null,
{ errorCode: 'EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_NOT_AVAILABLE' }
);
mockInsightsByAppIdAsync.mockRejectedValue(
new CombinedError({ graphQLErrors: [graphQLError] })
);
const { command } = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
'Maestro insights are not available for your current plan.'
);
});
});
@@ -0,0 +1,298 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { WorkflowsInsightsQuery } from '../../../graphql/queries/WorkflowsInsightsQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import WorkflowInsights from '../insights';
jest.mock('../../../graphql/queries/WorkflowsInsightsQuery', () => ({
WorkflowsInsightsQuery: { byAppIdAsync: jest.fn() },
}));
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockByAppIdAsync = jest.mocked(WorkflowsInsightsQuery.byAppIdAsync);
const mockByIdWorkflowFileNamesAsync = jest.mocked(AppQuery.byIdWorkflowFileNamesAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function metric(currentValue: number, previousValue: number): any {
return { __typename: 'WorkflowsInsightsMetric', currentValue, previousValue };
}
const workflows = [{ __typename: 'Workflow', id: 'wf-build', fileName: 'build.yml' }];
const appResponse: any = {
__typename: 'App',
id: 'app-1',
fullName: '@acme/app',
workflows,
workflowsInsights: {
__typename: 'AppWorkflowsInsights',
overviewMetrics: {
__typename: 'WorkflowsInsightsOverviewMetrics',
totalRuns: metric(100, 80),
successfulRuns: metric(75, 64),
failedRuns: metric(20, 10),
activeWorkflows: metric(3, 3),
},
runsOverTime: {
__typename: 'WorkflowsInsightsRunsOverTimeData',
lineChart: {
__typename: 'LineChartData',
labels: ['2026-09-01T00:00:00.000Z'],
datasets: [
{ id: 'WorkflowsInsightsRunsOverTimeDataset:total', label: 'Total Runs', data: [100] },
{
id: 'WorkflowsInsightsRunsOverTimeDataset:success',
data: [75],
},
{ id: 'WorkflowsInsightsRunsOverTimeDataset:failure', label: 'Failed Runs', data: [20] },
{
id: 'WorkflowsInsightsRunsOverTimeDataset:canceled',
data: [5],
},
],
},
},
workflows: {
__typename: 'WorkflowsInsightsWorkflowConnection',
edges: [
{
__typename: 'WorkflowsInsightsWorkflowEdge',
node: {
__typename: 'WorkflowsInsightsWorkflowNode',
workflowId: 'wf-build',
name: 'Build',
totalRuns: 100,
successfulRuns: 75,
failedRuns: 20,
canceledRuns: 5,
lastRunAt: '2026-09-01T10:00:00.000Z',
},
},
],
pageInfo: { __typename: 'PageInfo', hasNextPage: false },
},
},
};
describe(WorkflowInsights, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const now = new Date('2026-09-08T12:00:00.000Z');
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({ now });
mockByAppIdAsync.mockResolvedValue(appResponse);
mockByIdWorkflowFileNamesAsync.mockResolvedValue(workflows);
});
afterEach(() => {
jest.useRealTimers();
});
function createCommand(argv: string[]): {
command: WorkflowInsights;
getContextAsync: jest.SpyInstance;
} {
const command = new WorkflowInsights(argv, mockConfig);
const getContextAsync = jest
.spyOn(command as any, 'getContextAsync')
.mockImplementation(async (_commandClass: any, { projectIdOverride }: any) => ({
projectId: projectIdOverride ?? 'app-1',
loggedIn: { graphqlClient },
}));
return { command, getContextAsync };
}
it('queries the last 7 days by whole days with no filters and the default limit', async () => {
const { command } = createCommand([]);
await command.runAsync();
expect(mockByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'app-1',
startTime: '2026-09-01T00:00:00.000Z',
endTime: '2026-09-09T00:00:00.000Z',
filters: undefined,
granularity: 'DAY',
first: 50,
});
});
it('picks hourly buckets for --days 1 and per-minute buckets for a short explicit range', async () => {
const { command: dayCommand } = createCommand(['--days', '1']);
await dayCommand.runAsync();
expect(mockByAppIdAsync).toHaveBeenLastCalledWith(
graphqlClient,
expect.objectContaining({ startTime: '2026-09-07T12:00:00.000Z', granularity: 'HOUR' })
);
const { command: rangeCommand } = createCommand([
'--start',
'2026-09-08T10:00:00.000Z',
'--end',
'2026-09-08T11:00:00.000Z',
]);
await rangeCommand.runAsync();
expect(mockByAppIdAsync).toHaveBeenLastCalledWith(
graphqlClient,
expect.objectContaining({
startTime: '2026-09-08T10:00:00.000Z',
endTime: '2026-09-08T11:00:00.000Z',
granularity: 'MINUTE',
})
);
});
// Four days is exactly the longest window the server accepts hourly buckets for, so widening
// to whole hours would push it past that limit.
it('drops --days 4 to whole days when the clock is not on the hour', async () => {
jest.setSystemTime(new Date('2026-09-08T12:20:30.000Z'));
const { command } = createCommand(['--days', '4']);
await command.runAsync();
expect(mockByAppIdAsync).toHaveBeenLastCalledWith(
graphqlClient,
expect.objectContaining({
startTime: '2026-09-04T00:00:00.000Z',
endTime: '2026-09-09T00:00:00.000Z',
granularity: 'DAY',
})
);
});
// --json redirects stdout by swapping process.stdout.write, so it has to be in place before
// anything that can log — including the error this range triggers.
it('rejects a backwards explicit range after enabling JSON output and before resolving context', async () => {
const { command, getContextAsync } = createCommand([
'--json',
'--non-interactive',
'--start',
'2026-09-08T11:00:45.000Z',
'--end',
'2026-09-08T11:00:15.000Z',
]);
await expect(command.runAsync()).rejects.toThrow(/requested time range is empty/);
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(getContextAsync).not.toHaveBeenCalled();
expect(mockByAppIdAsync).not.toHaveBeenCalled();
});
it('rejects --end without --start', async () => {
const { command } = createCommand(['--end', '2026-09-08T11:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow(/--end requires --start/);
expect(mockByAppIdAsync).not.toHaveBeenCalled();
});
it('rejects --days combined with --start', async () => {
const { command } = createCommand(['--days', '7', '--start', '2026-09-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('resolves workflow file names and passes the other filters to the server', async () => {
const { command } = createCommand([
'--workflow',
'build.yml',
'--status',
'FAILURE',
'--status',
'CANCELED',
'--trigger',
'GITHUB_PUSH',
'--git-ref',
'main',
]);
await command.runAsync();
expect(mockByIdWorkflowFileNamesAsync).toHaveBeenCalledWith(graphqlClient, 'app-1');
expect(mockByAppIdAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
filters: {
workflowIds: ['wf-build'],
statuses: ['FAILURE', 'CANCELED'],
triggerEventTypes: ['GITHUB_PUSH'],
gitRefRequested: ['refs/heads/main'],
},
})
);
});
it('only accepts finished-run statuses', async () => {
const { command } = createCommand(['--status', 'IN_PROGRESS']);
await expect(command.runAsync()).rejects.toThrow();
expect(mockByAppIdAsync).not.toHaveBeenCalled();
});
it('skips the project directory when --project-id is given', async () => {
const { command, getContextAsync } = createCommand(['--project-id', 'other-app']);
await command.runAsync();
expect(getContextAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ projectIdOverride: 'other-app' })
);
expect(mockByAppIdAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ appId: 'other-app' })
);
});
it('rejects a non-integer --limit before querying', async () => {
const { command } = createCommand(['--limit', '1.5']);
await expect(command.runAsync()).rejects.toThrow(/as an integer/);
expect(mockByAppIdAsync).not.toHaveBeenCalled();
});
it('passes --limit through as the page size', async () => {
const { command } = createCommand(['--limit', '10']);
await command.runAsync();
expect(mockByAppIdAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ first: 10 })
);
});
it('emits JSON when --json is passed', async () => {
const { command } = createCommand(['--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(json.project).toBe('@acme/app');
expect(json.timespan).toEqual({
start: '2026-09-01T00:00:00.000Z',
end: '2026-09-09T00:00:00.000Z',
});
expect(json.overview.totalRuns).toEqual({ current: 100, previous: 80 });
expect(json.overview.successRatePercent).toEqual({ current: 75, previous: 80 });
expect(json.runsOverTime.granularity).toBe('DAY');
expect(json.workflows[0].name).toBe('Build');
expect(json.workflows[0].fileName).toBe('build.yml');
});
it('turns a plan-gate rejection into a readable error with the plan limit', async () => {
const graphQLError = new GraphQLError(
'The selected timeframe exceeds the supported range for workflow insights.',
null,
null,
null,
null,
null,
{ errorCode: 'EAS_WORKFLOWS_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED', metadata: { limitDays: 30 } }
);
mockByAppIdAsync.mockRejectedValue(new CombinedError({ graphQLErrors: [graphQLError] }));
const { command } = createCommand(['--days', '90']);
await expect(command.runAsync()).rejects.toThrow(
'The selected timeframe exceeds the supported range for workflow insights. Your plan includes the last 30 days.'
);
});
});
@@ -0,0 +1,110 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
import { WorkflowRunTriggerEventType } from '../../../graphql/generated';
import { WorkflowsInsightsQuery } from '../../../graphql/queries/WorkflowsInsightsQuery';
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../../insights/timeRange';
import Log from '../../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
INSIGHTS_RUN_STATUSES,
WorkflowsInsightsSharedFilterFlags,
getAppliedWorkflowsInsightsFilters,
resolveWorkflowsInsightsFiltersInputAsync,
} from '../../../workflow/insights/filters';
import {
buildWorkflowsInsightsJson,
buildWorkflowsInsightsTable,
toWorkflowsInsightsSummary,
} from '../../../workflow/insights/formatInsights';
import { alignInsightsTimespan } from '../../../workflow/insights/granularity';
import { withInsightsPlanGateHandlingAsync } from '../../../workflow/insights/planGating';
const DEFAULT_WORKFLOWS_LIMIT = 50;
const MAX_WORKFLOWS_LIMIT = 100;
export default class WorkflowInsights extends EasCommand {
static override description =
'display run counts, success rate, and per-workflow trends for a time range';
static override flags = {
...WorkflowsInsightsSharedFilterFlags,
status: Flags.option({
description: 'Only include runs with this status (can be specified multiple times).',
options: INSIGHTS_RUN_STATUSES,
multiple: true,
})(),
trigger: Flags.option({
description: 'Only include runs started by this trigger (can be specified multiple times).',
options: Object.values(WorkflowRunTriggerEventType),
multiple: true,
})(),
...InsightsTimeRangeFlags,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_WORKFLOWS_LIMIT,
limit: MAX_WORKFLOWS_LIMIT,
description: `The number of workflows to list. Defaults to ${DEFAULT_WORKFLOWS_LIMIT} and is capped at ${MAX_WORKFLOWS_LIMIT}.`,
}),
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(WorkflowInsights);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const { timespan, granularity } = alignInsightsTimespan(resolveInsightsTimeRange(flags));
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowInsights, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
const filters = await resolveWorkflowsInsightsFiltersInputAsync(
graphqlClient,
projectId,
flags
);
const app = await withInsightsPlanGateHandlingAsync(() =>
WorkflowsInsightsQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
startTime: timespan.startTime,
endTime: timespan.endTime,
filters,
granularity,
first: flags.limit ?? DEFAULT_WORKFLOWS_LIMIT,
})
);
const summary = toWorkflowsInsightsSummary(app, {
timespan,
granularity,
filters: getAppliedWorkflowsInsightsFilters(flags),
});
if (json) {
printJsonOnlyOutput(buildWorkflowsInsightsJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildWorkflowsInsightsTable(summary));
}
}
}
@@ -0,0 +1,179 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
import { WorkflowDeviceTestCaseInsightsQuery } from '../../../graphql/queries/WorkflowDeviceTestCaseInsightsQuery';
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../../insights/timeRange';
import Log from '../../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { WorkflowsInsightsSharedFilterFlags } from '../../../workflow/insights/filters';
import {
buildMaestroFlowHistoryJson,
buildMaestroFlowHistoryTable,
buildMaestroInsightsJson,
buildMaestroInsightsTable,
toMaestroFlowHistorySummary,
toMaestroInsightsSummary,
} from '../../../workflow/insights/formatMaestroInsights';
import { alignMaestroInsightsTimespan } from '../../../workflow/insights/granularity';
import {
MAESTRO_STATUS_OPTIONS,
getAppliedMaestroInsightsFilters,
resolveMaestroHistoryFiltersInputAsync,
resolveMaestroInsightsFiltersInputAsync,
} from '../../../workflow/insights/maestroFilters';
import {
MAESTRO_SORT_DIRECTIONS,
MAESTRO_SORT_OPTIONS,
SORT_DIRECTION_BY_OPTION,
SORT_FIELD_BY_OPTION,
} from '../../../workflow/insights/maestroSort';
import { withInsightsPlanGateHandlingAsync } from '../../../workflow/insights/planGating';
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
const ERROR_PATTERNS_LIMIT = 5;
// These only shape the flows table, so they cannot be combined with a single flow's history.
const OVERVIEW_ONLY_FLAGS = ['status', 'tag', 'search', 'sort', 'sort-direction'];
export default class WorkflowInsightsMaestro extends EasCommand {
static override description =
"display Maestro test insights for a time range: pass and flake rates, per-flow stats, and a single flow's history";
static override flags = {
...WorkflowsInsightsSharedFilterFlags,
status: Flags.option({
description:
'Only include flow runs with this status (can be specified multiple times). PASSED means passed on the first attempt.',
options: MAESTRO_STATUS_OPTIONS,
multiple: true,
exclusive: ['flow'],
})(),
tag: Flags.string({
description: 'Only include flow runs with this tag (can be specified multiple times).',
multiple: true,
exclusive: ['flow'],
}),
search: Flags.string({
description: 'Only list flows whose path contains this text.',
exclusive: ['flow'],
}),
sort: Flags.option({
description: 'Sort the flows table by this column.',
options: MAESTRO_SORT_OPTIONS,
default: 'fails',
exclusive: ['flow'],
})(),
'sort-direction': Flags.option({
description: 'Sort direction for the flows table.',
options: MAESTRO_SORT_DIRECTIONS,
default: 'desc',
exclusive: ['flow'],
})(),
flow: Flags.string({
description: `Show one flow's history instead of the overview, by flow path. Cannot be combined with ${OVERVIEW_ONLY_FLAGS.map(
flag => `--${flag}`
).join(', ')}.`,
exclusive: OVERVIEW_ONLY_FLAGS,
}),
...InsightsTimeRangeFlags,
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_LIMIT,
limit: MAX_LIMIT,
description: `The number of flows to list, or of recent runs with --flow. Defaults to ${DEFAULT_LIMIT} and is capped at ${MAX_LIMIT}.`,
}),
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(WorkflowInsightsMaestro);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const { timespan, granularity } = alignMaestroInsightsTimespan(resolveInsightsTimeRange(flags));
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowInsightsMaestro, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
const appliedFilters = getAppliedMaestroInsightsFilters(flags);
const limit = flags.limit ?? DEFAULT_LIMIT;
if (flags.flow !== undefined) {
const flowPath = flags.flow;
const filters = await resolveMaestroHistoryFiltersInputAsync(graphqlClient, projectId, flags);
const app = await withInsightsPlanGateHandlingAsync(() =>
WorkflowDeviceTestCaseInsightsQuery.historyByAppIdAsync(graphqlClient, {
appId: projectId,
path: flowPath,
startTime: timespan.startTime,
endTime: timespan.endTime,
filters,
granularity,
errorPatternsFirst: ERROR_PATTERNS_LIMIT,
recentRunsFirst: limit,
})
);
const summary = toMaestroFlowHistorySummary(app, {
flowPath,
timespan,
granularity,
filters: appliedFilters,
});
if (json) {
printJsonOnlyOutput(buildMaestroFlowHistoryJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildMaestroFlowHistoryTable(summary));
}
return;
}
const filters = await resolveMaestroInsightsFiltersInputAsync(graphqlClient, projectId, flags);
const sort = { field: flags.sort, direction: flags['sort-direction'] };
const app = await withInsightsPlanGateHandlingAsync(() =>
WorkflowDeviceTestCaseInsightsQuery.insightsByAppIdAsync(graphqlClient, {
appId: projectId,
startTime: timespan.startTime,
endTime: timespan.endTime,
filters,
granularity,
sortField: SORT_FIELD_BY_OPTION[sort.field],
sortDirection: SORT_DIRECTION_BY_OPTION[sort.direction],
search: flags.search,
first: limit,
})
);
const summary = toMaestroInsightsSummary(app, {
timespan,
granularity,
filters: appliedFilters,
sort,
});
if (json) {
printJsonOnlyOutput(buildMaestroInsightsJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildMaestroInsightsTable(summary));
}
}
}
+47
View File
@@ -5998,6 +5998,8 @@ export enum BuildRetryDisabledReason {
AlreadyRetried = 'ALREADY_RETRIED',
InvalidStatus = 'INVALID_STATUS',
IsGithubBuild = 'IS_GITHUB_BUILD',
IsWorkflowBuild = 'IS_WORKFLOW_BUILD',
LocalBuildsNotRetryable = 'LOCAL_BUILDS_NOT_RETRYABLE',
NotCompletedYet = 'NOT_COMPLETED_YET',
TooMuchTimeElapsed = 'TOO_MUCH_TIME_ELAPSED'
}
@@ -16018,6 +16020,13 @@ export type AppByIdWorkflowsQueryVariables = Exact<{
export type AppByIdWorkflowsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, workflows: Array<{ __typename?: 'Workflow', id: string, name?: string | null, fileName: string, createdAt: any, updatedAt: any, revisionsPaginated: { __typename?: 'WorkflowRevisionsConnection', edges: Array<{ __typename?: 'WorkflowRevisionEdge', node: { __typename?: 'WorkflowRevision', id: string, blobSha: string, commitSha?: string | null, createdAt: any, yamlConfig: string } }> } }> } } };
export type AppByIdWorkflowFileNamesQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
export type AppByIdWorkflowFileNamesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, workflows: Array<{ __typename?: 'Workflow', id: string, fileName: string }> } } };
export type AppByIdWorkflowRunsFilteredByStatusQueryVariables = Exact<{
appId: Scalars['String']['input'];
status?: InputMaybe<WorkflowRunStatus>;
@@ -16744,6 +16753,33 @@ export type WebhookByIdQueryVariables = Exact<{
export type WebhookByIdQuery = { __typename?: 'RootQuery', webhook: { __typename?: 'WebhookQuery', byId: { __typename?: 'Webhook', id: string, event: WebhookType, url: string, createdAt: any, updatedAt: any } } };
export type WorkflowDeviceTestCaseInsightsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
filters?: InputMaybe<WorkflowDeviceTestCaseInsightsFiltersInput>;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
sortField?: InputMaybe<WorkflowDeviceTestCaseStatSortField>;
sortDirection?: InputMaybe<WorkflowDeviceTestCaseSortDirection>;
search?: InputMaybe<Scalars['String']['input']>;
first: Scalars['Int']['input'];
}>;
export type WorkflowDeviceTestCaseInsightsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, workflowDeviceTestCaseInsights: { __typename?: 'WorkflowDeviceTestCaseInsights', totals: { __typename?: 'WorkflowDeviceTestCaseInsightsTotals', totalRuns: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number, previousValue: number }, passedCleanCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number, previousValue: number }, flakyCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number, previousValue: number }, distinctFlakyTestCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number, previousValue: number }, avgDurationMs: { __typename?: 'WorkflowDeviceTestCaseInsightsNullableMetric', currentValue?: number | null, previousValue?: number | null } }, timeSeries: Array<{ __typename?: 'WorkflowDeviceTestCaseInsightsBucket', bucketStartAt: any, passedClean: number, flaky: number, failed: number }>, tests: { __typename?: 'WorkflowDeviceTestCaseStatConnection', totalCount: number, edges: Array<{ __typename?: 'WorkflowDeviceTestCaseStatEdge', node: { __typename?: 'WorkflowDeviceTestCaseStat', path: string, name: string, totalRuns: number, passedCleanCount: number, flakyCount: number, failedCount: number, p90DurationMs?: number | null, lastRunAt: any, lastRunStatus: WorkflowDeviceTestCaseStatus, lastRunIsFlaky: boolean } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean } } } } } };
export type WorkflowDeviceTestCaseHistoryByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
path: Scalars['String']['input'];
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
filters?: InputMaybe<WorkflowDeviceTestCaseHistoryFiltersInput>;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
errorPatternsFirst: Scalars['Int']['input'];
recentRunsFirst: Scalars['Int']['input'];
}>;
export type WorkflowDeviceTestCaseHistoryByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, workflowDeviceTestCaseHistory: { __typename?: 'WorkflowDeviceTestCaseHistory', totals: { __typename?: 'WorkflowDeviceTestCaseInsightsTotals', totalRuns: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number }, passedCleanCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number }, flakyCount: { __typename?: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: number }, p90DurationMs: { __typename?: 'WorkflowDeviceTestCaseInsightsNullableMetric', currentValue?: number | null } }, timeSeries: Array<{ __typename?: 'WorkflowDeviceTestCaseInsightsBucket', bucketStartAt: any, passedClean: number, flaky: number, failed: number }>, errorPatterns: Array<{ __typename?: 'WorkflowDeviceTestCaseErrorPattern', sampleMessage: string, count: number }>, recentRuns: { __typename?: 'WorkflowDeviceTestCaseRecentRunConnection', totalCount: number, edges: Array<{ __typename?: 'WorkflowDeviceTestCaseRecentRunEdge', node: { __typename?: 'WorkflowDeviceTestCaseRecentRun', id: string, status: WorkflowDeviceTestCaseStatus, durationMs?: number | null, isFlaky: boolean, createdAt: any, workflowRunId: string, workflowRunName: string, gitRef?: string | null, commitSha?: string | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean } } } } } };
export type WorkflowJobByIdQueryVariables = Exact<{
workflowJobId: Scalars['ID']['input'];
}>;
@@ -16821,6 +16857,17 @@ export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQue
| { __typename: 'User', username: string, id: string }
| null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }>, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } } }> } } } };
export type WorkflowsInsightsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
timespan: WorkflowsInsightsTimespanInput;
filters?: InputMaybe<WorkflowsInsightsFiltersInput>;
granularity: WorkflowsInsightsRunsOverTimeGranularity;
first: Scalars['Int']['input'];
}>;
export type WorkflowsInsightsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, workflows: Array<{ __typename?: 'Workflow', id: string, fileName: string }>, workflowsInsights: { __typename?: 'AppWorkflowsInsights', overviewMetrics: { __typename?: 'WorkflowsInsightsOverviewMetrics', totalRuns: { __typename?: 'WorkflowsInsightsMetric', currentValue: number, previousValue: number }, successfulRuns: { __typename?: 'WorkflowsInsightsMetric', currentValue: number, previousValue: number }, failedRuns: { __typename?: 'WorkflowsInsightsMetric', currentValue: number, previousValue: number }, activeWorkflows: { __typename?: 'WorkflowsInsightsMetric', currentValue: number, previousValue: number } }, runsOverTime: { __typename?: 'WorkflowsInsightsRunsOverTimeData', lineChart: { __typename?: 'LineChartData', labels: Array<string>, datasets: Array<{ __typename?: 'LineDataset', id: string, data: Array<number | null> }> } }, workflows: { __typename?: 'WorkflowsInsightsWorkflowConnection', edges: Array<{ __typename?: 'WorkflowsInsightsWorkflowEdge', node: { __typename?: 'WorkflowsInsightsWorkflowNode', workflowId: string, name: string, totalRuns: number, successfulRuns: number, failedRuns: number, canceledRuns: number, lastRunAt: any } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean } } } } } };
export type AccountFragment = { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
@@ -9,6 +9,7 @@ import {
AppByFullNameQuery,
AppByIdProfileImageUrlQuery,
AppByIdQuery,
AppByIdWorkflowFileNamesQuery,
AppByIdWorkflowRunsFilteredByStatusQuery,
AppByIdWorkflowsQuery,
AppFragment,
@@ -131,6 +132,34 @@ export const AppQuery = {
assert(data.app, 'GraphQL: `app` not defined in server response');
return data.app.byId.workflows;
},
async byIdWorkflowFileNamesAsync(
graphqlClient: ExpoGraphqlClient,
appId: string
): Promise<{ id: string; fileName: string }[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppByIdWorkflowFileNamesQuery>(
gql`
query AppByIdWorkflowFileNamesQuery($appId: String!) {
app {
byId(appId: $appId) {
id
workflows {
id
fileName
}
}
}
}
`,
{ appId },
{ additionalTypenames: ['App', 'Workflow'] }
)
.toPromise()
);
assert(data.app, 'GraphQL: `app` not defined in server response');
return data.app.byId.workflows;
},
async byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
@@ -0,0 +1,256 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
import {
WorkflowDeviceTestCaseHistoryByAppIdQuery,
WorkflowDeviceTestCaseHistoryByAppIdQueryVariables,
WorkflowDeviceTestCaseHistoryFiltersInput,
WorkflowDeviceTestCaseInsightsByAppIdQuery,
WorkflowDeviceTestCaseInsightsByAppIdQueryVariables,
WorkflowDeviceTestCaseInsightsFiltersInput,
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity,
WorkflowDeviceTestCaseSortDirection,
WorkflowDeviceTestCaseStatSortField,
} from '../generated';
export type AppWithMaestroInsightsObject =
WorkflowDeviceTestCaseInsightsByAppIdQuery['app']['byId'];
export type AppWithMaestroFlowHistoryObject =
WorkflowDeviceTestCaseHistoryByAppIdQuery['app']['byId'];
const FEATURE_NAME = 'EAS Maestro insights';
export const WorkflowDeviceTestCaseInsightsQuery = {
async insightsByAppIdAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
startTime,
endTime,
filters,
granularity,
sortField,
sortDirection,
search,
first,
}: {
appId: string;
startTime: string;
endTime: string;
filters?: WorkflowDeviceTestCaseInsightsFiltersInput;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
sortField: WorkflowDeviceTestCaseStatSortField;
sortDirection: WorkflowDeviceTestCaseSortDirection;
search?: string;
first: number;
}
): Promise<AppWithMaestroInsightsObject> {
const data = await withUpgradeRequiredErrorHandlingAsync(
graphqlClient
.query<
WorkflowDeviceTestCaseInsightsByAppIdQuery,
WorkflowDeviceTestCaseInsightsByAppIdQueryVariables
>(
gql`
query WorkflowDeviceTestCaseInsightsByAppId(
$appId: String!
$timespan: WorkflowDeviceTestCaseInsightsTimespanInput!
$filters: WorkflowDeviceTestCaseInsightsFiltersInput
$granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity!
$sortField: WorkflowDeviceTestCaseStatSortField
$sortDirection: WorkflowDeviceTestCaseSortDirection
$search: String
$first: Int!
) {
app {
byId(appId: $appId) {
id
fullName
workflowDeviceTestCaseInsights(timespan: $timespan, filters: $filters) {
totals {
totalRuns {
currentValue
previousValue
}
passedCleanCount {
currentValue
previousValue
}
flakyCount {
currentValue
previousValue
}
distinctFlakyTestCount {
currentValue
previousValue
}
avgDurationMs {
currentValue
previousValue
}
}
timeSeries(granularity: $granularity) {
bucketStartAt
passedClean
flaky
failed
}
tests(
sortField: $sortField
sortDirection: $sortDirection
search: $search
first: $first
) {
edges {
node {
path
name
totalRuns
passedCleanCount
flakyCount
failedCount
p90DurationMs
lastRunAt
lastRunStatus
lastRunIsFlaky
}
}
pageInfo {
hasNextPage
}
totalCount
}
}
}
}
}
`,
{
appId,
timespan: { start: startTime, end: endTime },
filters,
granularity,
sortField,
sortDirection,
search,
first,
},
{ additionalTypenames: ['App', 'WorkflowDeviceTestCaseInsights'] }
)
.toPromise(),
{ featureName: FEATURE_NAME }
);
return data.app.byId;
},
async historyByAppIdAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
path,
startTime,
endTime,
filters,
granularity,
errorPatternsFirst,
recentRunsFirst,
}: {
appId: string;
path: string;
startTime: string;
endTime: string;
filters?: WorkflowDeviceTestCaseHistoryFiltersInput;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
errorPatternsFirst: number;
recentRunsFirst: number;
}
): Promise<AppWithMaestroFlowHistoryObject> {
const data = await withUpgradeRequiredErrorHandlingAsync(
graphqlClient
.query<
WorkflowDeviceTestCaseHistoryByAppIdQuery,
WorkflowDeviceTestCaseHistoryByAppIdQueryVariables
>(
gql`
query WorkflowDeviceTestCaseHistoryByAppId(
$appId: String!
$path: String!
$timespan: WorkflowDeviceTestCaseInsightsTimespanInput!
$filters: WorkflowDeviceTestCaseHistoryFiltersInput
$granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity!
$errorPatternsFirst: Int!
$recentRunsFirst: Int!
) {
app {
byId(appId: $appId) {
id
fullName
workflowDeviceTestCaseHistory(path: $path, timespan: $timespan, filters: $filters) {
totals {
totalRuns {
currentValue
}
passedCleanCount {
currentValue
}
flakyCount {
currentValue
}
p90DurationMs {
currentValue
}
}
timeSeries(granularity: $granularity) {
bucketStartAt
passedClean
flaky
failed
}
errorPatterns(first: $errorPatternsFirst) {
sampleMessage
count
}
recentRuns(first: $recentRunsFirst) {
edges {
node {
id
status
durationMs
isFlaky
createdAt
workflowRunId
workflowRunName
gitRef
commitSha
}
}
pageInfo {
hasNextPage
}
totalCount
}
}
}
}
}
`,
{
appId,
path,
timespan: { start: startTime, end: endTime },
filters,
granularity,
errorPatternsFirst,
recentRunsFirst,
},
{ additionalTypenames: ['App', 'WorkflowDeviceTestCaseHistory'] }
)
.toPromise(),
{ featureName: FEATURE_NAME }
);
return data.app.byId;
},
};
@@ -0,0 +1,116 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
import {
WorkflowsInsightsByAppIdQuery,
WorkflowsInsightsByAppIdQueryVariables,
WorkflowsInsightsFiltersInput,
WorkflowsInsightsRunsOverTimeGranularity,
} from '../generated';
export type AppWithWorkflowsInsightsObject = WorkflowsInsightsByAppIdQuery['app']['byId'];
export const WorkflowsInsightsQuery = {
async byAppIdAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
startTime,
endTime,
filters,
granularity,
first,
}: {
appId: string;
startTime: string;
endTime: string;
filters?: WorkflowsInsightsFiltersInput;
granularity: WorkflowsInsightsRunsOverTimeGranularity;
first: number;
}
): Promise<AppWithWorkflowsInsightsObject> {
const data = await withUpgradeRequiredErrorHandlingAsync(
graphqlClient
.query<WorkflowsInsightsByAppIdQuery, WorkflowsInsightsByAppIdQueryVariables>(
gql`
query WorkflowsInsightsByAppId(
$appId: String!
$timespan: WorkflowsInsightsTimespanInput!
$filters: WorkflowsInsightsFiltersInput
$granularity: WorkflowsInsightsRunsOverTimeGranularity!
$first: Int!
) {
app {
byId(appId: $appId) {
id
fullName
workflows {
id
fileName
}
workflowsInsights {
overviewMetrics(timespan: $timespan, filters: $filters) {
totalRuns {
currentValue
previousValue
}
successfulRuns {
currentValue
previousValue
}
failedRuns {
currentValue
previousValue
}
activeWorkflows {
currentValue
previousValue
}
}
runsOverTime(timespan: $timespan, filters: $filters, granularity: $granularity) {
lineChart {
labels
datasets {
id
data
}
}
}
workflows(timespan: $timespan, filters: $filters, first: $first) {
edges {
node {
workflowId
name
totalRuns
successfulRuns
failedRuns
canceledRuns
lastRunAt
}
}
pageInfo {
hasNextPage
}
}
}
}
}
}
`,
{
appId,
timespan: { start: startTime, end: endTime },
filters,
granularity,
first,
},
{ additionalTypenames: ['App', 'AppWorkflowsInsights', 'Workflow'] }
)
.toPromise(),
{ featureName: 'EAS Workflows insights' }
);
return data.app.byId;
},
};
@@ -0,0 +1,56 @@
import { formatRateWithDelta, formatTrend, ratePercent, toMetricSummary } from '../metrics';
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
describe(toMetricSummary, () => {
it('renames the server pair', () => {
expect(toMetricSummary({ currentValue: 3, previousValue: 4 })).toEqual({
current: 3,
previous: 4,
});
});
});
describe(ratePercent, () => {
it('returns 0 when there is nothing to rate', () => {
expect(ratePercent(0, 0)).toBe(0);
});
it('returns the share as a percentage', () => {
expect(ratePercent(3, 4)).toBe(75);
});
});
describe(formatTrend, () => {
it('is n/a when the previous period had no data', () => {
expect(formatTrend({ current: 10, previous: 0 })).toContain('n/a');
});
it('formats the percentage change with a sign', () => {
expect(formatTrend({ current: 125, previous: 100 })).toContain('+25.0%');
expect(formatTrend({ current: 50, previous: 100 })).toContain('-50.0%');
expect(formatTrend({ current: 100, previous: 100 })).toContain('0.0%');
});
});
describe(formatRateWithDelta, () => {
it('is n/a while there are no runs to rate', () => {
expect(
stripAnsi(formatRateWithDelta({ current: 0, previous: 50 }, { current: 0, previous: 10 }))
).toBe('n/a');
});
it('shows the rate without a delta when the previous period had no runs', () => {
expect(
stripAnsi(formatRateWithDelta({ current: 80, previous: 0 }, { current: 10, previous: 0 }))
).toBe('80.0% (n/a)');
});
it('shows the change in percentage points', () => {
expect(
stripAnsi(formatRateWithDelta({ current: 80, previous: 75.5 }, { current: 10, previous: 8 }))
).toBe('80.0% (+4.5 pts)');
});
});
@@ -0,0 +1,56 @@
import { resolveInsightsTimeRange } from '../timeRange';
describe(resolveInsightsTimeRange, () => {
const now = new Date('2026-09-08T12:00:00.000Z');
beforeEach(() => {
jest.useFakeTimers({ now });
});
afterEach(() => {
jest.useRealTimers();
});
it('defaults to the last 7 days', () => {
expect(resolveInsightsTimeRange({})).toEqual({
daysBack: 7,
startTime: '2026-09-01T12:00:00.000Z',
endTime: '2026-09-08T12:00:00.000Z',
});
});
it('uses --days when given', () => {
expect(resolveInsightsTimeRange({ days: 1 })).toEqual({
daysBack: 1,
startTime: '2026-09-07T12:00:00.000Z',
endTime: '2026-09-08T12:00:00.000Z',
});
});
it('uses --start and --end when both are given', () => {
expect(
resolveInsightsTimeRange({
start: '2026-08-01T00:00:00.000Z',
end: '2026-08-02T00:00:00.000Z',
})
).toEqual({
daysBack: undefined,
startTime: '2026-08-01T00:00:00.000Z',
endTime: '2026-08-02T00:00:00.000Z',
});
});
it('ends now when only --start is given', () => {
expect(resolveInsightsTimeRange({ start: '2026-09-05T00:00:00.000Z' })).toEqual({
daysBack: undefined,
startTime: '2026-09-05T00:00:00.000Z',
endTime: '2026-09-08T12:00:00.000Z',
});
});
it('rejects --end without --start instead of silently ignoring it', () => {
expect(() => resolveInsightsTimeRange({ end: '2026-09-05T00:00:00.000Z' })).toThrow(
/--end requires --start/
);
});
});
@@ -0,0 +1,9 @@
/** Entries render in the order the caller lists them. */
export function formatAppliedFilters(
entries: [label: string, value: string | string[] | undefined][]
): string {
return entries
.filter(([, value]) => (Array.isArray(value) ? value.length > 0 : Boolean(value)))
.map(([label, value]) => `${label}: ${Array.isArray(value) ? value.join(', ') : value}`)
.join('; ');
}
@@ -0,0 +1,71 @@
import chalk from 'chalk';
import indentString from 'indent-string';
import { toDateOnly, toDateTime } from './formatTimespan';
import renderTextTable from '../utils/renderTextTable';
/** Each insights surface has its own generated enum with these values. */
export type InsightsGranularityValue = 'MINUTE' | 'HOUR' | 'DAY';
const INSIGHTS_GRANULARITY_PRESENTATION: Record<
InsightsGranularityValue,
{
label: string;
unitPlural: string;
columnHeader: string;
formatBucketStart: (isoTimestamp: string) => string;
}
> = {
MINUTE: {
label: 'per minute, UTC',
unitPlural: 'minutes',
columnHeader: 'Time',
formatBucketStart: toDateTime,
},
HOUR: {
label: 'hourly, UTC',
unitPlural: 'hours',
columnHeader: 'Time',
formatBucketStart: toDateTime,
},
DAY: {
label: 'daily, UTC',
unitPlural: 'days',
columnHeader: 'Date',
formatBucketStart: toDateOnly,
},
};
/**
* The server fills the whole window with buckets, so a quiet project would print one zero row
* per period. Only the table omits them; the JSON output keeps every bucket.
*/
export function buildRunsOverTimeSection<Bucket extends { start: string }>(
buckets: Bucket[],
granularity: InsightsGranularityValue,
{
runs,
columns,
toRow,
}: { runs: (bucket: Bucket) => number; columns: string[]; toRow: (bucket: Bucket) => string[] }
): string[] {
const bucketsWithRuns = buckets.filter(bucket => runs(bucket) > 0);
if (bucketsWithRuns.length === 0) {
return [];
}
const presentation = INSIGHTS_GRANULARITY_PRESENTATION[granularity];
const omittedNote =
bucketsWithRuns.length < buckets.length
? `; ${presentation.unitPlural} with no runs omitted`
: '';
const table = renderTextTable(
[presentation.columnHeader, ...columns],
bucketsWithRuns.map(bucket => [presentation.formatBucketStart(bucket.start), ...toRow(bucket)])
);
return [
'',
chalk.bold(`Runs over time (${presentation.label}${omittedNote}):`),
'',
indentString(table, 2),
];
}
@@ -1,8 +1,11 @@
import dateFormat from 'dateformat';
export interface InsightsTimespanFields {
export interface InsightsTimespanBounds {
startTime: string;
endTime: string;
}
export interface InsightsTimespanFields extends InsightsTimespanBounds {
daysBack?: number;
}
@@ -13,6 +16,18 @@ export function formatTimespan(timespan: InsightsTimespanFields): string {
return `${toDateOnly(timespan.startTime)} to ${toDateOnly(timespan.endTime)}`;
}
export function toTimespanJson(timespan: InsightsTimespanFields): object {
return {
start: timespan.startTime,
end: timespan.endTime,
...(timespan.daysBack !== undefined ? { daysBack: timespan.daysBack } : {}),
};
}
export function toDateOnly(isoTimestamp: string): string {
return dateFormat(new Date(isoTimestamp), 'UTC:yyyy-mm-dd');
}
export function toDateTime(isoTimestamp: string): string {
return dateFormat(new Date(isoTimestamp), 'UTC:yyyy-mm-dd HH:MM');
}
+65
View File
@@ -0,0 +1,65 @@
import chalk from 'chalk';
/** A metric for the selected window and for the window of equal length right before it. */
export interface InsightsMetricSummary {
current: number;
previous: number;
}
export function toMetricSummary(metric: {
currentValue: number;
previousValue: number;
}): InsightsMetricSummary {
return { current: metric.currentValue, previous: metric.previousValue };
}
export function ratePercent(part: number, total: number): number {
return total === 0 ? 0 : (part / total) * 100;
}
export function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
export function formatCountWithTrend(
metric: InsightsMetricSummary,
options?: { lowerIsBetter?: boolean }
): string {
return `${metric.current.toLocaleString()} (${formatTrend(metric, options)})`;
}
/** A rate with its change in percentage points. */
export function formatRateWithDelta(
rate: InsightsMetricSummary,
totalRuns: InsightsMetricSummary,
{ lowerIsBetter = false }: { lowerIsBetter?: boolean } = {}
): string {
if (totalRuns.current === 0) {
return chalk.dim('n/a');
}
if (totalRuns.previous === 0) {
return `${formatPercent(rate.current)} (${chalk.dim('n/a')})`;
}
const delta = rate.current - rate.previous;
return `${formatPercent(rate.current)} (${formatSignedChange(delta, ' pts', lowerIsBetter ? delta < 0 : delta > 0)})`;
}
/** A change from zero has no meaningful percentage. */
export function formatTrend(
metric: InsightsMetricSummary,
{ lowerIsBetter = false }: { lowerIsBetter?: boolean } = {}
): string {
if (metric.previous === 0) {
return chalk.dim('n/a');
}
const pct = ((metric.current - metric.previous) / metric.previous) * 100;
return formatSignedChange(pct, '%', lowerIsBetter ? pct < 0 : pct > 0);
}
function formatSignedChange(value: number, unit: string, isImprovement: boolean): string {
if (value === 0) {
return chalk.dim(`0.0${unit}`);
}
const text = `${value > 0 ? '+' : '-'}${Math.abs(value).toFixed(1)}${unit}`;
return isImprovement ? chalk.green(text) : chalk.red(text);
}
@@ -1,7 +1,25 @@
import { Flags } from '@oclif/core';
import { resolveTimeRange } from '../observe/startAndEndTime';
export const INSIGHTS_DEFAULT_DAYS_BACK = 7;
export const InsightsTimeRangeFlags = {
days: Flags.integer({
description: `Show insights from the last N days (default ${INSIGHTS_DEFAULT_DAYS_BACK}, mutually exclusive with --start/--end).`,
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
};
export function resolveInsightsTimeRange(flags: { days?: number; start?: string; end?: string }): {
daysBack?: number;
startTime: string;
@@ -0,0 +1,53 @@
import { resolveTimeRange } from '../startAndEndTime';
describe(resolveTimeRange, () => {
const now = new Date('2026-09-08T12:00:00.000Z');
beforeEach(() => {
jest.useFakeTimers({ now });
});
afterEach(() => {
jest.useRealTimers();
});
it('defaults to the last 60 days', () => {
expect(resolveTimeRange({})).toEqual({
daysBack: 60,
startTime: '2026-07-10T12:00:00.000Z',
endTime: '2026-09-08T12:00:00.000Z',
});
});
it('uses --start and --end when both are given', () => {
expect(
resolveTimeRange({ start: '2026-08-01T00:00:00.000Z', end: '2026-08-02T00:00:00.000Z' })
).toEqual({
daysBack: undefined,
startTime: '2026-08-01T00:00:00.000Z',
endTime: '2026-08-02T00:00:00.000Z',
});
});
it('rejects --end without --start instead of silently ignoring it', () => {
expect(() => resolveTimeRange({ end: '2026-08-02T00:00:00.000Z' })).toThrow(
/--end requires --start/
);
});
it('rejects an invalid --start date', () => {
expect(() => resolveTimeRange({ start: 'yesterday' })).toThrow(/Invalid --start date/);
});
it('rejects a range that ends before it starts', () => {
expect(() =>
resolveTimeRange({ start: '2026-08-02T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' })
).toThrow(/requested time range is empty/);
});
it('rejects a zero-length range', () => {
expect(() =>
resolveTimeRange({ start: '2026-08-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' })
).toThrow(/requested time range is empty/);
});
});
-6
View File
@@ -2,12 +2,6 @@ import { Flags } from '@oclif/core';
import { allowedPlatformFlagValues } from './platforms';
export const ObserveProjectIdFlag = {
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
};
export const ObservePlatformFlag = {
platform: Flags.option({
description: 'Filter by platform ("apple" covers iOS, iPadOS, tvOS, and macOS)',
+1 -4
View File
@@ -2,6 +2,7 @@ import chalk from 'chalk';
import { AppObserveError, AppObserveErrorGroup, PageInfo } from '../graphql/generated';
import renderTextTable from '../utils/renderTextTable';
import { truncate } from '../utils/terminalText';
import { buildTimeRangeDescription, formatLogTimestamp, formatTimestamp } from './formatUtils';
export interface ObserveErrorGroupJson {
@@ -101,10 +102,6 @@ export interface BuildErrorGroupsTableOptions {
endTime?: string;
}
function truncate(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
}
export function buildObserveErrorGroupsTable(
groups: AppObserveErrorGroup[],
options?: BuildErrorGroupsTableOptions
+2 -7
View File
@@ -1,5 +1,5 @@
import { EasCommandError } from '../commandUtils/errors';
import { GraphqlError } from '../graphql/client';
import { findPlanGateError } from '../commandUtils/planGating';
// Must match the server's Observe plan-gate error codes (`ExpoErrorCode`). Both
// signal "this Observe feature is not available on the account's current plan";
@@ -14,12 +14,7 @@ const OBSERVE_PLAN_GATE_ERROR_CODES: ReadonlySet<string> = new Set([
]);
function findObservePlanGateMessage(error: unknown): string | undefined {
if (!(error instanceof GraphqlError)) {
return undefined;
}
return error.graphQLErrors.find(e =>
OBSERVE_PLAN_GATE_ERROR_CODES.has(e?.extensions?.errorCode as string)
)?.message;
return findPlanGateError(error, OBSERVE_PLAN_GATE_ERROR_CODES)?.message;
}
/**
@@ -1,43 +0,0 @@
import EasCommand, { ContextInput } from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
/**
* Shared context resolution for observe commands.
*
* If `projectIdOverride` is provided (typically via `--project-id`), the
* helper only requires the LoggedIn context (so the command can run outside
* of a project directory). Otherwise it uses the full context definition,
* which derives the project ID from the local app config.
*/
export async function resolveObserveCommandContextAsync({
command,
commandClass,
loggedInOnlyContextDefinition,
projectIdOverride,
nonInteractive,
}: {
command: EasCommand;
commandClass: { contextDefinition: ContextInput<any> };
loggedInOnlyContextDefinition: ContextInput<any>;
projectIdOverride?: string;
nonInteractive: boolean;
}): Promise<{ projectId: string; graphqlClient: ExpoGraphqlClient }> {
// `getContextAsync` is `protected` on EasCommand; cast to access from this helper.
const commandWithContextAccess = command as unknown as {
getContextAsync: (
cls: { contextDefinition: ContextInput<any> },
options: { nonInteractive: boolean }
) => Promise<any>;
};
if (projectIdOverride) {
const ctx = await commandWithContextAccess.getContextAsync(
{ contextDefinition: loggedInOnlyContextDefinition },
{ nonInteractive }
);
return { projectId: projectIdOverride, graphqlClient: ctx.loggedIn.graphqlClient };
}
const ctx = await commandWithContextAccess.getContextAsync(commandClass, { nonInteractive });
return { projectId: ctx.projectId, graphqlClient: ctx.loggedIn.graphqlClient };
}
@@ -1,4 +1,5 @@
import { validateDateFlag } from './fetchMetrics';
import { EasCommandError } from '../commandUtils/errors';
export const DEFAULT_DAYS_BACK = 60;
@@ -36,6 +37,9 @@ export function resolveTimeRange(flags: { days?: number; start?: string; end?: s
if (flags.end) {
validateDateFlag(flags.end, '--end');
}
if (flags.end && !flags.start) {
throw new EasCommandError('--end requires --start. Pass both, or use --days instead.');
}
const daysBack = flags.days ?? (flags.start ? undefined : DEFAULT_DAYS_BACK);
const { startTime, endTime } = startAndEndTime({
@@ -44,5 +48,13 @@ export function resolveTimeRange(flags: { days?: number; start?: string; end?: s
end: flags.end,
});
// Reachable by swapping --start and --end, or by passing a --start in the future, since --end
// defaults to now.
if (new Date(startTime) >= new Date(endTime)) {
throw new EasCommandError(
`The requested time range is empty: --start (${startTime}) must be earlier than the end of the range (${endTime}). Pass a --start earlier than the end, or use --days instead.`
);
}
return { daysBack, startTime, endTime };
}
@@ -61,14 +61,37 @@ describe('local egress configuration', () => {
EAS_SIMULATOR_EGRESS_ALLOW: '',
});
const instructions = formatRemoteSessionInstructions(agentDeviceConfigWithEgress, 'dotenv');
expect(instructions).toContain('eas simulator:egress');
expect(instructions).toContain(
"🔀 Local egress: the simulator's HTTP(S) traffic exits from this machine.\n"
);
expect(instructions).toContain('Run the egress client to connect the tunnel');
expect(instructions).not.toContain('may reach');
expect(instructions).toContain('eas simulator:egress');
expect(instructions).toContain('Keep it running for the life of the session.');
expect(instructions).not.toContain('It can also reach');
expect(formatRemoteSessionInstructions(agentDeviceConfigWithEgress, 'env')).toContain(
"export EAS_SIMULATOR_EGRESS_ALLOW=''"
);
});
it('says nothing about starting the client when it runs inline', () => {
for (const configType of ['dotenv', 'env'] as const) {
const instructions = formatRemoteSessionInstructions(
agentDeviceConfigWithEgress,
configType,
{
egressClientRunsInline: true,
}
);
expect(instructions).toContain(
"🔀 Local egress: the simulator's HTTP(S) traffic exits from this machine."
);
expect(instructions).not.toContain('eas simulator:egress');
expect(instructions).not.toContain('Run the egress client');
expect(instructions).not.toContain('Keep it running');
expect(instructions).not.toContain('export EAS_SIMULATOR_EGRESS');
}
});
it('carries the allowed local destinations into the config, env file and instructions', () => {
const egressAllow = ['localhost:3000', '192.168.1.20:8080'];
expect(getLocalEgressConfig(agentDeviceConfigWithEgress, egressAllow)?.allow).toEqual(
@@ -84,19 +107,19 @@ describe('local egress configuration', () => {
egressAllow,
});
expect(instructions).toContain(
"The simulator may reach localhost:3000, 192.168.1.20:8080 on this machine's network."
);
expect(instructions).toContain(
'127.0.0.1:3000 in the simulator reaches the same port on this machine (like adb reverse)'
"🔀 Local egress: the simulator's HTTP(S) traffic exits from this machine. It can also reach localhost:3000, 192.168.1.20:8080."
);
expect(instructions).not.toContain('reachable by name only');
});
it('omits the loopback forwarding notice when no allowed destination is loopback', () => {
it('warns only about loopback entries the device host will not forward', () => {
expect(
formatRemoteSessionInstructions(agentDeviceConfigWithEgress, 'dotenv', {
egressAllow: ['192.168.1.20:8080'],
egressAllow: ['localhost:80', 'localhost:3000'],
})
).not.toContain('adb reverse');
).toContain(
'localhost:80 is reachable by name only: privileged ports and the egress proxy port are not forwarded to 127.0.0.1 in the simulator.'
);
});
});
@@ -122,13 +145,13 @@ describe(getLoopbackForwardPlan, () => {
});
describe(formatLoopbackForwardNotice, () => {
it('describes forwarded ports and name-only entries', () => {
it('describes name-only entries and stays silent about forwarded ports', () => {
expect(formatLoopbackForwardNotice({ ports: [3000, 8082], skipped: ['localhost:80'] })).toEqual(
[
'127.0.0.1:3000, 127.0.0.1:8082 in the simulator reach the same ports on this machine (like adb reverse), so dev server URLs that use 127.0.0.1 work.',
'localhost:80 is reachable by name only: privileged ports and the egress proxy port are not forwarded to 127.0.0.1 in the simulator.',
]
);
expect(formatLoopbackForwardNotice({ ports: [3000], skipped: [] })).toEqual([]);
expect(formatLoopbackForwardNotice({ ports: [], skipped: [] })).toEqual([]);
});
});
+4 -1
View File
@@ -1003,7 +1003,10 @@ export async function runLocalEgressAsync({
case 'disconnected':
if (connected) {
connected = false;
onDisconnected?.(line);
// A disconnect we caused by stopping is not something to warn about.
if (!signal.aborted) {
onDisconnected?.(line);
}
}
break;
case 'other':
+40 -34
View File
@@ -81,6 +81,12 @@ export type LocalEgressConfig = {
export type LocalEgressOptions = {
egressAllow?: readonly string[];
/**
* Whether the calling command runs the egress client itself in the current
* terminal, as interactive `simulator:start` does. When false the reader must
* start `eas simulator:egress` in another process.
*/
egressClientRunsInline?: boolean;
};
/**
@@ -143,23 +149,18 @@ export function getLoopbackForwardPlan(
return { ports: [...ports].sort((a, b) => a - b), skipped };
}
export function formatLoopbackForwardNotice({ ports, skipped }: LoopbackForwardPlan): string[] {
const lines: string[] = [];
if (ports.length > 0) {
const addresses = ports.map(port => `127.0.0.1:${port}`).join(', ');
lines.push(
`${addresses} in the simulator ${ports.length === 1 ? 'reaches' : 'reach'} the same ` +
`${ports.length === 1 ? 'port' : 'ports'} on this machine (like adb reverse), so dev server ` +
'URLs that use 127.0.0.1 work.'
);
/**
* Only the exception is worth a line: forwarded ports just work, but an entry
* the device host will not forward changes what the reader would expect.
*/
export function formatLoopbackForwardNotice({ skipped }: LoopbackForwardPlan): string[] {
if (skipped.length === 0) {
return [];
}
if (skipped.length > 0) {
lines.push(
`${skipped.join(', ')} ${skipped.length === 1 ? 'is' : 'are'} reachable by name only: ` +
'privileged ports and the egress proxy port are not forwarded to 127.0.0.1 in the simulator.'
);
}
return lines;
return [
`${skipped.join(', ')} ${skipped.length === 1 ? 'is' : 'are'} reachable by name only: ` +
'privileged ports and the egress proxy port are not forwarded to 127.0.0.1 in the simulator.',
];
}
export function getLocalEgressEnvironmentVariables(
@@ -258,34 +259,39 @@ export function sanitizeRemoteConfigForJson(
export function formatRemoteSessionInstructions(
remoteConfig: DeviceRunSessionRemoteConfig,
configType: RemoteSessionInstructionsConfigType,
{ egressAllow }: LocalEgressOptions = {}
{ egressAllow, egressClientRunsInline = false }: LocalEgressOptions = {}
): string {
const instructions = formatControllerInstructions(remoteConfig, configType);
const egress = getLocalEgressConfig(remoteConfig, egressAllow);
if (!egress) {
return instructions;
}
const egressCommand =
configType === 'env' ? 'eas simulator:egress --config-type env' : 'eas simulator:egress';
const summary =
"🔀 Local egress: the simulator's HTTP(S) traffic exits from this machine." +
(egress.allow.length > 0 ? ` It can also reach ${egress.allow.join(', ')}.` : '');
return [
instructions,
'',
'🔀 This session can route proxied HTTP(S) requests through this machine.',
...(configType === 'env'
? Object.entries(getLocalEgressEnvironmentVariables(egress)).map(
([key, value]) => `export ${key}='${value}'`
)
: []),
'Run the egress client to connect the tunnel:',
'',
configType === 'env' ? 'eas simulator:egress --config-type env' : 'eas simulator:egress',
'',
'Keep it running for the life of the session.',
...(egress.allow.length > 0
? [
summary,
...formatLoopbackForwardNotice(getLoopbackForwardPlan(egress.allow, egress.port)),
// In interactive mode the client starts in this terminal once the session is
// ready and stops with it, so there is nothing for the reader to run.
...(egressClientRunsInline
? []
: [
...(configType === 'env'
? Object.entries(getLocalEgressEnvironmentVariables(egress)).map(
([key, value]) => `export ${key}='${value}'`
)
: []),
'Run the egress client to connect the tunnel:',
'',
`The simulator may reach ${egress.allow.join(', ')} on this machine's network.`,
...formatLoopbackForwardNotice(getLoopbackForwardPlan(egress.allow, egress.port)),
]
: []),
egressCommand,
'',
'Keep it running for the life of the session.',
]),
].join('\n');
}
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import { UpdateWithInsightsObject } from '../../graphql/queries/UpdateInsightsQuery';
import { formatTimespan, toDateOnly } from '../../insights/formatTimespan';
import { formatTimespan, toDateOnly, toTimespanJson } from '../../insights/formatTimespan';
import formatFields from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
@@ -84,11 +84,7 @@ function toPlatformSummary(update: UpdateWithInsightsObject): UpdateInsightsPlat
export function buildUpdateInsightsJson(summary: UpdateInsightsSummary): object {
return {
groupId: summary.groupId,
timespan: {
start: summary.startTime,
end: summary.endTime,
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
},
timespan: toTimespanJson(summary),
platforms: summary.platforms.map(p => ({
platform: p.platform,
updateId: p.updateId,
@@ -10,3 +10,7 @@ const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F
export function sanitizeTerminalText(value: string): string {
return value.replace(CONTROL_CHARACTERS, '').replace(/\s+/g, ' ').trim();
}
export function truncate(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
}
@@ -0,0 +1,119 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { WorkflowRunStatus, WorkflowRunTriggerEventType } from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import {
getAppliedWorkflowsInsightsFilters,
normalizeGitRef,
resolveWorkflowsInsightsFiltersInputAsync,
} from '../filters';
jest.mock('../../../graphql/queries/AppQuery');
const mockByIdWorkflowFileNamesAsync = jest.mocked(AppQuery.byIdWorkflowFileNamesAsync);
const graphqlClient = {} as ExpoGraphqlClient;
describe(resolveWorkflowsInsightsFiltersInputAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockByIdWorkflowFileNamesAsync.mockResolvedValue([
{ id: 'id-of-build.yml', fileName: 'build.yml' },
{ id: 'id-of-test.yml', fileName: 'test.yml' },
]);
});
it('returns no input when no filter flag is set', async () => {
const input = await resolveWorkflowsInsightsFiltersInputAsync(graphqlClient, 'app-1', {});
expect(input).toBeUndefined();
expect(mockByIdWorkflowFileNamesAsync).not.toHaveBeenCalled();
});
it('resolves workflow file names to IDs', async () => {
const input = await resolveWorkflowsInsightsFiltersInputAsync(graphqlClient, 'app-1', {
workflow: ['build.yml', 'test.yml'],
});
expect(mockByIdWorkflowFileNamesAsync).toHaveBeenCalledWith(graphqlClient, 'app-1');
expect(input).toEqual({ workflowIds: ['id-of-build.yml', 'id-of-test.yml'] });
});
it('rejects unknown workflow file names and lists the ones in the project', async () => {
await expect(
resolveWorkflowsInsightsFiltersInputAsync(graphqlClient, 'app-1', {
workflow: ['build.yml', 'deploy.yml'],
})
).rejects.toThrow(
'Workflow file(s) not found on this project: "deploy.yml". Known: build.yml, test.yml. --workflow takes the workflow file name including the extension; a workflow is listed after its first run.'
);
});
it('says so when the project has no workflows yet', async () => {
mockByIdWorkflowFileNamesAsync.mockResolvedValue([]);
await expect(
resolveWorkflowsInsightsFiltersInputAsync(graphqlClient, 'app-1', {
workflow: ['build.yml', 'test.yml'],
})
).rejects.toThrow(
'Workflow file(s) not found on this project: "build.yml", "test.yml". No workflows are known yet. --workflow takes the workflow file name including the extension; a workflow is listed after its first run.'
);
});
it('passes statuses and triggers through and normalizes the git ref', async () => {
const input = await resolveWorkflowsInsightsFiltersInputAsync(graphqlClient, 'app-1', {
status: [WorkflowRunStatus.Failure],
trigger: [WorkflowRunTriggerEventType.GithubPush],
'git-ref': 'main',
});
expect(input).toEqual({
statuses: ['FAILURE'],
triggerEventTypes: ['GITHUB_PUSH'],
gitRefRequested: ['refs/heads/main'],
});
});
});
describe(getAppliedWorkflowsInsightsFilters, () => {
it('is undefined when no filter flag is set', () => {
expect(getAppliedWorkflowsInsightsFilters({})).toBeUndefined();
});
it('keeps the values as given, with the git ref normalized', () => {
expect(
getAppliedWorkflowsInsightsFilters({
workflow: ['build.yml'],
status: [WorkflowRunStatus.Failure],
'git-ref': 'main',
})
).toEqual({
workflows: ['build.yml'],
statuses: ['FAILURE'],
gitRef: 'refs/heads/main',
});
});
});
describe(normalizeGitRef, () => {
it('leaves a commit SHA alone, since eas workflow:run records runs under one', () => {
expect(normalizeGitRef('5f2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d')).toBe(
'5f2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d'
);
expect(normalizeGitRef('5F2B3C4D5E6F708192A3B4C5D6E7F8091A2B3C4D')).toBe(
'5F2B3C4D5E6F708192A3B4C5D6E7F8091A2B3C4D'
);
});
it('qualifies a short SHA-looking branch name, which git treats as a branch too', () => {
expect(normalizeGitRef('5f2b3c4')).toBe('refs/heads/5f2b3c4');
});
it('prefixes bare branch names with refs/heads/', () => {
expect(normalizeGitRef('main')).toBe('refs/heads/main');
});
it('leaves fully qualified refs alone', () => {
expect(normalizeGitRef('refs/heads/main')).toBe('refs/heads/main');
expect(normalizeGitRef('refs/tags/v1.0.0')).toBe('refs/tags/v1.0.0');
});
});
@@ -0,0 +1,327 @@
import {
WorkflowRunStatus,
WorkflowsInsightsRunsOverTimeGranularity,
} from '../../../graphql/generated';
import { AppWithWorkflowsInsightsObject } from '../../../graphql/queries/WorkflowsInsightsQuery';
import {
WorkflowsInsightsSummary,
buildWorkflowsInsightsJson,
buildWorkflowsInsightsTable,
toWorkflowsInsightsSummary,
} from '../formatInsights';
function metric(currentValue: number, previousValue: number): any {
return { __typename: 'WorkflowsInsightsMetric', currentValue, previousValue };
}
function makeApp(
overrides: {
labels?: string[];
datasets?: { id: string; data: (number | null)[] }[];
workflows?: any[];
hasNextPage?: boolean;
} = {}
): AppWithWorkflowsInsightsObject {
return {
__typename: 'App',
id: 'app-1',
fullName: '@acme/app',
workflows: [
{ __typename: 'Workflow', id: 'wf-1', fileName: 'build.yml' },
{ __typename: 'Workflow', id: 'wf-2', fileName: 'tests.yml' },
],
workflowsInsights: {
__typename: 'AppWorkflowsInsights',
overviewMetrics: {
__typename: 'WorkflowsInsightsOverviewMetrics',
totalRuns: metric(100, 80),
successfulRuns: metric(75, 64),
failedRuns: metric(20, 10),
activeWorkflows: metric(3, 3),
},
runsOverTime: {
__typename: 'WorkflowsInsightsRunsOverTimeData',
lineChart: {
__typename: 'LineChartData',
labels: overrides.labels ?? ['2026-09-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z'],
datasets: overrides.datasets ?? [
// Deliberately out of order: buckets must be matched by dataset id.
{
id: 'WorkflowsInsightsRunsOverTimeDataset:canceled',
data: [3, 2],
},
{
id: 'WorkflowsInsightsRunsOverTimeDataset:total',
data: [60, 40],
},
{
id: 'WorkflowsInsightsRunsOverTimeDataset:failure',
data: [12, null],
},
{
id: 'WorkflowsInsightsRunsOverTimeDataset:success',
data: [45, 30],
},
],
},
},
workflows: {
__typename: 'WorkflowsInsightsWorkflowConnection',
edges: (
overrides.workflows ?? [
{
workflowId: 'wf-1',
name: 'Build',
totalRuns: 60,
successfulRuns: 45,
failedRuns: 12,
canceledRuns: 3,
lastRunAt: '2026-09-02T10:30:00.000Z',
},
{
workflowId: 'wf-2',
name: 'Tests',
totalRuns: 0,
successfulRuns: 0,
failedRuns: 0,
canceledRuns: 0,
lastRunAt: '2026-08-20T08:00:00.000Z',
},
]
).map(node => ({
__typename: 'WorkflowsInsightsWorkflowEdge',
node: { __typename: 'WorkflowsInsightsWorkflowNode', ...node },
})),
pageInfo: { __typename: 'PageInfo', hasNextPage: overrides.hasNextPage ?? false },
},
},
};
}
// The command widens the requested window out to whole buckets and drops daysBack, so a summary
// always carries bounds only.
const TIMESPAN = {
startTime: '2026-08-26T00:00:00.000Z',
endTime: '2026-09-02T00:00:00.000Z',
};
function makeSummary(
overrides: Parameters<typeof makeApp>[0] = {},
filters?: WorkflowsInsightsSummary['filters']
): WorkflowsInsightsSummary {
return toWorkflowsInsightsSummary(makeApp(overrides), {
timespan: TIMESPAN,
granularity: WorkflowsInsightsRunsOverTimeGranularity.Day,
filters,
});
}
describe(toWorkflowsInsightsSummary, () => {
it('copies the overview metrics as current/previous pairs and derives the success rate', () => {
const summary = makeSummary();
expect(summary.appFullName).toBe('@acme/app');
expect(summary.overview.totalRuns).toEqual({ current: 100, previous: 80 });
expect(summary.overview.failedRuns).toEqual({ current: 20, previous: 10 });
expect(summary.overview.successRatePercent).toEqual({ current: 75, previous: 80 });
expect(Object.keys(summary.overview)).toEqual([
'totalRuns',
'successRatePercent',
'activeWorkflows',
'failedRuns',
]);
});
it('matches runs-over-time buckets by dataset id and treats missing points as zero', () => {
const summary = makeSummary();
expect(summary.runsOverTime).toEqual([
{
start: '2026-09-01T00:00:00.000Z',
totalRuns: 60,
successfulRuns: 45,
failedRuns: 12,
canceledRuns: 3,
},
{
start: '2026-09-02T00:00:00.000Z',
totalRuns: 40,
successfulRuns: 30,
failedRuns: 0,
canceledRuns: 2,
},
]);
});
it('fails loudly when the server response lacks a dataset', () => {
expect(() =>
makeSummary({
datasets: [{ id: 'WorkflowsInsightsRunsOverTimeDataset:total', data: [60, 40] }],
})
).toThrow('missing the "WorkflowsInsightsRunsOverTimeDataset:success" dataset');
});
it('derives a per-workflow success rate and keeps the has-more flag', () => {
const summary = makeSummary({ hasNextPage: true });
expect(summary.workflows.map(w => [w.name, w.successRatePercent])).toEqual([
['Build', 75],
['Tests', 0],
]);
expect(summary.hasMoreWorkflows).toBe(true);
});
it('keys each workflow by file name, falling back to the ID for one the app no longer lists', () => {
const app = makeApp();
app.workflows = app.workflows.filter(workflow => workflow.id !== 'wf-2');
const summary = toWorkflowsInsightsSummary(app, {
timespan: TIMESPAN,
granularity: WorkflowsInsightsRunsOverTimeGranularity.Day,
});
expect(summary.workflows.map(w => w.fileName)).toEqual(['build.yml', 'wf-2']);
});
});
describe(buildWorkflowsInsightsJson, () => {
it('builds the JSON payload with timespan, overview, buckets, and workflows', () => {
const json = buildWorkflowsInsightsJson(makeSummary()) as any;
expect(json.project).toBe('@acme/app');
expect(json.timespan).toEqual({
start: TIMESPAN.startTime,
end: TIMESPAN.endTime,
});
expect(json.filters).toBeUndefined();
expect(json.overview.successRatePercent).toEqual({ current: 75, previous: 80 });
expect(json.runsOverTime.granularity).toBe('DAY');
expect(json.runsOverTime.buckets).toHaveLength(2);
expect(json.workflows[0]).toEqual({
workflowId: 'wf-1',
fileName: 'build.yml',
name: 'Build',
totalRuns: 60,
successfulRuns: 45,
failedRuns: 12,
canceledRuns: 3,
successRatePercent: 75,
lastRunAt: '2026-09-02T10:30:00.000Z',
});
expect(json.hasMoreWorkflows).toBe(false);
});
it('includes the applied filters', () => {
const summary = toWorkflowsInsightsSummary(makeApp(), {
timespan: TIMESPAN,
granularity: WorkflowsInsightsRunsOverTimeGranularity.Day,
filters: { workflows: ['build.yml'], gitRef: 'refs/heads/main' },
});
const json = buildWorkflowsInsightsJson(summary) as any;
expect(json.timespan).toEqual({ start: TIMESPAN.startTime, end: TIMESPAN.endTime });
expect(json.filters).toEqual({ workflows: ['build.yml'], gitRef: 'refs/heads/main' });
});
});
describe(buildWorkflowsInsightsTable, () => {
it('renders the header, overview, runs over time, and workflows table', () => {
const table = buildWorkflowsInsightsTable(makeSummary());
expect(table).toContain('Workflows insights:');
expect(table).toContain('@acme/app');
expect(table).toContain('2026-08-26 to 2026-09-02');
expect(table).toContain('Total runs');
expect(table).toContain('+25.0%');
expect(table).toContain('Success rate');
expect(table).toContain('75.0%');
expect(table).toContain('-5.0 pts');
expect(table).toContain('Runs over time (daily, UTC):');
expect(table).toContain('2026-09-01');
expect(table).toContain('build.yml');
expect(table).not.toContain('Build');
expect(table).toContain('2026-09-02 10:30');
expect(table).not.toContain('Filters');
expect(table).not.toContain('with the most runs');
});
it('shows the applied filters and the has-more hint', () => {
const table = buildWorkflowsInsightsTable(
makeSummary(
{ hasNextPage: true },
{ statuses: [WorkflowRunStatus.Failure], gitRef: 'refs/heads/main' }
)
);
expect(table).toContain('Filters');
expect(table).toContain('statuses: FAILURE; git ref: refs/heads/main');
expect(table).toContain('Workflows (showing the 2 with the most runs):');
});
it('labels hourly buckets with the time', () => {
const summary = toWorkflowsInsightsSummary(makeApp(), {
timespan: { startTime: '2026-09-01T00:00:00.000Z', endTime: '2026-09-02T00:00:00.000Z' },
granularity: WorkflowsInsightsRunsOverTimeGranularity.Hour,
});
const table = buildWorkflowsInsightsTable(summary);
expect(table).toContain('Runs over time (hourly, UTC):');
expect(table).toContain('2026-09-01 00:00');
});
it('omits buckets with no runs and says so in the heading', () => {
const table = buildWorkflowsInsightsTable(
makeSummary({
labels: [
'2026-08-27T00:00:00.000Z',
'2026-08-28T00:00:00.000Z',
'2026-08-29T00:00:00.000Z',
],
datasets: [
{ id: 'WorkflowsInsightsRunsOverTimeDataset:total', data: [60, 0, 40] },
{ id: 'WorkflowsInsightsRunsOverTimeDataset:success', data: [45, 0, 30] },
{ id: 'WorkflowsInsightsRunsOverTimeDataset:failure', data: [12, 0, 8] },
{ id: 'WorkflowsInsightsRunsOverTimeDataset:canceled', data: [3, 0, 2] },
],
})
);
expect(table).toContain('Runs over time (daily, UTC; days with no runs omitted):');
expect(table).toContain('2026-08-27');
expect(table).not.toContain('2026-08-28');
expect(table).toContain('2026-08-29');
});
it('skips the runs-over-time table when no bucket has a run', () => {
const zero = (id: string): { id: string; data: number[] } => ({ id, data: [0, 0] });
const summary = makeSummary({
workflows: [],
datasets: [
zero('WorkflowsInsightsRunsOverTimeDataset:total'),
zero('WorkflowsInsightsRunsOverTimeDataset:success'),
zero('WorkflowsInsightsRunsOverTimeDataset:failure'),
zero('WorkflowsInsightsRunsOverTimeDataset:canceled'),
],
});
const table = buildWorkflowsInsightsTable(summary);
expect(table).toContain('No workflow runs in this time range.');
expect(table).not.toContain('Runs over time');
expect((buildWorkflowsInsightsJson(summary) as any).runsOverTime.buckets).toHaveLength(2);
});
it('shows n/a instead of a success rate when there were no runs', () => {
const app = makeApp();
app.workflowsInsights.overviewMetrics.totalRuns = metric(0, 0);
app.workflowsInsights.overviewMetrics.successfulRuns = metric(0, 0);
const table = buildWorkflowsInsightsTable(
toWorkflowsInsightsSummary(app, {
timespan: TIMESPAN,
granularity: WorkflowsInsightsRunsOverTimeGranularity.Day,
})
);
// Chalk styles the label and the value separately.
expect(table.replace(/\x1b\[[0-9;]*m/g, '')).toMatch(/Success rate\s+n\/a/);
expect(table).not.toContain('0.0 pts');
});
});
@@ -0,0 +1,429 @@
import {
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity,
WorkflowDeviceTestCaseStatus,
} from '../../../graphql/generated';
import {
AppWithMaestroFlowHistoryObject,
AppWithMaestroInsightsObject,
} from '../../../graphql/queries/WorkflowDeviceTestCaseInsightsQuery';
import {
MaestroFlowHistorySummary,
MaestroInsightsSummary,
buildMaestroFlowHistoryJson,
buildMaestroFlowHistoryTable,
buildMaestroInsightsJson,
buildMaestroInsightsTable,
formatDurationMs,
toMaestroFlowHistorySummary,
toMaestroInsightsSummary,
} from '../formatMaestroInsights';
function metric(currentValue: number, previousValue: number): any {
return { __typename: 'WorkflowDeviceTestCaseInsightsMetric', currentValue, previousValue };
}
const timeSeries = [
{
__typename: 'WorkflowDeviceTestCaseInsightsBucket' as const,
bucketStartAt: '2026-09-01T00:00:00.000Z',
passedClean: 100,
flaky: 5,
failed: 10,
},
{
__typename: 'WorkflowDeviceTestCaseInsightsBucket' as const,
bucketStartAt: '2026-09-02T00:00:00.000Z',
passedClean: 70,
flaky: 5,
failed: 10,
},
];
function makeApp(
overrides: { flows?: any[]; hasNextPage?: boolean; timeSeries?: typeof timeSeries } = {}
): AppWithMaestroInsightsObject {
return {
__typename: 'App',
id: 'app-1',
fullName: '@acme/app',
workflowDeviceTestCaseInsights: {
__typename: 'WorkflowDeviceTestCaseInsights',
totals: {
__typename: 'WorkflowDeviceTestCaseInsightsTotals',
totalRuns: metric(200, 160),
passedCleanCount: metric(170, 140),
flakyCount: metric(10, 4),
distinctFlakyTestCount: metric(3, 2),
avgDurationMs: {
__typename: 'WorkflowDeviceTestCaseInsightsNullableMetric',
currentValue: 42100,
previousValue: null,
},
},
timeSeries: overrides.timeSeries ?? timeSeries,
tests: {
__typename: 'WorkflowDeviceTestCaseStatConnection',
edges: (
overrides.flows ?? [
{
path: 'flows/login.yaml',
name: 'Login',
totalRuns: 120,
passedCleanCount: 100,
flakyCount: 8,
failedCount: 12,
p90DurationMs: 70000,
lastRunAt: '2026-09-02T10:30:00.000Z',
lastRunStatus: WorkflowDeviceTestCaseStatus.Failed,
lastRunIsFlaky: false,
},
{
path: 'flows/checkout.yaml',
name: 'Checkout',
totalRuns: 80,
passedCleanCount: 70,
flakyCount: 2,
failedCount: 8,
p90DurationMs: null,
lastRunAt: '2026-09-02T09:00:00.000Z',
lastRunStatus: WorkflowDeviceTestCaseStatus.Passed,
lastRunIsFlaky: true,
},
]
).map(node => ({
__typename: 'WorkflowDeviceTestCaseStatEdge',
node: { __typename: 'WorkflowDeviceTestCaseStat', ...node },
})),
pageInfo: { __typename: 'PageInfo', hasNextPage: overrides.hasNextPage ?? true },
totalCount: 5,
},
},
};
}
function makeHistoryApp(): AppWithMaestroFlowHistoryObject {
return {
__typename: 'App',
id: 'app-1',
fullName: '@acme/app',
workflowDeviceTestCaseHistory: {
__typename: 'WorkflowDeviceTestCaseHistory',
totals: {
__typename: 'WorkflowDeviceTestCaseInsightsTotals',
totalRuns: { __typename: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: 200 },
passedCleanCount: { __typename: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: 170 },
flakyCount: { __typename: 'WorkflowDeviceTestCaseInsightsMetric', currentValue: 10 },
p90DurationMs: {
__typename: 'WorkflowDeviceTestCaseInsightsNullableMetric',
currentValue: 72000,
},
},
timeSeries,
errorPatterns: [
{
__typename: 'WorkflowDeviceTestCaseErrorPattern',
sampleMessage: 'Element not found:\n id "login-button"',
count: 7,
},
],
recentRuns: {
__typename: 'WorkflowDeviceTestCaseRecentRunConnection',
edges: [
{
__typename: 'WorkflowDeviceTestCaseRecentRunEdge',
node: {
__typename: 'WorkflowDeviceTestCaseRecentRun',
id: 'r1',
status: WorkflowDeviceTestCaseStatus.Failed,
durationMs: 41000,
isFlaky: false,
createdAt: '2026-09-02T10:30:00.000Z',
workflowRunId: 'wr-1',
workflowRunName: 'E2E #42',
gitRef: 'refs/heads/main',
commitSha: 'abcdef1234567',
},
},
{
__typename: 'WorkflowDeviceTestCaseRecentRunEdge',
node: {
__typename: 'WorkflowDeviceTestCaseRecentRun',
id: 'r2',
status: WorkflowDeviceTestCaseStatus.Passed,
durationMs: null,
isFlaky: true,
createdAt: '2026-09-02T09:00:00.000Z',
workflowRunId: 'wr-2',
workflowRunName: 'E2E #41',
gitRef: null,
commitSha: null,
},
},
],
pageInfo: { __typename: 'PageInfo', hasNextPage: false },
totalCount: 2,
},
},
};
}
// The command widens the requested window out to whole buckets and drops daysBack, so a summary
// always carries bounds only.
const TIMESPAN = {
startTime: '2026-08-26T00:00:00.000Z',
endTime: '2026-09-02T00:00:00.000Z',
};
const SORT = { field: 'fails' as const, direction: 'desc' as const };
function makeSummary(overrides: Parameters<typeof makeApp>[0] = {}): MaestroInsightsSummary {
return toMaestroInsightsSummary(makeApp(overrides), {
timespan: TIMESPAN,
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity.Day,
sort: SORT,
});
}
function makeHistorySummary(
app: AppWithMaestroFlowHistoryObject = makeHistoryApp()
): MaestroFlowHistorySummary {
return toMaestroFlowHistorySummary(app, {
flowPath: 'flows/login.yaml',
timespan: TIMESPAN,
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity.Day,
});
}
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
describe(toMaestroInsightsSummary, () => {
it('derives the overview tiles the way the dashboard does', () => {
const { totals } = makeSummary();
expect(totals).toEqual({
totalRuns: { current: 200, previous: 160 },
passRatePercent: { current: 90, previous: 90 },
distinctFlakyFlows: { current: 3, previous: 2 },
avgDurationMs: { current: 42100, previous: null },
});
});
it('keeps buckets as passed, flaky, and failed', () => {
expect(makeSummary().runsOverTime[0]).toEqual({
start: '2026-09-01T00:00:00.000Z',
passedClean: 100,
flaky: 5,
failed: 10,
});
});
it('derives per-flow rates and keeps unknown durations as null', () => {
const summary = makeSummary();
expect(summary.flows[0]).toEqual({
path: 'flows/login.yaml',
name: 'Login',
totalRuns: 120,
passRatePercent: 90,
failed: 12,
flakeRatePercent: summary.flows[0].flakeRatePercent,
p90DurationMs: 70000,
lastRunAt: '2026-09-02T10:30:00.000Z',
lastRunStatus: 'FAILED',
lastRunIsFlaky: false,
});
expect(summary.flows[0].flakeRatePercent).toBeCloseTo(6.67, 2);
expect(summary.flows[1].p90DurationMs).toBeNull();
expect(summary.totalFlows).toBe(5);
expect(summary.hasMoreFlows).toBe(true);
expect(summary.sort).toEqual(SORT);
});
});
describe(buildMaestroInsightsJson, () => {
it('builds the JSON payload', () => {
const json = buildMaestroInsightsJson(makeSummary()) as any;
expect(json.project).toBe('@acme/app');
expect(json.timespan).toEqual({
start: TIMESPAN.startTime,
end: TIMESPAN.endTime,
});
expect(json.filters).toBeUndefined();
expect(Object.keys(json.totals)).toEqual([
'totalRuns',
'passRatePercent',
'distinctFlakyFlows',
'avgDurationMs',
]);
expect(json.runsOverTime.granularity).toBe('DAY');
expect(json.flows).toHaveLength(2);
expect(json.flows[0].path).toBe('flows/login.yaml');
expect(json.totalFlows).toBe(5);
expect(json.hasMoreFlows).toBe(true);
expect(json.sort).toEqual({ field: 'fails', direction: 'desc' });
});
});
describe(buildMaestroInsightsTable, () => {
it('renders the four overview tiles, runs over time, and the flows table', () => {
const table = buildMaestroInsightsTable(makeSummary());
expect(table).toContain('Maestro insights:');
expect(table).toContain('@acme/app');
expect(table).toContain('Maestro runs');
expect(table).toContain('+25.0%');
expect(table).toContain('Pass rate');
expect(table).toContain('90.0%');
expect(table).toContain('Flaky flows');
expect(table).toContain('Avg duration');
expect(table).toContain('42.1s');
const overview = table.slice(table.indexOf('Overview'), table.indexOf('Runs over time'));
expect(overview).not.toContain('Flake rate');
expect(overview).not.toContain('P90');
expect(overview).not.toContain('Passed');
expect(table).toContain('Runs over time (daily, UTC):');
expect(table).toContain('Flows (showing 2 of 5, sorted by fails desc):');
expect(table).toContain('flows/login.yaml');
expect(table).toContain('70.0s');
expect(table).toContain('FAILED');
expect(table).toContain('FLAKY');
});
it('omits buckets with no runs and says so in the heading', () => {
const bucket = (day: string, passedClean: number): (typeof timeSeries)[number] => ({
__typename: 'WorkflowDeviceTestCaseInsightsBucket',
bucketStartAt: `2026-08-${day}T00:00:00.000Z`,
passedClean,
flaky: 0,
failed: 0,
});
const table = buildMaestroInsightsTable(
makeSummary({ timeSeries: [bucket('27', 10), bucket('28', 0), bucket('29', 4)] })
);
expect(table).toContain('Runs over time (daily, UTC; days with no runs omitted):');
expect(table).toContain('2026-08-27');
expect(table).not.toContain('2026-08-28');
expect(table).toContain('2026-08-29');
});
it('skips the runs-over-time table when no bucket has a run', () => {
const summary = makeSummary({
flows: [],
timeSeries: timeSeries.map(bucket => ({ ...bucket, passedClean: 0, flaky: 0, failed: 0 })),
});
const table = buildMaestroInsightsTable(summary);
expect(table).toContain('No flows matched in this time range.');
expect(table).not.toContain('Runs over time');
expect((buildMaestroInsightsJson(summary) as any).runsOverTime.buckets).toHaveLength(2);
});
it('says so when no flow ran', () => {
const table = buildMaestroInsightsTable(makeSummary({ flows: [], hasNextPage: false }));
expect(table).toContain('No flows matched in this time range.');
});
});
describe(toMaestroFlowHistorySummary, () => {
it('derives the detail tiles without a previous-period comparison', () => {
const summary = makeHistorySummary();
expect(summary.flowPath).toBe('flows/login.yaml');
expect(summary.totals).toEqual({
totalRuns: 200,
passRatePercent: 90,
flakyRuns: 10,
p90DurationMs: 72000,
});
expect(summary.errorPatterns).toEqual([
{ count: 7, sampleMessage: 'Element not found:\n id "login-button"' },
]);
expect(summary.recentRuns[1]).toEqual({
id: 'r2',
status: 'PASSED',
isFlaky: true,
durationMs: null,
createdAt: '2026-09-02T09:00:00.000Z',
workflowRunId: 'wr-2',
workflowRunName: 'E2E #41',
gitRef: null,
commitSha: null,
});
expect(summary.totalRecentRuns).toBe(2);
expect(summary.hasMoreRecentRuns).toBe(false);
});
});
describe(buildMaestroFlowHistoryJson, () => {
it('includes the flow path and the history sections', () => {
const json = buildMaestroFlowHistoryJson(makeHistorySummary()) as any;
expect(json.flow).toBe('flows/login.yaml');
expect(json.totals).toEqual({
totalRuns: 200,
passRatePercent: 90,
flakyRuns: 10,
p90DurationMs: 72000,
});
expect(json.errorPatterns).toHaveLength(1);
expect(json.recentRuns).toHaveLength(2);
expect(json.totalRecentRuns).toBe(2);
});
});
describe(buildMaestroFlowHistoryTable, () => {
it('renders the flow header, detail tiles, error patterns, and recent runs', () => {
const table = buildMaestroFlowHistoryTable(makeHistorySummary());
expect(table).toContain('Maestro flow insights:');
expect(table).toContain('flows/login.yaml');
expect(stripAnsi(table)).toMatch(/Maestro runs\s+200/);
expect(stripAnsi(table)).toMatch(/Pass rate\s+90\.0%/);
expect(stripAnsi(table)).toMatch(/Flaky runs\s+10/);
expect(stripAnsi(table)).toMatch(/P90 duration\s+72\.0s/);
expect(table).not.toContain('previous period');
expect(table).not.toContain('Flaky flows');
expect(table).toContain('Error patterns (top 1):');
expect(table).toContain('Element not found: id "login-button"');
expect(table).toContain('Recent runs (2):');
expect(table).toContain('41.0s');
expect(table).toContain('abcdef1');
expect(table).toContain('E2E #42 (wr-1)');
expect(table).toContain('FLAKY');
});
it('collapses and truncates long error messages to one line', () => {
const app = makeHistoryApp();
app.workflowDeviceTestCaseHistory.errorPatterns[0].sampleMessage = `Assertion failed:\n${'x'.repeat(200)}`;
const table = buildMaestroFlowHistoryTable(makeHistorySummary(app));
const line = table.split('\n').find(l => l.includes('Assertion failed:'));
expect(line).toBeDefined();
expect(line).toContain('Assertion failed: xxx');
expect(line).toContain('…');
expect(line!.length).toBeLessThan(160);
});
it('shows n/a for the pass rate when the flow has no runs', () => {
const app = makeHistoryApp();
app.workflowDeviceTestCaseHistory.totals.totalRuns.currentValue = 0;
app.workflowDeviceTestCaseHistory.totals.passedCleanCount.currentValue = 0;
app.workflowDeviceTestCaseHistory.totals.flakyCount.currentValue = 0;
expect(stripAnsi(buildMaestroFlowHistoryTable(makeHistorySummary(app)))).toMatch(
/Pass rate\s+n\/a/
);
});
});
describe(formatDurationMs, () => {
it('formats like the dashboard', () => {
expect(formatDurationMs(null)).toBe('n/a');
expect(formatDurationMs(850)).toBe('850ms');
expect(formatDurationMs(42100)).toBe('42.1s');
});
});
@@ -0,0 +1,140 @@
import {
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity,
WorkflowsInsightsRunsOverTimeGranularity,
} from '../../../graphql/generated';
import {
alignInsightsTimespan,
alignMaestroInsightsTimespan,
granularityForTimespan,
} from '../granularity';
describe(granularityForTimespan, () => {
const start = '2026-09-01T00:00:00.000Z';
it('uses minutes for windows up to 2 hours', () => {
expect(granularityForTimespan(start, '2026-09-01T02:00:00.000Z')).toBe(
WorkflowsInsightsRunsOverTimeGranularity.Minute
);
});
it('uses hours for windows up to 4 days', () => {
expect(granularityForTimespan(start, '2026-09-01T02:00:01.000Z')).toBe(
WorkflowsInsightsRunsOverTimeGranularity.Hour
);
expect(granularityForTimespan(start, '2026-09-05T00:00:00.000Z')).toBe(
WorkflowsInsightsRunsOverTimeGranularity.Hour
);
});
it('uses days for longer windows', () => {
expect(granularityForTimespan(start, '2026-09-05T00:00:01.000Z')).toBe(
WorkflowsInsightsRunsOverTimeGranularity.Day
);
expect(granularityForTimespan(start, '2026-10-01T00:00:00.000Z')).toBe(
WorkflowsInsightsRunsOverTimeGranularity.Day
);
});
});
describe(alignInsightsTimespan, () => {
it('leaves a window that already sits on bucket boundaries alone', () => {
expect(
alignInsightsTimespan({
startTime: '2026-09-01T10:00:00.000Z',
endTime: '2026-09-01T12:00:00.000Z',
})
).toEqual({
timespan: { startTime: '2026-09-01T10:00:00.000Z', endTime: '2026-09-01T12:00:00.000Z' },
granularity: WorkflowsInsightsRunsOverTimeGranularity.Minute,
});
expect(
alignInsightsTimespan({
startTime: '2026-09-01T00:00:00.000Z',
endTime: '2026-09-05T00:00:00.000Z',
})
).toEqual({
timespan: { startTime: '2026-09-01T00:00:00.000Z', endTime: '2026-09-05T00:00:00.000Z' },
granularity: WorkflowsInsightsRunsOverTimeGranularity.Hour,
});
});
it('widens the bounds out to whole buckets', () => {
expect(
alignInsightsTimespan({
startTime: '2026-09-04T15:20:30.000Z',
endTime: '2026-09-11T15:20:30.000Z',
})
).toEqual({
timespan: { startTime: '2026-09-04T00:00:00.000Z', endTime: '2026-09-12T00:00:00.000Z' },
granularity: WorkflowsInsightsRunsOverTimeGranularity.Day,
});
});
it('falls back to a coarser bucket when widening crosses a threshold', () => {
// Widening these by minutes and by hours respectively would exceed the limit the server
// accepts for that bucket size.
expect(
alignInsightsTimespan({
startTime: '2026-09-01T10:00:30.000Z',
endTime: '2026-09-01T12:00:30.000Z',
})
).toEqual({
timespan: { startTime: '2026-09-01T10:00:00.000Z', endTime: '2026-09-01T13:00:00.000Z' },
granularity: WorkflowsInsightsRunsOverTimeGranularity.Hour,
});
expect(
alignInsightsTimespan({
startTime: '2026-09-01T00:30:00.000Z',
endTime: '2026-09-05T00:29:00.000Z',
})
).toEqual({
timespan: { startTime: '2026-09-01T00:00:00.000Z', endTime: '2026-09-06T00:00:00.000Z' },
granularity: WorkflowsInsightsRunsOverTimeGranularity.Day,
});
});
it('keeps an unaligned single day on hourly buckets', () => {
expect(
alignInsightsTimespan({
startTime: '2026-09-10T15:20:00.000Z',
endTime: '2026-09-11T15:20:00.000Z',
})
).toEqual({
timespan: { startTime: '2026-09-10T15:00:00.000Z', endTime: '2026-09-11T16:00:00.000Z' },
granularity: WorkflowsInsightsRunsOverTimeGranularity.Hour,
});
});
// The server re-runs the same widening on whatever we send, so the result has to be stable
// or the chart would read a wider window than the overview all over again.
it('is a fixed point, so the server widening it again changes nothing', () => {
const once = alignInsightsTimespan({
startTime: '2026-09-04T15:20:30.000Z',
endTime: '2026-09-11T15:20:30.000Z',
});
expect(alignInsightsTimespan(once.timespan)).toEqual(once);
});
});
describe(alignMaestroInsightsTimespan, () => {
it('maps the bucket size onto the Maestro enum and forwards the widened bounds', () => {
const start = '2026-09-01T00:00:00.000Z';
expect(
alignMaestroInsightsTimespan({ startTime: start, endTime: '2026-09-01T01:00:00.000Z' })
.granularity
).toBe(WorkflowDeviceTestCaseInsightsTimeSeriesGranularity.Minute);
expect(
alignMaestroInsightsTimespan({ startTime: start, endTime: '2026-09-03T00:00:00.000Z' })
.granularity
).toBe(WorkflowDeviceTestCaseInsightsTimeSeriesGranularity.Hour);
expect(
alignMaestroInsightsTimespan({
startTime: '2026-09-04T15:20:30.000Z',
endTime: '2026-09-11T15:20:30.000Z',
})
).toEqual({
timespan: { startTime: '2026-09-04T00:00:00.000Z', endTime: '2026-09-12T00:00:00.000Z' },
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity.Day,
});
});
});
@@ -0,0 +1,80 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import {
getAppliedMaestroInsightsFilters,
resolveMaestroHistoryFiltersInputAsync,
resolveMaestroInsightsFiltersInputAsync,
} from '../maestroFilters';
jest.mock('../../../graphql/queries/AppQuery');
const mockByIdWorkflowFileNamesAsync = jest.mocked(AppQuery.byIdWorkflowFileNamesAsync);
const graphqlClient = {} as ExpoGraphqlClient;
describe(resolveMaestroInsightsFiltersInputAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockByIdWorkflowFileNamesAsync.mockResolvedValue([
{ id: 'id-of-e2e.yml', fileName: 'e2e.yml' },
]);
});
it('returns no input when no filter flag is set', async () => {
await expect(
resolveMaestroInsightsFiltersInputAsync(graphqlClient, 'app-1', {})
).resolves.toBeUndefined();
expect(mockByIdWorkflowFileNamesAsync).not.toHaveBeenCalled();
});
it('maps the dashboard status names onto the server enum and resolves workflows', async () => {
const input = await resolveMaestroInsightsFiltersInputAsync(graphqlClient, 'app-1', {
workflow: ['e2e.yml'],
status: ['PASSED', 'FLAKY', 'FAILED'],
tag: ['smoke'],
'git-ref': 'main',
});
expect(input).toEqual({
workflowIds: ['id-of-e2e.yml'],
statuses: ['PASSED_CLEAN', 'FLAKY', 'FAILED'],
tags: ['smoke'],
gitRefs: ['refs/heads/main'],
});
});
});
describe(resolveMaestroHistoryFiltersInputAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockByIdWorkflowFileNamesAsync.mockResolvedValue([
{ id: 'id-of-e2e.yml', fileName: 'e2e.yml' },
]);
});
it('resolves only the workflow and git ref filters', async () => {
await expect(
resolveMaestroHistoryFiltersInputAsync(graphqlClient, 'app-1', {
workflow: ['e2e.yml'],
'git-ref': 'main',
})
).resolves.toEqual({ workflowIds: ['id-of-e2e.yml'], gitRefs: ['refs/heads/main'] });
});
it('is undefined when neither is set', async () => {
await expect(
resolveMaestroHistoryFiltersInputAsync(graphqlClient, 'app-1', {})
).resolves.toBeUndefined();
});
});
describe(getAppliedMaestroInsightsFilters, () => {
it('is undefined when no filter flag is set', () => {
expect(getAppliedMaestroInsightsFilters({})).toBeUndefined();
});
it('keeps the values as given, with the git ref normalized', () => {
expect(
getAppliedMaestroInsightsFilters({ status: ['PASSED'], tag: ['smoke'], 'git-ref': 'main' })
).toEqual({ statuses: ['PASSED'], tags: ['smoke'], gitRef: 'refs/heads/main' });
});
});
@@ -0,0 +1,103 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { EasCommandError } from '../../../commandUtils/errors';
import {
EAS_WORKFLOWS_INSIGHTS_NOT_AVAILABLE_ERROR_CODE,
EAS_WORKFLOWS_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED_ERROR_CODE,
EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_NOT_AVAILABLE_ERROR_CODE,
EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED_ERROR_CODE,
withInsightsPlanGateHandlingAsync,
} from '../planGating';
function graphqlErrorWithCode(
errorCode: string,
message = 'server message',
metadata?: Record<string, unknown>
): CombinedError {
const graphQLError = new GraphQLError(message, null, null, null, null, null, {
errorCode,
...(metadata ? { metadata } : {}),
});
return new CombinedError({ graphQLErrors: [graphQLError] });
}
describe(withInsightsPlanGateHandlingAsync, () => {
it('returns the value when the operation succeeds', async () => {
await expect(withInsightsPlanGateHandlingAsync(async () => 42)).resolves.toBe(42);
});
it('surfaces the server message when the plan does not include insights', async () => {
const promise = withInsightsPlanGateHandlingAsync(() => {
throw graphqlErrorWithCode(
EAS_WORKFLOWS_INSIGHTS_NOT_AVAILABLE_ERROR_CODE,
'Workflow insights are not available for your current plan.'
);
});
await expect(promise).rejects.toBeInstanceOf(EasCommandError);
await expect(promise).rejects.toThrow(
'Workflow insights are not available for your current plan.'
);
});
it('appends the plan day limit when the server reports it', async () => {
const promise = withInsightsPlanGateHandlingAsync(() => {
throw graphqlErrorWithCode(
EAS_WORKFLOWS_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED_ERROR_CODE,
'The selected timeframe exceeds the supported range.',
{ limitDays: 30 }
);
});
await expect(promise).rejects.toThrow(
'The selected timeframe exceeds the supported range. Your plan includes the last 30 days.'
);
});
it('handles the Maestro insights plan-gate codes too', async () => {
const promise = withInsightsPlanGateHandlingAsync(() => {
throw graphqlErrorWithCode(
EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_NOT_AVAILABLE_ERROR_CODE,
'Maestro insights are not available for your current plan.'
);
});
await expect(promise).rejects.toBeInstanceOf(EasCommandError);
await expect(promise).rejects.toThrow(
'Maestro insights are not available for your current plan.'
);
});
it('appends the plan day limit for the Maestro timespan code too', async () => {
const promise = withInsightsPlanGateHandlingAsync(() => {
throw graphqlErrorWithCode(
EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED_ERROR_CODE,
'The selected timeframe exceeds the supported range.',
{ limitDays: 30 }
);
});
await expect(promise).rejects.toThrow(
'The selected timeframe exceeds the supported range. Your plan includes the last 30 days.'
);
});
it('passes through GraphQL errors with a different code unchanged', async () => {
const original = graphqlErrorWithCode('SOME_OTHER_ERROR');
await expect(
withInsightsPlanGateHandlingAsync(() => {
throw original;
})
).rejects.toBe(original);
});
it('passes through non-GraphQL errors unchanged', async () => {
const original = new Error('network down');
await expect(
withInsightsPlanGateHandlingAsync(() => {
throw original;
})
).rejects.toBe(original);
});
});
@@ -0,0 +1,120 @@
import { Flags } from '@oclif/core';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../../commandUtils/errors';
import {
WorkflowRunStatus,
WorkflowRunTriggerEventType,
WorkflowsInsightsFiltersInput,
} from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
// Insights only cover finished runs, so the other run statuses would never match.
export const INSIGHTS_RUN_STATUSES = [
WorkflowRunStatus.Success,
WorkflowRunStatus.Failure,
WorkflowRunStatus.Canceled,
] as const;
export type InsightsRunStatus = (typeof INSIGHTS_RUN_STATUSES)[number];
/** The filters as the user typed them, before workflow file names are resolved to IDs. */
export interface AppliedWorkflowsInsightsFilters {
workflows?: string[];
statuses?: InsightsRunStatus[];
triggerEventTypes?: WorkflowRunTriggerEventType[];
gitRef?: string;
}
export const WorkflowsInsightsSharedFilterFlags = {
workflow: Flags.string({
description: 'Only include runs of this workflow file name (can be specified multiple times).',
multiple: true,
}),
'git-ref': Flags.string({
description:
'Only include runs requested for this git ref, for example main or refs/heads/main, or for the full 40-character commit SHA that eas workflow:run recorded.',
}),
};
export interface WorkflowsInsightsFilterFlags {
workflow?: string[];
status?: InsightsRunStatus[];
trigger?: WorkflowRunTriggerEventType[];
'git-ref'?: string;
}
/** The server filters by workflow ID, so workflow file names are resolved first. */
export async function resolveWorkflowsInsightsFiltersInputAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
flags: WorkflowsInsightsFilterFlags
): Promise<WorkflowsInsightsFiltersInput | undefined> {
const input: WorkflowsInsightsFiltersInput = {};
if (flags.workflow?.length) {
input.workflowIds = await resolveWorkflowIdsAsync(graphqlClient, appId, flags.workflow);
}
if (flags.status?.length) {
input.statuses = flags.status;
}
if (flags.trigger?.length) {
input.triggerEventTypes = flags.trigger;
}
if (flags['git-ref']) {
input.gitRefRequested = [normalizeGitRef(flags['git-ref'])];
}
return Object.keys(input).length === 0 ? undefined : input;
}
export function getAppliedWorkflowsInsightsFilters(
flags: WorkflowsInsightsFilterFlags
): AppliedWorkflowsInsightsFilters | undefined {
const applied: AppliedWorkflowsInsightsFilters = {
...(flags.workflow?.length ? { workflows: flags.workflow } : {}),
...(flags.status?.length ? { statuses: flags.status } : {}),
...(flags.trigger?.length ? { triggerEventTypes: flags.trigger } : {}),
...(flags['git-ref'] ? { gitRef: normalizeGitRef(flags['git-ref']) } : {}),
};
return Object.keys(applied).length === 0 ? undefined : applied;
}
export async function resolveWorkflowIdsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
fileNames: string[]
): Promise<string[]> {
const workflows = await AppQuery.byIdWorkflowFileNamesAsync(graphqlClient, appId);
const knownFileNames = new Set(workflows.map(workflow => workflow.fileName));
const requestedFileNames = new Set(fileNames);
const unknownFileNames = [...requestedFileNames].filter(
fileName => !knownFileNames.has(fileName)
);
if (unknownFileNames.length > 0) {
const quoted = unknownFileNames.map(fileName => `"${fileName}"`).join(', ');
const known =
knownFileNames.size > 0
? `Known: ${[...knownFileNames].sort().join(', ')}.`
: 'No workflows are known yet.';
throw new EasCommandError(
`Workflow file(s) not found on this project: ${quoted}. ${known} --workflow takes the workflow file name including the extension; a workflow is listed after its first run.`
);
}
return workflows
.filter(workflow => requestedFileNames.has(workflow.fileName))
.map(workflow => workflow.id);
}
/**
* A run records either the fully qualified ref it was requested for, or the commit SHA when
* `eas workflow:run --ref` resolved one, so only a bare branch name needs qualifying.
*/
export function normalizeGitRef(gitRef: string): string {
if (gitRef.startsWith('refs/') || COMMIT_SHA.test(gitRef)) {
return gitRef;
}
return `refs/heads/${gitRef}`;
}
@@ -0,0 +1,246 @@
import chalk from 'chalk';
import indentString from 'indent-string';
import { WorkflowsInsightsRunsOverTimeGranularity } from '../../graphql/generated';
import { AppWithWorkflowsInsightsObject } from '../../graphql/queries/WorkflowsInsightsQuery';
import { buildRunsOverTimeSection } from '../../insights/formatRunsOverTime';
import {
InsightsTimespanFields,
formatTimespan,
toDateTime,
toTimespanJson,
} from '../../insights/formatTimespan';
import { formatAppliedFilters } from '../../insights/formatFilters';
import { AppliedWorkflowsInsightsFilters } from './filters';
import formatFields from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
import {
InsightsMetricSummary,
formatCountWithTrend,
formatPercent,
formatRateWithDelta,
ratePercent,
toMetricSummary,
} from '../../insights/metrics';
export interface WorkflowsInsightsBucket {
start: string;
totalRuns: number;
successfulRuns: number;
failedRuns: number;
canceledRuns: number;
}
export interface WorkflowsInsightsWorkflowSummary {
workflowId: string;
fileName: string;
name: string;
totalRuns: number;
successfulRuns: number;
failedRuns: number;
canceledRuns: number;
successRatePercent: number;
lastRunAt: string;
}
export interface WorkflowsInsightsSummary extends InsightsTimespanFields {
appFullName: string;
granularity: WorkflowsInsightsRunsOverTimeGranularity;
filters?: AppliedWorkflowsInsightsFilters;
overview: {
totalRuns: InsightsMetricSummary;
successRatePercent: InsightsMetricSummary;
activeWorkflows: InsightsMetricSummary;
failedRuns: InsightsMetricSummary;
};
runsOverTime: WorkflowsInsightsBucket[];
workflows: WorkflowsInsightsWorkflowSummary[];
hasMoreWorkflows: boolean;
}
// Dataset ids assigned by the server's `runsOverTime` resolver.
const RUNS_OVER_TIME_DATASET_IDS = {
total: 'WorkflowsInsightsRunsOverTimeDataset:total',
success: 'WorkflowsInsightsRunsOverTimeDataset:success',
failure: 'WorkflowsInsightsRunsOverTimeDataset:failure',
canceled: 'WorkflowsInsightsRunsOverTimeDataset:canceled',
};
export function toWorkflowsInsightsSummary(
app: AppWithWorkflowsInsightsObject,
{
timespan,
granularity,
filters,
}: {
timespan: InsightsTimespanFields;
granularity: WorkflowsInsightsRunsOverTimeGranularity;
filters?: AppliedWorkflowsInsightsFilters;
}
): WorkflowsInsightsSummary {
const { overviewMetrics, runsOverTime, workflows } = app.workflowsInsights;
// Insights rows carry the workflow's YAML name, which is neither unique nor what
// `--workflow` accepts, so the table is keyed by file name instead.
const fileNamesByWorkflowId = new Map(app.workflows.map(w => [w.id, w.fileName]));
return {
appFullName: app.fullName,
...timespan,
granularity,
filters,
overview: {
totalRuns: toMetricSummary(overviewMetrics.totalRuns),
successRatePercent: {
current: ratePercent(
overviewMetrics.successfulRuns.currentValue,
overviewMetrics.totalRuns.currentValue
),
previous: ratePercent(
overviewMetrics.successfulRuns.previousValue,
overviewMetrics.totalRuns.previousValue
),
},
activeWorkflows: toMetricSummary(overviewMetrics.activeWorkflows),
failedRuns: toMetricSummary(overviewMetrics.failedRuns),
},
runsOverTime: toBuckets(runsOverTime.lineChart),
workflows: workflows.edges.map(({ node }) => ({
workflowId: node.workflowId,
fileName: fileNamesByWorkflowId.get(node.workflowId) ?? node.workflowId,
name: node.name,
totalRuns: node.totalRuns,
successfulRuns: node.successfulRuns,
failedRuns: node.failedRuns,
canceledRuns: node.canceledRuns,
successRatePercent: ratePercent(node.successfulRuns, node.totalRuns),
lastRunAt: node.lastRunAt,
})),
hasMoreWorkflows: workflows.pageInfo.hasNextPage,
};
}
function toBuckets(lineChart: {
labels: string[];
datasets: { id: string; data: (number | null)[] }[];
}): WorkflowsInsightsBucket[] {
const dataset = (id: string): (number | null)[] => {
const match = lineChart.datasets.find(d => d.id === id);
if (!match) {
throw new Error(`Workflows insights response is missing the "${id}" dataset.`);
}
return match.data;
};
const total = dataset(RUNS_OVER_TIME_DATASET_IDS.total);
const success = dataset(RUNS_OVER_TIME_DATASET_IDS.success);
const failure = dataset(RUNS_OVER_TIME_DATASET_IDS.failure);
const canceled = dataset(RUNS_OVER_TIME_DATASET_IDS.canceled);
return lineChart.labels.map((start, i) => ({
start,
totalRuns: total[i] ?? 0,
successfulRuns: success[i] ?? 0,
failedRuns: failure[i] ?? 0,
canceledRuns: canceled[i] ?? 0,
}));
}
export function buildWorkflowsInsightsJson(summary: WorkflowsInsightsSummary): object {
return {
project: summary.appFullName,
timespan: toTimespanJson(summary),
...(summary.filters ? { filters: summary.filters } : {}),
overview: summary.overview,
runsOverTime: {
granularity: summary.granularity,
buckets: summary.runsOverTime,
},
workflows: summary.workflows,
hasMoreWorkflows: summary.hasMoreWorkflows,
};
}
export function buildWorkflowsInsightsTable(summary: WorkflowsInsightsSummary): string {
const sections: string[] = [];
sections.push(chalk.bold('Workflows insights:'));
sections.push(
formatFields([
{ label: 'Project', value: summary.appFullName },
{ label: 'Time range', value: formatTimespan(summary) },
...(summary.filters ? [{ label: 'Filters', value: formatFilters(summary.filters) }] : []),
])
);
const { overview } = summary;
sections.push('');
sections.push(chalk.bold('Overview (compared with the previous period):'));
sections.push(
formatFields([
{ label: 'Total runs', value: formatCountWithTrend(overview.totalRuns) },
{
label: 'Success rate',
value: formatRateWithDelta(overview.successRatePercent, overview.totalRuns),
},
{ label: 'Active workflows', value: formatCountWithTrend(overview.activeWorkflows) },
{
label: 'Failed runs',
value: formatCountWithTrend(overview.failedRuns, { lowerIsBetter: true }),
},
])
);
sections.push(
...buildRunsOverTimeSection(summary.runsOverTime, summary.granularity, {
runs: bucket => bucket.totalRuns,
columns: ['Total', 'Successful', 'Failed', 'Canceled'],
toRow: bucket => [
bucket.totalRuns.toLocaleString(),
bucket.successfulRuns.toLocaleString(),
bucket.failedRuns.toLocaleString(),
bucket.canceledRuns.toLocaleString(),
],
})
);
sections.push('');
if (summary.workflows.length === 0) {
sections.push(chalk.bold('Workflows:'));
sections.push(chalk.dim(' No workflow runs in this time range.'));
} else {
sections.push(
chalk.bold(
summary.hasMoreWorkflows
? `Workflows (showing the ${summary.workflows.length} with the most runs):`
: 'Workflows:'
)
);
sections.push('');
sections.push(indentString(renderWorkflowsTable(summary.workflows), 2));
}
return sections.join('\n');
}
function formatFilters(filters: AppliedWorkflowsInsightsFilters): string {
return formatAppliedFilters([
['workflows', filters.workflows],
['statuses', filters.statuses],
['triggers', filters.triggerEventTypes],
['git ref', filters.gitRef],
]);
}
function renderWorkflowsTable(workflows: WorkflowsInsightsWorkflowSummary[]): string {
return renderTextTable(
['Workflow', 'Runs', 'Successful', 'Failed', 'Canceled', 'Success rate', 'Last run'],
workflows.map(workflow => [
workflow.fileName,
workflow.totalRuns.toLocaleString(),
workflow.successfulRuns.toLocaleString(),
workflow.failedRuns.toLocaleString(),
workflow.canceledRuns.toLocaleString(),
formatPercent(workflow.successRatePercent),
toDateTime(workflow.lastRunAt),
])
);
}
@@ -0,0 +1,485 @@
import chalk from 'chalk';
import indentString from 'indent-string';
import {
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity,
WorkflowDeviceTestCaseStatus,
} from '../../graphql/generated';
import {
AppWithMaestroFlowHistoryObject,
AppWithMaestroInsightsObject,
} from '../../graphql/queries/WorkflowDeviceTestCaseInsightsQuery';
import { buildRunsOverTimeSection } from '../../insights/formatRunsOverTime';
import {
InsightsTimespanFields,
formatTimespan,
toDateTime,
toTimespanJson,
} from '../../insights/formatTimespan';
import { formatAppliedFilters } from '../../insights/formatFilters';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
import { sanitizeTerminalText, truncate } from '../../utils/terminalText';
import { AppliedMaestroInsightsFilters } from './maestroFilters';
import { MaestroSortDirection, MaestroSortOption } from './maestroSort';
import {
InsightsMetricSummary,
formatCountWithTrend,
formatPercent,
formatRateWithDelta,
formatTrend,
ratePercent,
toMetricSummary,
} from '../../insights/metrics';
/** Durations are unknown until at least one run in the window reported one. */
export interface NullableMetricSummary {
current: number | null;
previous: number | null;
}
/** The Maestro tab's overview tiles. */
export interface MaestroInsightsTotals {
totalRuns: InsightsMetricSummary;
passRatePercent: InsightsMetricSummary;
distinctFlakyFlows: InsightsMetricSummary;
avgDurationMs: NullableMetricSummary;
}
/** The tiles of one flow's detail view, which shows no previous-period comparison. */
export interface MaestroFlowTotals {
totalRuns: number;
passRatePercent: number;
flakyRuns: number;
p90DurationMs: number | null;
}
export interface MaestroInsightsBucket {
start: string;
passedClean: number;
flaky: number;
failed: number;
}
export interface MaestroFlowSummary {
path: string;
name: string;
totalRuns: number;
passRatePercent: number;
failed: number;
flakeRatePercent: number;
p90DurationMs: number | null;
lastRunAt: string;
lastRunStatus: WorkflowDeviceTestCaseStatus;
lastRunIsFlaky: boolean;
}
export interface MaestroInsightsSummary extends InsightsTimespanFields {
appFullName: string;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
filters?: AppliedMaestroInsightsFilters;
totals: MaestroInsightsTotals;
runsOverTime: MaestroInsightsBucket[];
flows: MaestroFlowSummary[];
totalFlows: number;
hasMoreFlows: boolean;
sort: { field: MaestroSortOption; direction: MaestroSortDirection };
}
export interface MaestroErrorPatternSummary {
count: number;
sampleMessage: string;
}
export interface MaestroRecentRunSummary {
id: string;
status: WorkflowDeviceTestCaseStatus;
isFlaky: boolean;
durationMs: number | null;
createdAt: string;
workflowRunId: string;
workflowRunName: string;
gitRef: string | null;
commitSha: string | null;
}
export interface MaestroFlowHistorySummary extends InsightsTimespanFields {
appFullName: string;
flowPath: string;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
filters?: Pick<AppliedMaestroInsightsFilters, 'workflows' | 'gitRef'>;
totals: MaestroFlowTotals;
runsOverTime: MaestroInsightsBucket[];
errorPatterns: MaestroErrorPatternSummary[];
recentRuns: MaestroRecentRunSummary[];
totalRecentRuns: number;
hasMoreRecentRuns: boolean;
}
type OverviewTotalsObject =
AppWithMaestroInsightsObject['workflowDeviceTestCaseInsights']['totals'];
type FlowTotalsObject = AppWithMaestroFlowHistoryObject['workflowDeviceTestCaseHistory']['totals'];
type BucketObject =
AppWithMaestroInsightsObject['workflowDeviceTestCaseInsights']['timeSeries'][number];
export function toMaestroInsightsSummary(
app: AppWithMaestroInsightsObject,
{
timespan,
granularity,
filters,
sort,
}: {
timespan: InsightsTimespanFields;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
filters?: AppliedMaestroInsightsFilters;
sort: { field: MaestroSortOption; direction: MaestroSortDirection };
}
): MaestroInsightsSummary {
const { totals, timeSeries, tests } = app.workflowDeviceTestCaseInsights;
return {
appFullName: app.fullName,
...timespan,
granularity,
filters,
totals: toOverviewTotals(totals),
runsOverTime: timeSeries.map(toBucket),
flows: tests.edges.map(({ node }) => ({
path: node.path,
name: node.name,
totalRuns: node.totalRuns,
passRatePercent: ratePercent(node.passedCleanCount + node.flakyCount, node.totalRuns),
failed: node.failedCount,
flakeRatePercent: ratePercent(node.flakyCount, node.totalRuns),
p90DurationMs: node.p90DurationMs ?? null,
lastRunAt: node.lastRunAt,
lastRunStatus: node.lastRunStatus,
lastRunIsFlaky: node.lastRunIsFlaky,
})),
totalFlows: tests.totalCount,
hasMoreFlows: tests.pageInfo.hasNextPage,
sort,
};
}
export function toMaestroFlowHistorySummary(
app: AppWithMaestroFlowHistoryObject,
{
flowPath,
timespan,
granularity,
filters,
}: {
flowPath: string;
timespan: InsightsTimespanFields;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
filters?: AppliedMaestroInsightsFilters;
}
): MaestroFlowHistorySummary {
const { totals, timeSeries, errorPatterns, recentRuns } = app.workflowDeviceTestCaseHistory;
return {
appFullName: app.fullName,
flowPath,
...timespan,
granularity,
filters,
totals: toFlowTotals(totals),
runsOverTime: timeSeries.map(toBucket),
errorPatterns: errorPatterns.map(pattern => ({
count: pattern.count,
sampleMessage: pattern.sampleMessage,
})),
recentRuns: recentRuns.edges.map(({ node }) => ({
id: node.id,
status: node.status,
isFlaky: node.isFlaky,
durationMs: node.durationMs ?? null,
createdAt: node.createdAt,
workflowRunId: node.workflowRunId,
workflowRunName: node.workflowRunName,
gitRef: node.gitRef ?? null,
commitSha: node.commitSha ?? null,
})),
totalRecentRuns: recentRuns.totalCount,
hasMoreRecentRuns: recentRuns.pageInfo.hasNextPage,
};
}
function toOverviewTotals(totals: OverviewTotalsObject): MaestroInsightsTotals {
const totalRuns = toMetricSummary(totals.totalRuns);
const passedClean = toMetricSummary(totals.passedCleanCount);
const flaky = toMetricSummary(totals.flakyCount);
return {
totalRuns,
passRatePercent: {
current: ratePercent(passedClean.current + flaky.current, totalRuns.current),
previous: ratePercent(passedClean.previous + flaky.previous, totalRuns.previous),
},
distinctFlakyFlows: toMetricSummary(totals.distinctFlakyTestCount),
avgDurationMs: {
current: totals.avgDurationMs.currentValue ?? null,
previous: totals.avgDurationMs.previousValue ?? null,
},
};
}
function toFlowTotals(totals: FlowTotalsObject): MaestroFlowTotals {
const totalRuns = totals.totalRuns.currentValue;
const flakyRuns = totals.flakyCount.currentValue;
return {
totalRuns,
passRatePercent: ratePercent(totals.passedCleanCount.currentValue + flakyRuns, totalRuns),
flakyRuns,
p90DurationMs: totals.p90DurationMs.currentValue ?? null,
};
}
function toBucket(bucket: BucketObject): MaestroInsightsBucket {
return {
start: bucket.bucketStartAt,
passedClean: bucket.passedClean,
flaky: bucket.flaky,
failed: bucket.failed,
};
}
export function buildMaestroInsightsJson(summary: MaestroInsightsSummary): object {
return {
project: summary.appFullName,
timespan: toTimespanJson(summary),
...(summary.filters ? { filters: summary.filters } : {}),
totals: summary.totals,
runsOverTime: { granularity: summary.granularity, buckets: summary.runsOverTime },
flows: summary.flows,
totalFlows: summary.totalFlows,
hasMoreFlows: summary.hasMoreFlows,
sort: summary.sort,
};
}
export function buildMaestroFlowHistoryJson(summary: MaestroFlowHistorySummary): object {
return {
project: summary.appFullName,
flow: summary.flowPath,
timespan: toTimespanJson(summary),
...(summary.filters ? { filters: summary.filters } : {}),
totals: summary.totals,
runsOverTime: { granularity: summary.granularity, buckets: summary.runsOverTime },
errorPatterns: summary.errorPatterns,
recentRuns: summary.recentRuns,
totalRecentRuns: summary.totalRecentRuns,
hasMoreRecentRuns: summary.hasMoreRecentRuns,
};
}
export function buildMaestroInsightsTable(summary: MaestroInsightsSummary): string {
const sections: string[] = [];
sections.push(chalk.bold('Maestro insights:'));
sections.push(
formatFields([
{ label: 'Project', value: summary.appFullName },
{ label: 'Time range', value: formatTimespan(summary) },
...(summary.filters ? [{ label: 'Filters', value: formatFilters(summary.filters) }] : []),
])
);
sections.push('');
sections.push(chalk.bold('Overview (compared with the previous period):'));
sections.push(formatFields(buildOverviewTotalsFields(summary.totals)));
sections.push(...buildMaestroRunsOverTimeSection(summary.runsOverTime, summary.granularity));
sections.push('');
if (summary.flows.length === 0) {
sections.push(chalk.bold('Flows:'));
sections.push(chalk.dim(' No flows matched in this time range.'));
} else {
const scope = formatListScope(summary.flows.length, summary.totalFlows, summary.hasMoreFlows);
sections.push(
chalk.bold(`Flows (${scope}, sorted by ${summary.sort.field} ${summary.sort.direction}):`)
);
sections.push('');
sections.push(indentString(renderFlowsTable(summary.flows), 2));
}
return sections.join('\n');
}
export function buildMaestroFlowHistoryTable(summary: MaestroFlowHistorySummary): string {
const sections: string[] = [];
sections.push(chalk.bold('Maestro flow insights:'));
sections.push(
formatFields([
{ label: 'Project', value: summary.appFullName },
{ label: 'Flow', value: summary.flowPath },
{ label: 'Time range', value: formatTimespan(summary) },
...(summary.filters ? [{ label: 'Filters', value: formatFilters(summary.filters) }] : []),
])
);
sections.push('');
sections.push(chalk.bold('Overview:'));
sections.push(formatFields(buildFlowTotalsFields(summary.totals)));
sections.push(...buildMaestroRunsOverTimeSection(summary.runsOverTime, summary.granularity));
sections.push('');
if (summary.errorPatterns.length === 0) {
sections.push(chalk.bold('Error patterns:'));
sections.push(chalk.dim(' No failures in this time range.'));
} else {
sections.push(chalk.bold(`Error patterns (top ${summary.errorPatterns.length}):`));
sections.push('');
sections.push(indentString(renderErrorPatternsTable(summary.errorPatterns), 2));
}
sections.push('');
if (summary.recentRuns.length === 0) {
sections.push(chalk.bold('Recent runs:'));
sections.push(
chalk.dim(' No runs of this flow in this time range. The flow path must match the overview.')
);
} else {
const scope = formatListScope(
summary.recentRuns.length,
summary.totalRecentRuns,
summary.hasMoreRecentRuns
);
sections.push(chalk.bold(`Recent runs (${scope}):`));
sections.push('');
sections.push(indentString(renderRecentRunsTable(summary.recentRuns), 2));
}
return sections.join('\n');
}
function buildOverviewTotalsFields(totals: MaestroInsightsTotals): FormatFieldsItem[] {
return [
{ label: 'Maestro runs', value: formatCountWithTrend(totals.totalRuns) },
{ label: 'Pass rate', value: formatRateWithDelta(totals.passRatePercent, totals.totalRuns) },
{
label: 'Flaky flows',
value: formatCountWithTrend(totals.distinctFlakyFlows, { lowerIsBetter: true }),
},
{ label: 'Avg duration', value: formatDurationWithTrend(totals.avgDurationMs) },
];
}
function buildFlowTotalsFields(totals: MaestroFlowTotals): FormatFieldsItem[] {
return [
{ label: 'Maestro runs', value: totals.totalRuns.toLocaleString() },
{
label: 'Pass rate',
value: totals.totalRuns === 0 ? chalk.dim('n/a') : formatPercent(totals.passRatePercent),
},
{ label: 'Flaky runs', value: totals.flakyRuns.toLocaleString() },
{
label: 'P90 duration',
value:
totals.p90DurationMs === null ? chalk.dim('n/a') : formatDurationMs(totals.p90DurationMs),
},
];
}
function formatListScope(shown: number, total: number, hasMore: boolean): string {
return hasMore ? `showing ${shown} of ${total.toLocaleString()}` : `${shown}`;
}
function buildMaestroRunsOverTimeSection(
buckets: MaestroInsightsBucket[],
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity
): string[] {
return buildRunsOverTimeSection(buckets, granularity, {
runs: bucket => bucket.passedClean + bucket.flaky + bucket.failed,
columns: ['Passed', 'Flaky', 'Failed'],
toRow: bucket => [
bucket.passedClean.toLocaleString(),
bucket.flaky.toLocaleString(),
bucket.failed.toLocaleString(),
],
});
}
function renderFlowsTable(flows: MaestroFlowSummary[]): string {
return renderTextTable(
['Flow', 'Runs', 'Pass rate', 'Fails', 'Flake rate', 'P90', 'Last run', 'Last status'],
flows.map(flow => [
flow.path,
flow.totalRuns.toLocaleString(),
formatPercent(flow.passRatePercent),
flow.failed.toLocaleString(),
formatPercent(flow.flakeRatePercent),
formatDurationMs(flow.p90DurationMs),
toDateTime(flow.lastRunAt),
formatRunStatus(flow.lastRunStatus, flow.lastRunIsFlaky),
])
);
}
function renderErrorPatternsTable(patterns: MaestroErrorPatternSummary[]): string {
const SAMPLE_MESSAGE_MAX_LENGTH = 100;
return renderTextTable(
['Count', 'Sample message'],
patterns.map(pattern => [
pattern.count.toLocaleString(),
truncate(sanitizeTerminalText(pattern.sampleMessage), SAMPLE_MESSAGE_MAX_LENGTH),
])
);
}
function renderRecentRunsTable(runs: MaestroRecentRunSummary[]): string {
return renderTextTable(
['Started', 'Status', 'Duration', 'Branch', 'Commit', 'Workflow run'],
runs.map(run => [
toDateTime(run.createdAt),
formatRunStatus(run.status, run.isFlaky),
formatDurationMs(run.durationMs),
run.gitRef ?? '',
run.commitSha?.slice(0, 7) ?? '',
`${run.workflowRunName} (${run.workflowRunId})`,
])
);
}
function formatFilters(filters: AppliedMaestroInsightsFilters): string {
return formatAppliedFilters([
['workflows', filters.workflows],
['statuses', filters.statuses],
['tags', filters.tags],
['search', filters.search],
['git ref', filters.gitRef],
]);
}
/** A flaky run passed only after a retry, so it is shown as its own state. */
function formatRunStatus(status: WorkflowDeviceTestCaseStatus, isFlaky: boolean): string {
return isFlaky ? 'FLAKY' : status;
}
/** Matches the dashboard's duration format. */
export function formatDurationMs(ms: number | null): string {
if (ms === null) {
// Plain text: this lands in table cells, which renderTextTable pads by string length.
return 'n/a';
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
}
function formatDurationWithTrend(metric: NullableMetricSummary): string {
if (metric.current === null) {
return chalk.dim('n/a');
}
const trend =
metric.previous === null
? chalk.dim('n/a')
: formatTrend(
{ current: metric.current, previous: metric.previous },
{ lowerIsBetter: true }
);
return `${formatDurationMs(metric.current)} (${trend})`;
}
@@ -0,0 +1,110 @@
import {
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity,
WorkflowsInsightsRunsOverTimeGranularity,
} from '../../graphql/generated';
import { InsightsTimespanBounds } from '../../insights/formatTimespan';
const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
// The server accepts MINUTE buckets for windows up to 2 hours and HOUR buckets
// for windows up to 4 days.
const MINUTE_GRANULARITY_MAX_HOURS = 2;
const HOUR_GRANULARITY_MAX_HOURS = 4 * 24;
const GRANULARITY_INTERVAL_MS: Record<WorkflowsInsightsRunsOverTimeGranularity, number> = {
[WorkflowsInsightsRunsOverTimeGranularity.Minute]: MINUTE_MS,
[WorkflowsInsightsRunsOverTimeGranularity.Hour]: HOUR_MS,
[WorkflowsInsightsRunsOverTimeGranularity.Day]: DAY_MS,
};
// Finest first, derived from the table above so a bucket size added to the generated enum cannot
// be silently skipped here.
const GRANULARITIES_FINEST_FIRST = (
Object.keys(GRANULARITY_INTERVAL_MS) as WorkflowsInsightsRunsOverTimeGranularity[]
).sort((a, b) => GRANULARITY_INTERVAL_MS[a] - GRANULARITY_INTERVAL_MS[b]);
export function granularityForTimespan(
startTime: string,
endTime: string
): WorkflowsInsightsRunsOverTimeGranularity {
const hours = (new Date(endTime).getTime() - new Date(startTime).getTime()) / HOUR_MS;
if (hours <= MINUTE_GRANULARITY_MAX_HOURS) {
return WorkflowsInsightsRunsOverTimeGranularity.Minute;
}
if (hours <= HOUR_GRANULARITY_MAX_HOURS) {
return WorkflowsInsightsRunsOverTimeGranularity.Hour;
}
return WorkflowsInsightsRunsOverTimeGranularity.Day;
}
/**
* Widen a requested window out to whole buckets, and report the bucket size the widened window
* needs.
*
* The server widens only the runs-over-time query to whole buckets and leaves the overview and
* per-workflow queries on the exact bounds, so an unaligned window makes those sections count
* different runs. Sending the widened window turns that server-side widening into a no-op.
*
* Widening lengthens the window, which can push it past the threshold of the very bucket size it
* was widened by, so each candidate is only accepted if the widened window still asks for it.
* Falling through to the coarsest bucket means every finer one was ruled out, and widening by a
* coarser bucket is at least as wide, so the coarsest is always self-consistent.
*
* Widening the end past now puts unelapsed time in the current period while the previous period
* the server compares it against is complete, so count metrics span unequal exposure.
*/
export function alignInsightsTimespan(timespan: InsightsTimespanBounds): {
timespan: InsightsTimespanBounds;
granularity: WorkflowsInsightsRunsOverTimeGranularity;
} {
const startMs = new Date(timespan.startTime).getTime();
const endMs = new Date(timespan.endTime).getTime();
// Every candidate widens the originally requested window, so a coarser one does not stack
// another bucket of padding onto an already widened window.
const coarsest = GRANULARITIES_FINEST_FIRST[GRANULARITIES_FINEST_FIRST.length - 1];
const granularity =
GRANULARITIES_FINEST_FIRST.slice(0, -1).find(candidate => {
const widened = widenToWholeBuckets(startMs, endMs, candidate);
return granularityForTimespan(widened.startTime, widened.endTime) === candidate;
}) ?? coarsest;
return { timespan: widenToWholeBuckets(startMs, endMs, granularity), granularity };
}
function widenToWholeBuckets(
startMs: number,
endMs: number,
granularity: WorkflowsInsightsRunsOverTimeGranularity
): { startTime: string; endTime: string } {
const interval = GRANULARITY_INTERVAL_MS[granularity];
return {
startTime: new Date(Math.floor(startMs / interval) * interval).toISOString(),
endTime: new Date(Math.ceil(endMs / interval) * interval).toISOString(),
};
}
// Maestro insights use their own enum for the same three bucket sizes.
const MAESTRO_GRANULARITY: Record<
WorkflowsInsightsRunsOverTimeGranularity,
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity
> = {
[WorkflowsInsightsRunsOverTimeGranularity.Minute]:
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity.Minute,
[WorkflowsInsightsRunsOverTimeGranularity.Hour]:
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity.Hour,
[WorkflowsInsightsRunsOverTimeGranularity.Day]:
WorkflowDeviceTestCaseInsightsTimeSeriesGranularity.Day,
};
export function alignMaestroInsightsTimespan(timespan: InsightsTimespanBounds): {
timespan: InsightsTimespanBounds;
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
} {
const aligned = alignInsightsTimespan(timespan);
return {
timespan: aligned.timespan,
granularity: MAESTRO_GRANULARITY[aligned.granularity],
};
}
@@ -0,0 +1,79 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
WorkflowDeviceTestCaseHistoryFiltersInput,
WorkflowDeviceTestCaseInsightsFiltersInput,
WorkflowDeviceTestCaseStatusFilter,
} from '../../graphql/generated';
import { normalizeGitRef, resolveWorkflowIdsAsync } from './filters';
/** Statuses as the dashboard names them; `PASSED` means passed on the first attempt. */
export const MAESTRO_STATUS_OPTIONS = ['PASSED', 'FLAKY', 'FAILED'] as const;
export type MaestroStatusOption = (typeof MAESTRO_STATUS_OPTIONS)[number];
const STATUS_FILTER_BY_OPTION: Record<MaestroStatusOption, WorkflowDeviceTestCaseStatusFilter> = {
PASSED: WorkflowDeviceTestCaseStatusFilter.PassedClean,
FLAKY: WorkflowDeviceTestCaseStatusFilter.Flaky,
FAILED: WorkflowDeviceTestCaseStatusFilter.Failed,
};
export interface MaestroInsightsFilterFlags {
workflow?: string[];
status?: MaestroStatusOption[];
tag?: string[];
search?: string;
'git-ref'?: string;
}
/** The filters as the user typed them, before workflow file names are resolved to IDs. */
export interface AppliedMaestroInsightsFilters {
workflows?: string[];
statuses?: MaestroStatusOption[];
tags?: string[];
search?: string;
gitRef?: string;
}
export async function resolveMaestroHistoryFiltersInputAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
flags: Pick<MaestroInsightsFilterFlags, 'workflow' | 'git-ref'>
): Promise<WorkflowDeviceTestCaseHistoryFiltersInput | undefined> {
const input: WorkflowDeviceTestCaseHistoryFiltersInput = {};
if (flags.workflow?.length) {
input.workflowIds = await resolveWorkflowIdsAsync(graphqlClient, appId, flags.workflow);
}
if (flags['git-ref']) {
input.gitRefs = [normalizeGitRef(flags['git-ref'])];
}
return Object.keys(input).length === 0 ? undefined : input;
}
export async function resolveMaestroInsightsFiltersInputAsync(
graphqlClient: ExpoGraphqlClient,
appId: string,
flags: MaestroInsightsFilterFlags
): Promise<WorkflowDeviceTestCaseInsightsFiltersInput | undefined> {
const input: WorkflowDeviceTestCaseInsightsFiltersInput = {
...(await resolveMaestroHistoryFiltersInputAsync(graphqlClient, appId, flags)),
};
if (flags.status?.length) {
input.statuses = flags.status.map(status => STATUS_FILTER_BY_OPTION[status]);
}
if (flags.tag?.length) {
input.tags = flags.tag;
}
return Object.keys(input).length === 0 ? undefined : input;
}
export function getAppliedMaestroInsightsFilters(
flags: MaestroInsightsFilterFlags
): AppliedMaestroInsightsFilters | undefined {
const applied: AppliedMaestroInsightsFilters = {
...(flags.workflow?.length ? { workflows: flags.workflow } : {}),
...(flags.status?.length ? { statuses: flags.status } : {}),
...(flags.tag?.length ? { tags: flags.tag } : {}),
...(flags.search ? { search: flags.search } : {}),
...(flags['git-ref'] ? { gitRef: normalizeGitRef(flags['git-ref']) } : {}),
};
return Object.keys(applied).length === 0 ? undefined : applied;
}
@@ -0,0 +1,37 @@
import {
WorkflowDeviceTestCaseSortDirection,
WorkflowDeviceTestCaseStatSortField,
} from '../../graphql/generated';
export const MAESTRO_SORT_OPTIONS = [
'fails',
'runs',
'flakes',
'pass-rate',
'flake-rate',
'p90',
'last-run',
] as const;
export type MaestroSortOption = (typeof MAESTRO_SORT_OPTIONS)[number];
export const MAESTRO_SORT_DIRECTIONS = ['asc', 'desc'] as const;
export type MaestroSortDirection = (typeof MAESTRO_SORT_DIRECTIONS)[number];
export const SORT_FIELD_BY_OPTION: Record<MaestroSortOption, WorkflowDeviceTestCaseStatSortField> =
{
runs: WorkflowDeviceTestCaseStatSortField.Runs,
fails: WorkflowDeviceTestCaseStatSortField.Fails,
flakes: WorkflowDeviceTestCaseStatSortField.Flakes,
'pass-rate': WorkflowDeviceTestCaseStatSortField.PassRate,
'flake-rate': WorkflowDeviceTestCaseStatSortField.FlakeRate,
p90: WorkflowDeviceTestCaseStatSortField.P90Duration,
'last-run': WorkflowDeviceTestCaseStatSortField.LastRun,
};
export const SORT_DIRECTION_BY_OPTION: Record<
MaestroSortDirection,
WorkflowDeviceTestCaseSortDirection
> = {
asc: WorkflowDeviceTestCaseSortDirection.Asc,
desc: WorkflowDeviceTestCaseSortDirection.Desc,
};
@@ -0,0 +1,36 @@
import { EasCommandError } from '../../commandUtils/errors';
import { findPlanGateError } from '../../commandUtils/planGating';
// Must match the server's plan-gate error codes (`ExpoErrorCode`).
export const EAS_WORKFLOWS_INSIGHTS_NOT_AVAILABLE_ERROR_CODE =
'EAS_WORKFLOWS_INSIGHTS_NOT_AVAILABLE';
export const EAS_WORKFLOWS_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED_ERROR_CODE =
'EAS_WORKFLOWS_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED';
export const EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_NOT_AVAILABLE_ERROR_CODE =
'EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_NOT_AVAILABLE';
export const EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED_ERROR_CODE =
'EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED';
const PLAN_GATE_ERROR_CODES: ReadonlySet<string> = new Set([
EAS_WORKFLOWS_INSIGHTS_NOT_AVAILABLE_ERROR_CODE,
EAS_WORKFLOWS_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED_ERROR_CODE,
EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_NOT_AVAILABLE_ERROR_CODE,
EAS_WORKFLOW_DEVICE_TEST_CASE_INSIGHTS_TIMESPAN_LIMIT_EXCEEDED_ERROR_CODE,
]);
/** Plan-gate rejections from either insights surface become a plain error message. */
export async function withInsightsPlanGateHandlingAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
const planGateError = findPlanGateError(error, PLAN_GATE_ERROR_CODES);
if (!planGateError) {
throw error;
}
const limitDays = (planGateError.extensions?.metadata as { limitDays?: unknown } | undefined)
?.limitDays;
const limitText =
typeof limitDays === 'number' ? ` Your plan includes the last ${limitDays} days.` : '';
throw new EasCommandError(`${planGateError.message}${limitText}`);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "24.0.0",
"version": "24.2.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "24.0.0",
"@expo/eas-build-job": "24.2.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "24.1.0",
"version": "24.2.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,9 +26,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "24.1.0",
"@expo/build-tools": "24.2.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "24.0.0",
"@expo/eas-build-job": "24.2.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "24.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "24.0.0",
"version": "24.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "24.0.0",
"@expo/eas-build-job": "24.2.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "24.1.0",
"version": "24.2.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,15 +19,15 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "24.1.0",
"@expo/build-tools": "24.2.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "24.0.0",
"@expo/eas-build-job": "24.2.0",
"@expo/logger": "24.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.0.0",
"@expo/steps": "24.2.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@hapi/boom": "10.0.1",
Generated
+17 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:24.1.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:24.2.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:24.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.0.0"
"@expo/steps": "npm:24.2.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2557,7 +2557,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:24.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:24.2.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -2576,12 +2576,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:24.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:24.2.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.2.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2895,11 +2895,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:24.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:24.2.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -2969,15 +2969,15 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:24.1.0"
"@expo/build-tools": "npm:24.2.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/logger": "npm:24.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.0.0"
"@expo/steps": "npm:24.2.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9828,7 +9828,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:24.0.0"
"@expo/steps": "npm:24.2.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10412,9 +10412,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:24.1.0"
"@expo/build-tools": "npm:24.2.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:24.0.0"
@@ -10469,8 +10469,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:24.0.0"
"@expo/eas-json": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/eas-json": "npm:24.2.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:24.0.0"
@@ -10485,7 +10485,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.0.0"
"@expo/steps": "npm:24.2.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"