mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97553334d4 | ||
|
|
31d12b9888 | ||
|
|
dc2658f63b | ||
|
|
b8b9de78fc | ||
|
|
8ff599b1f2 | ||
|
|
e6f02af973 | ||
|
|
a163b5a76d | ||
|
|
cc37c1c360 | ||
|
|
e78771e0f4 | ||
|
|
288bc44dfc | ||
|
|
d3db3926e3 | ||
|
|
5b87ba0adb | ||
|
|
0b2cea7bb7 | ||
|
|
eab4fc3cd5 | ||
|
|
e2e8cd245c | ||
|
|
80d46f0783 | ||
|
|
3b6b63a273 |
No files matched your search
+30
-2
@@ -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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.0.0",
|
||||
"PRODUCTION": "24.0.0"
|
||||
"STAGING": "24.1.2",
|
||||
"PRODUCTION": "24.1.2"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.1.0",
|
||||
"version": "24.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Generated
+12
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user