mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71cf0aea74 | ||
|
|
f6a3028356 | ||
|
|
3182070f4c | ||
|
|
097b58c7a9 | ||
|
|
9584d05708 | ||
|
|
b9ac59b336 |
No files matched your search
+12
-1
@@ -8,12 +8,23 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Stop fetching the primary account in the login query. Commands that need it now fetch it on demand, so scoped access tokens without primary account access no longer fail at login. ([#4380](https://github.com/expo/eas-cli/pull/4380) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Regenerate GraphQL types and update Observe tests for the new `firstSeenAt` and `lastSeenAt` event-name fields. ([#4379](https://github.com/expo/eas-cli/pull/4379) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [24.5.0](https://github.com/expo/eas-cli/releases/tag/v24.5.0) - 2026-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.4.2",
|
||||
"PRODUCTION": "24.4.2"
|
||||
"STAGING": "24.5.0",
|
||||
"PRODUCTION": "24.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.5.0",
|
||||
"version": "24.6.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+133
-133
File diff suppressed because it is too large.
Load diff
Generated
+519
-25
@@ -25267,16 +25267,12 @@
|
||||
"fields": [
|
||||
{
|
||||
"name": "previousPeriodTotal",
|
||||
"description": null,
|
||||
"description": "Total for the equal-length window before startTime. Null when the window is longer than 45 days, since the previous period would fall outside the 90-day retention.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
@@ -25398,23 +25394,19 @@
|
||||
},
|
||||
{
|
||||
"name": "previousPeriodCrashCount",
|
||||
"description": "Fatal exception events in the equal-length window before startTime.",
|
||||
"description": "Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previousPeriodCrashFreeSessions",
|
||||
"description": "Same fractions for the equal-length window before startTime; null when it had no activity.",
|
||||
"description": "Same fractions for the equal-length window before startTime; null when it had no activity, or when the window is longer than 45 days and the previous period falls outside the 90-day retention.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
@@ -25438,16 +25430,12 @@
|
||||
},
|
||||
{
|
||||
"name": "previousPeriodCrashedUsers",
|
||||
"description": null,
|
||||
"description": "Null when the window is longer than 45 days.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
}
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
@@ -29533,6 +29521,38 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "firstSeenAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "lastSeenAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"description": null,
|
||||
@@ -29810,6 +29830,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "FIRST_SEEN",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "LAST_SEEN",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "NAME",
|
||||
"description": null,
|
||||
@@ -76017,6 +76049,22 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sandboxes",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "SandboxQuery",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "snack",
|
||||
"description": null,
|
||||
@@ -77878,6 +77926,232 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "Sandbox",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "app",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "App",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "createdAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "finishedAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "lastUsedAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startedAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "SandboxStatus",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "turtleJobRun",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "JobRun",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "updatedAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "SandboxQuery",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "byId",
|
||||
"description": null,
|
||||
"args": [
|
||||
{
|
||||
"name": "sandboxId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "Sandbox",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "SandboxStatus",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "ERRORED",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "RUNNING",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "STARTING",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "STOPPED",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "SecondFactorBooleanResult",
|
||||
@@ -81266,6 +81540,189 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "SupabaseAdvisorLint",
|
||||
"description": "An unresolved finding from Supabase's Security or Performance Advisor (database linter).",
|
||||
"fields": [
|
||||
{
|
||||
"name": "cacheKey",
|
||||
"description": "Stable identifier for this finding on this project.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "detail",
|
||||
"description": "Project-specific explanation naming the affected schema object.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "entity",
|
||||
"description": "Affected schema object, e.g. public.todos, when the lint names one.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "level",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "SupabaseAdvisorLintLevel",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"description": "Lint rule identifier, e.g. rls_disabled_in_public.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "remediation",
|
||||
"description": "Link to the remediation guide, when Supabase provides one.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "SupabaseAdvisorLintLevel",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "ERROR",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "INFO",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "WARN",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "SupabaseAdvisorType",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "PERFORMANCE",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "SECURITY",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "SupabaseConnection",
|
||||
@@ -81688,6 +82145,43 @@
|
||||
"name": "SupabaseProject",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "advisorLints",
|
||||
"description": "Live unresolved lints from the project's Security or Performance Advisor, ordered by severity.\nReadable with view permission; a token refresh triggered by a read is persisted with elevated\nprivileges. Null when Supabase cannot be reached.",
|
||||
"args": [
|
||||
{
|
||||
"name": "type",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "SupabaseAdvisorType",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "SupabaseAdvisorLint",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "app",
|
||||
"description": null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.5.0",
|
||||
"version": "24.6.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -11,8 +11,8 @@ import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVers
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { ensureActorHasPrimaryAccount } from '../../user/actions';
|
||||
import { mockTestCommand } from './utils';
|
||||
import { UserQuery } from '../../graphql/queries/UserQuery';
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
...jest.requireActual('@expo/config'),
|
||||
@@ -45,8 +45,8 @@ jest.mock('fs-extra', () => ({
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../user/actions');
|
||||
jest.mock('../../graphql/queries/WorkflowRunQuery');
|
||||
jest.mock('../../graphql/queries/UserQuery');
|
||||
jest.mock('../../graphql/mutations/WorkflowRunMutation');
|
||||
jest.mock('../../project/uploadAccountScopedFileAsync');
|
||||
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
|
||||
@@ -110,7 +110,9 @@ const mockActor = {
|
||||
|
||||
describe('Go command', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
|
||||
jest
|
||||
.mocked(UserQuery.requireCurrentUserPrimaryAccountAsync)
|
||||
.mockReturnValue(mockAccount as any);
|
||||
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
|
||||
files: [],
|
||||
sdkVersion: '55.0.0',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
|
||||
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
|
||||
import WorkflowLogView from '../../commands/workflow/logs';
|
||||
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
@@ -35,7 +35,7 @@ jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
|
||||
jest.mock('../../commandUtils/workflow/fetchLogs');
|
||||
|
||||
describe(WorkflowLogView, () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { BuildPhase } from '@expo/eas-build-job';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
|
||||
import { JobLogs, RawLogLine } from '../../commandUtils/logs/types';
|
||||
import fetch from '../../fetch';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildPriority,
|
||||
BuildStatus,
|
||||
RealtimeLogsTargetType,
|
||||
} from '../../graphql/generated';
|
||||
import {
|
||||
formatActiveBuildText,
|
||||
formatActiveBuildsText,
|
||||
isBuildCompleted,
|
||||
logSourceForBuild,
|
||||
} from '../logs';
|
||||
|
||||
jest.mock('../../fetch');
|
||||
|
||||
function createBuildFragment(overrides: Partial<BuildFragment> = {}): BuildFragment {
|
||||
return {
|
||||
id: 'build-id',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
platform: AppPlatform.Android,
|
||||
logFiles: [],
|
||||
priority: BuildPriority.Normal,
|
||||
app: {
|
||||
__typename: 'App',
|
||||
slug: 'test-project',
|
||||
id: uuid(),
|
||||
name: 'test-project',
|
||||
ownerAccount: {
|
||||
__typename: 'Account',
|
||||
id: uuid(),
|
||||
name: 'test-account',
|
||||
},
|
||||
},
|
||||
status: BuildStatus.InProgress,
|
||||
isForIosSimulator: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function phaseLines(phase: string, count: number): RawLogLine[] {
|
||||
return Array.from({ length: count }, (_, index) => ({ phase, msg: `line${index}` }));
|
||||
}
|
||||
|
||||
function logsForPhase(phase: string, count: number): JobLogs {
|
||||
return groupLogLinesIntoSteps(phaseLines(phase, count));
|
||||
}
|
||||
|
||||
describe(logSourceForBuild, () => {
|
||||
it('keys the source by the build id', () => {
|
||||
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).key).toBe(
|
||||
'some-build-id'
|
||||
);
|
||||
});
|
||||
|
||||
it('targets the build itself for realtime logs', () => {
|
||||
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).realtimeTarget).toEqual({
|
||||
type: RealtimeLogsTargetType.Build,
|
||||
id: 'some-build-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('is in progress only while the build is in progress', () => {
|
||||
expect(
|
||||
logSourceForBuild(createBuildFragment({ status: BuildStatus.InProgress })).isInProgress
|
||||
).toBe(true);
|
||||
for (const status of [
|
||||
BuildStatus.New,
|
||||
BuildStatus.InQueue,
|
||||
BuildStatus.PendingCancel,
|
||||
BuildStatus.Canceled,
|
||||
BuildStatus.Errored,
|
||||
BuildStatus.Finished,
|
||||
]) {
|
||||
expect(logSourceForBuild(createBuildFragment({ status })).isInProgress).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
describe('fetchRawLogLinesAsync', () => {
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it('parses the first log file of the build', async () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce({
|
||||
text: async () =>
|
||||
[
|
||||
'{"logId":"1","phase":"INSTALL_DEPENDENCIES","msg":"npm ci"}',
|
||||
'{"logId":"2","phase":"INSTALL_DEPENDENCIES","msg":"done"}',
|
||||
].join('\n'),
|
||||
} as any);
|
||||
|
||||
const source = logSourceForBuild(
|
||||
createBuildFragment({ logFiles: ['https://logs.test/first', 'https://logs.test/second'] })
|
||||
);
|
||||
|
||||
await expect(source.fetchRawLogLinesAsync()).resolves.toEqual([
|
||||
{ logId: '1', phase: 'INSTALL_DEPENDENCIES', msg: 'npm ci' },
|
||||
{ logId: '2', phase: 'INSTALL_DEPENDENCIES', msg: 'done' },
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith('https://logs.test/first', {
|
||||
timeout: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the build has no log file', async () => {
|
||||
await expect(
|
||||
logSourceForBuild(createBuildFragment({ logFiles: [] })).fetchRawLogLinesAsync()
|
||||
).resolves.toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(isBuildCompleted, () => {
|
||||
it('reports FINISHED as completed', () => {
|
||||
expect(isBuildCompleted(BuildStatus.Finished)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports ERRORED as completed', () => {
|
||||
expect(isBuildCompleted(BuildStatus.Errored)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports CANCELED as completed', () => {
|
||||
expect(isBuildCompleted(BuildStatus.Canceled)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports NEW as not completed', () => {
|
||||
expect(isBuildCompleted(BuildStatus.New)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports IN_QUEUE as not completed', () => {
|
||||
expect(isBuildCompleted(BuildStatus.InQueue)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports IN_PROGRESS as not completed', () => {
|
||||
expect(isBuildCompleted(BuildStatus.InProgress)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports PENDING_CANCEL as not completed', () => {
|
||||
expect(isBuildCompleted(BuildStatus.PendingCancel)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatActiveBuildText, () => {
|
||||
it('returns the base text alone when there are no logs', () => {
|
||||
expect(formatActiveBuildText('Build in progress...', new Map())).toBe('Build in progress...');
|
||||
});
|
||||
|
||||
it('shows the display name of a raw build phase', () => {
|
||||
const output = formatActiveBuildText(
|
||||
'Build in progress...',
|
||||
logsForPhase(BuildPhase.INSTALL_DEPENDENCIES, 1)
|
||||
);
|
||||
|
||||
expect(output).toContain('Current phase');
|
||||
expect(output).toContain('Install dependencies');
|
||||
expect(output).not.toContain('INSTALL_DEPENDENCIES');
|
||||
});
|
||||
|
||||
it('passes a build step display name through untouched', () => {
|
||||
const output = formatActiveBuildText(
|
||||
'Build in progress...',
|
||||
groupLogLinesIntoSteps([
|
||||
{ buildStepId: 'step-id-1', buildStepDisplayName: 'Run fastlane build', msg: 'line0' },
|
||||
])
|
||||
);
|
||||
|
||||
expect(output).toContain('Run fastlane build');
|
||||
expect(output).not.toContain('step-id-1');
|
||||
});
|
||||
|
||||
it('keeps the base text and five trailing log lines', () => {
|
||||
const output = formatActiveBuildText(
|
||||
'Build in progress...',
|
||||
logsForPhase(BuildPhase.RUN_GRADLEW, 10)
|
||||
);
|
||||
|
||||
expect(output.startsWith('Build in progress...\n')).toBe(true);
|
||||
expect(output).toContain('line5');
|
||||
expect(output).toContain('line9');
|
||||
expect(output).not.toContain('line4');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatActiveBuildsText, () => {
|
||||
it('returns the base text alone when no build has logs', () => {
|
||||
expect(
|
||||
formatActiveBuildsText('Waiting for builds to complete.', [
|
||||
{ build: createBuildFragment(), logs: new Map() },
|
||||
])
|
||||
).toBe('Waiting for builds to complete.');
|
||||
});
|
||||
|
||||
it('labels each build with its platform emoji and display name', () => {
|
||||
const output = formatActiveBuildsText('Waiting for builds to complete.', [
|
||||
{
|
||||
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
|
||||
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 1),
|
||||
},
|
||||
{
|
||||
build: createBuildFragment({ id: 'ios-build', platform: AppPlatform.Ios }),
|
||||
logs: logsForPhase(BuildPhase.RUN_FASTLANE, 1),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(output).toContain('🤖 Android');
|
||||
expect(output).toContain('Run gradlew');
|
||||
expect(output).toContain('🍏 iOS');
|
||||
expect(output).toContain('Run fastlane');
|
||||
});
|
||||
|
||||
it('keeps three trailing log lines per build', () => {
|
||||
const output = formatActiveBuildsText('Waiting for builds to complete.', [
|
||||
{
|
||||
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
|
||||
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 10),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(output).toContain('line7');
|
||||
expect(output).toContain('line9');
|
||||
expect(output).not.toContain('line6');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { BuildPhase } from '@expo/eas-build-job';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { LogsState } from '../../commandUtils/logs/state';
|
||||
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
|
||||
import { JobLogs } from '../../commandUtils/logs/types';
|
||||
import { LogsWatcher } from '../../commandUtils/logs/watcher';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildPriority,
|
||||
BuildStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { Ora, isSpinnerEnabled, ora } from '../../ora';
|
||||
import { createRealtimeLogsClient } from '../../utils/centrifuge';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import { waitForBuildEndAsync } from '../build';
|
||||
|
||||
jest.mock('../../ora', () => ({
|
||||
...jest.requireActual('../../ora'),
|
||||
ora: jest.fn(),
|
||||
isSpinnerEnabled: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../commandUtils/logs/watcher', () => ({
|
||||
LogsWatcher: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/centrifuge', () => ({
|
||||
createRealtimeLogsClient: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../graphql/queries/BuildQuery', () => ({
|
||||
BuildQuery: {
|
||||
byIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../utils/promise', () => ({
|
||||
...jest.requireActual('../../utils/promise'),
|
||||
sleepAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const graphqlClient = {} as unknown as ExpoGraphqlClient;
|
||||
|
||||
function createBuildFragment(status: BuildStatus): BuildFragment {
|
||||
return {
|
||||
id: 'build-id',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
platform: AppPlatform.Android,
|
||||
logFiles: [],
|
||||
priority: BuildPriority.Normal,
|
||||
app: {
|
||||
__typename: 'App',
|
||||
slug: 'test-project',
|
||||
id: uuid(),
|
||||
name: 'test-project',
|
||||
ownerAccount: {
|
||||
__typename: 'Account',
|
||||
id: uuid(),
|
||||
name: 'test-account',
|
||||
},
|
||||
},
|
||||
status,
|
||||
isForIosSimulator: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createSpinner(): Ora {
|
||||
const spinner: Record<string, unknown> = {
|
||||
text: '',
|
||||
prefixText: '',
|
||||
isSpinning: true,
|
||||
start: jest.fn(() => spinner),
|
||||
stop: jest.fn(() => spinner),
|
||||
stopAndPersist: jest.fn(() => spinner),
|
||||
succeed: jest.fn(() => spinner),
|
||||
fail: jest.fn(() => spinner),
|
||||
warn: jest.fn(() => spinner),
|
||||
};
|
||||
return spinner as unknown as Ora;
|
||||
}
|
||||
|
||||
function setBuildStatuses(statuses: BuildStatus[]): void {
|
||||
const mocked = jest.mocked(BuildQuery.byIdAsync);
|
||||
for (const status of statuses) {
|
||||
mocked.mockResolvedValueOnce(createBuildFragment(status));
|
||||
}
|
||||
}
|
||||
|
||||
describe(waitForBuildEndAsync, () => {
|
||||
let spinner: Ora;
|
||||
let publish: () => void;
|
||||
let watcherClose: jest.Mock;
|
||||
let logs: JobLogs;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
spinner = createSpinner();
|
||||
logs = new Map();
|
||||
publish = () => {};
|
||||
watcherClose = jest.fn();
|
||||
|
||||
jest.mocked(ora).mockReturnValue(spinner);
|
||||
jest.mocked(LogsWatcher).mockImplementation(((
|
||||
_createRealtimeLogsClient: unknown,
|
||||
onRealtimeLogs: () => void
|
||||
) => {
|
||||
publish = onRealtimeLogs;
|
||||
return {
|
||||
syncAsync: jest.fn(async () =>
|
||||
new Map<string, LogsState>([
|
||||
['build-id', { getLogs: () => logs, markCompleted: jest.fn() } as unknown as LogsState],
|
||||
])
|
||||
),
|
||||
close: watcherClose,
|
||||
};
|
||||
}) as unknown as () => LogsWatcher);
|
||||
jest.mocked(sleepAsync).mockImplementation(async () => {
|
||||
publish();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create a watcher when the spinner is disabled', async () => {
|
||||
jest.mocked(isSpinnerEnabled).mockReturnValue(false);
|
||||
setBuildStatuses([BuildStatus.Finished]);
|
||||
|
||||
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
|
||||
|
||||
expect(LogsWatcher).not.toHaveBeenCalled();
|
||||
expect(createRealtimeLogsClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the current phase and its log tail on publication while in progress', async () => {
|
||||
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
|
||||
setBuildStatuses([BuildStatus.InProgress, BuildStatus.Finished]);
|
||||
jest.mocked(sleepAsync).mockImplementationOnce(async () => {
|
||||
logs = groupLogLinesIntoSteps([
|
||||
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' },
|
||||
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'added 1 package' },
|
||||
]);
|
||||
publish();
|
||||
});
|
||||
|
||||
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
|
||||
|
||||
expect(LogsWatcher).toHaveBeenCalledTimes(1);
|
||||
expect(spinner.text).toContain('Build in progress...');
|
||||
expect(spinner.text).toContain('Install dependencies');
|
||||
expect(spinner.text).toContain('added 1 package');
|
||||
expect(watcherClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a publication that arrives while the build is queued', async () => {
|
||||
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
|
||||
setBuildStatuses([BuildStatus.InProgress, BuildStatus.InQueue, BuildStatus.Finished]);
|
||||
logs = groupLogLinesIntoSteps([{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' }]);
|
||||
|
||||
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
|
||||
|
||||
expect(jest.mocked(sleepAsync).mock.calls.length).toBe(2);
|
||||
expect(spinner.text).toBe('Build queued...');
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
} from './errors';
|
||||
import { transformMetadata } from './graphql';
|
||||
import { LocalBuildMode, runLocalBuildAsync } from './local';
|
||||
import {
|
||||
formatActiveBuildText,
|
||||
formatActiveBuildsText,
|
||||
isBuildCompleted,
|
||||
logSourceForBuild,
|
||||
} from './logs';
|
||||
import { collectMetadataAsync } from './metadata';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import {
|
||||
@@ -46,6 +52,8 @@ import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import { formatStarterSubscribeCommand } from '../billing/plans';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { LogsState } from '../commandUtils/logs/state';
|
||||
import { LogsWatcher } from '../commandUtils/logs/watcher';
|
||||
import { createFingerprintAsync } from '../fingerprint/cli';
|
||||
import {
|
||||
AppPlatform,
|
||||
@@ -60,7 +68,7 @@ import {
|
||||
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
|
||||
import { BuildQuery } from '../graphql/queries/BuildQuery';
|
||||
import Log, { learnMore, link } from '../log';
|
||||
import { Ora, ora } from '../ora';
|
||||
import { Ora, isSpinnerEnabled, ora, updateSpinnerText } from '../ora';
|
||||
import {
|
||||
RequestedPlatform,
|
||||
appPlatformDisplayNames,
|
||||
@@ -70,6 +78,7 @@ import {
|
||||
import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAsync';
|
||||
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
|
||||
import { uploadFileAtPathToGCSAsync } from '../uploads';
|
||||
import { createRealtimeLogsClient } from '../utils/centrifuge';
|
||||
import { formatBytes } from '../utils/files';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import { createProgressTracker } from '../utils/progress';
|
||||
@@ -538,19 +547,64 @@ export async function waitForBuildEndAsync(
|
||||
originalSpinnerText = 'Waiting for builds to complete. You can press Ctrl+C to exit.';
|
||||
spinner = ora('Waiting for builds to complete. You can press Ctrl+C to exit.').start();
|
||||
}
|
||||
while (true) {
|
||||
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
|
||||
const { refetch } =
|
||||
builds.length === 1
|
||||
? await handleSingleBuildProgressAsync({ build: builds[0], accountName }, { spinner })
|
||||
: await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText });
|
||||
if (!refetch) {
|
||||
return builds;
|
||||
let render = (): void => {};
|
||||
const watcher = isSpinnerEnabled()
|
||||
? new LogsWatcher(
|
||||
() => createRealtimeLogsClient(graphqlClient),
|
||||
() => {
|
||||
render();
|
||||
}
|
||||
)
|
||||
: null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
render = (): void => {};
|
||||
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
|
||||
const logsStates = await syncBuildLogsAsync(watcher, builds);
|
||||
const installRender = (nextRender: () => void): void => {
|
||||
render = nextRender;
|
||||
};
|
||||
const { refetch } =
|
||||
builds.length === 1
|
||||
? await handleSingleBuildProgressAsync(
|
||||
{ build: builds[0], accountName },
|
||||
{ spinner, logsStates, installRender }
|
||||
)
|
||||
: await handleMultipleBuildsProgressAsync(
|
||||
{ builds },
|
||||
{ spinner, originalSpinnerText, logsStates, installRender }
|
||||
);
|
||||
if (!refetch) {
|
||||
return builds;
|
||||
}
|
||||
await sleepAsync(intervalSec * 1000);
|
||||
}
|
||||
await sleepAsync(intervalSec * 1000);
|
||||
} finally {
|
||||
watcher?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncBuildLogsAsync(
|
||||
watcher: LogsWatcher | null,
|
||||
builds: MaybeBuildFragment[]
|
||||
): Promise<Map<string, LogsState>> {
|
||||
if (!watcher) {
|
||||
return new Map();
|
||||
}
|
||||
const existingBuilds = builds.filter(isBuildFragment);
|
||||
const logsStates = await watcher.syncAsync(existingBuilds.map(build => logSourceForBuild(build)));
|
||||
for (const build of existingBuilds) {
|
||||
if (isBuildCompleted(build.status)) {
|
||||
nullthrows(
|
||||
logsStates.get(build.id),
|
||||
'syncAsync must have been called before markCompleted'
|
||||
).markCompleted();
|
||||
}
|
||||
}
|
||||
return logsStates;
|
||||
}
|
||||
|
||||
async function getBuildsSafelyAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
buildIds: string[]
|
||||
@@ -570,6 +624,11 @@ interface BuildProgressResult {
|
||||
refetch: boolean;
|
||||
}
|
||||
|
||||
type BuildLogsProgressOptions = {
|
||||
logsStates: Map<string, LogsState>;
|
||||
installRender: (render: () => void) => void;
|
||||
};
|
||||
|
||||
let queueProgressBarStarted = false;
|
||||
const queueProgressBar = new cliProgress.SingleBar(
|
||||
{ format: '|{bar}| {estimatedWaitTime}' },
|
||||
@@ -587,10 +646,12 @@ async function handleSingleBuildProgressAsync(
|
||||
build: MaybeBuildFragment;
|
||||
accountName: string;
|
||||
},
|
||||
{ spinner }: { spinner: Ora }
|
||||
{ spinner, logsStates, installRender }: { spinner: Ora } & BuildLogsProgressOptions
|
||||
): Promise<BuildProgressResult> {
|
||||
if (build === null) {
|
||||
spinner.text = 'Could not fetch the build status. Check your network connection.';
|
||||
updateSpinnerText(spinner, {
|
||||
text: 'Could not fetch the build status. Check your network connection.',
|
||||
});
|
||||
return { refetch: true };
|
||||
}
|
||||
|
||||
@@ -615,16 +676,18 @@ async function handleSingleBuildProgressAsync(
|
||||
statusNewSetAt ??= now;
|
||||
const newStatusDurationMs = now - statusNewSetAt;
|
||||
if (newStatusDurationMs < NEW_STATUS_GRACE_PERIOD_MS) {
|
||||
spinner.text = 'Waiting for build to get enqueued…';
|
||||
updateSpinnerText(spinner, { text: 'Waiting for build to get enqueued…' });
|
||||
} else {
|
||||
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
|
||||
formatAccountBillingUrl(accountName)
|
||||
)}.`;
|
||||
updateSpinnerText(spinner, {
|
||||
text: `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
|
||||
formatAccountBillingUrl(accountName)
|
||||
)}.`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case BuildStatus.InQueue: {
|
||||
spinner.text = 'Build queued...';
|
||||
updateSpinnerText(spinner, { text: 'Build queued...' });
|
||||
const progressBarPayload =
|
||||
typeof build.estimatedWaitTimeLeftSeconds === 'number'
|
||||
? { estimatedWaitTime: formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds) }
|
||||
@@ -661,9 +724,17 @@ async function handleSingleBuildProgressAsync(
|
||||
case BuildStatus.Canceled:
|
||||
spinner.fail('Build canceled');
|
||||
return { refetch: false };
|
||||
case BuildStatus.InProgress:
|
||||
spinner.text = 'Build in progress...';
|
||||
case BuildStatus.InProgress: {
|
||||
const logsState = logsStates.get(build.id);
|
||||
const render = (): void => {
|
||||
updateSpinnerText(spinner, {
|
||||
text: formatActiveBuildText('Build in progress...', logsState?.getLogs() ?? new Map()),
|
||||
});
|
||||
};
|
||||
installRender(render);
|
||||
render();
|
||||
break;
|
||||
}
|
||||
case BuildStatus.Errored:
|
||||
spinner.fail('Build failed');
|
||||
if (build.error) {
|
||||
@@ -698,7 +769,12 @@ const platforms = [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
async function handleMultipleBuildsProgressAsync(
|
||||
{ builds: maybeBuilds }: { builds: MaybeBuildFragment[] },
|
||||
{ spinner, originalSpinnerText }: { spinner: Ora; originalSpinnerText: string }
|
||||
{
|
||||
spinner,
|
||||
originalSpinnerText,
|
||||
logsStates,
|
||||
installRender,
|
||||
}: { spinner: Ora; originalSpinnerText: string } & BuildLogsProgressOptions
|
||||
): Promise<BuildProgressResult> {
|
||||
const buildCount = maybeBuilds.length;
|
||||
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
|
||||
@@ -732,7 +808,24 @@ async function handleMultipleBuildsProgressAsync(
|
||||
someNew &&
|
||||
statusNewSetAt !== null &&
|
||||
Date.now() - statusNewSetAt >= NEW_STATUS_GRACE_PERIOD_MS;
|
||||
spinner.text = formatPendingBuildsText(originalSpinnerText, builds, showConcurrencyWarning);
|
||||
const pendingBuildsText = formatPendingBuildsText(
|
||||
originalSpinnerText,
|
||||
builds,
|
||||
showConcurrencyWarning
|
||||
);
|
||||
const inProgressBuilds = builds.filter(build => build.status === BuildStatus.InProgress);
|
||||
const render = (): void => {
|
||||
const text = formatActiveBuildsText(
|
||||
pendingBuildsText,
|
||||
inProgressBuilds.map(build => ({
|
||||
build,
|
||||
logs: logsStates.get(build.id)?.getLogs() ?? new Map(),
|
||||
}))
|
||||
);
|
||||
updateSpinnerText(spinner, { text });
|
||||
};
|
||||
installRender(render);
|
||||
render();
|
||||
return { refetch: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { BuildPhase, buildPhaseDisplayName } from '@expo/eas-build-job';
|
||||
|
||||
import { choicesFromJobLogs, stepLogTail } from '../commandUtils/logs/format';
|
||||
import { parseLogLines } from '../commandUtils/logs/parseLogs';
|
||||
import { JobLogs, RawLogLine } from '../commandUtils/logs/types';
|
||||
import { LogSource } from '../commandUtils/logs/watcher';
|
||||
import fetch from '../fetch';
|
||||
import { BuildFragment, BuildStatus, RealtimeLogsTargetType } from '../graphql/generated';
|
||||
import Log from '../log';
|
||||
import { appPlatformDisplayNames, appPlatformEmojis } from '../platform';
|
||||
import formatFields, { FormatFieldsItem } from '../utils/formatFields';
|
||||
|
||||
const LOGS_FETCH_TIMEOUT_MS = 5_000;
|
||||
const SINGLE_BUILD_MAX_LOG_LINES = 5;
|
||||
const MULTIPLE_BUILDS_MAX_LOG_LINES = 3;
|
||||
|
||||
async function fetchAndParseLogsForBuildAsync(build: BuildFragment): Promise<RawLogLine[] | null> {
|
||||
const logFileUrl = build.logFiles[0];
|
||||
if (!logFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(logFileUrl, { timeout: LOGS_FETCH_TIMEOUT_MS });
|
||||
const rawLogs = await response.text();
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
const { logLines, errors } = parseLogLines(rawLogs);
|
||||
for (const error of errors) {
|
||||
Log.debug(`Failed to parse a log line: ${error.message}`);
|
||||
}
|
||||
return logLines;
|
||||
}
|
||||
|
||||
export function logSourceForBuild(build: BuildFragment): LogSource {
|
||||
return {
|
||||
key: build.id,
|
||||
realtimeTarget: { type: RealtimeLogsTargetType.Build, id: build.id },
|
||||
isInProgress: build.status === BuildStatus.InProgress,
|
||||
fetchRawLogLinesAsync: async () => await fetchAndParseLogsForBuildAsync(build),
|
||||
};
|
||||
}
|
||||
|
||||
export function isBuildCompleted(status: BuildStatus): boolean {
|
||||
switch (status) {
|
||||
case BuildStatus.Finished:
|
||||
case BuildStatus.Errored:
|
||||
case BuildStatus.Canceled:
|
||||
return true;
|
||||
case BuildStatus.New:
|
||||
case BuildStatus.InQueue:
|
||||
case BuildStatus.InProgress:
|
||||
case BuildStatus.PendingCancel:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stepDisplayName(label: string): string {
|
||||
return Object.hasOwn(buildPhaseDisplayName, label)
|
||||
? buildPhaseDisplayName[label as BuildPhase]
|
||||
: label;
|
||||
}
|
||||
|
||||
function activeBuildFields(
|
||||
logs: JobLogs,
|
||||
maxLogLines: number,
|
||||
buildLabel: string | null
|
||||
): FormatFieldsItem[] {
|
||||
const steps = choicesFromJobLogs(logs);
|
||||
if (steps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const currentStep = steps[steps.length - 1];
|
||||
|
||||
const fields: FormatFieldsItem[] = [{ label: '', value: '' }];
|
||||
if (buildLabel !== null) {
|
||||
fields.push({ label: ' Build', value: buildLabel });
|
||||
}
|
||||
fields.push({ label: ' Current phase', value: stepDisplayName(currentStep.name) });
|
||||
if (currentStep.logLines?.length) {
|
||||
fields.push({ label: ' Current logs', value: '' });
|
||||
for (const logLine of stepLogTail(currentStep, maxLogLines)) {
|
||||
fields.push({ label: '', value: logLine });
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function appendFields(baseText: string, fields: FormatFieldsItem[]): string {
|
||||
if (fields.length === 0) {
|
||||
return baseText;
|
||||
}
|
||||
return `${baseText}\n${formatFields(fields)}`;
|
||||
}
|
||||
|
||||
export function formatActiveBuildText(baseText: string, logs: JobLogs): string {
|
||||
return appendFields(baseText, activeBuildFields(logs, SINGLE_BUILD_MAX_LOG_LINES, null));
|
||||
}
|
||||
|
||||
export function formatActiveBuildsText(
|
||||
baseText: string,
|
||||
buildsWithLogs: { build: BuildFragment; logs: JobLogs }[]
|
||||
): string {
|
||||
return appendFields(
|
||||
baseText,
|
||||
buildsWithLogs.flatMap(({ build, logs }) =>
|
||||
activeBuildFields(
|
||||
logs,
|
||||
MULTIPLE_BUILDS_MAX_LOG_LINES,
|
||||
`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]}`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
-15
@@ -45,11 +45,6 @@ describe(getProjectIdAsync, () => {
|
||||
id: 'user_id',
|
||||
email: '[email protected]',
|
||||
username: 'notnotbrent',
|
||||
primaryAccount: {
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
@@ -420,11 +415,6 @@ describe(getProjectIdAsync, () => {
|
||||
id: 'user_id',
|
||||
email: '[email protected]',
|
||||
username: 'notnotbrent',
|
||||
primaryAccount: {
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
@@ -524,11 +514,6 @@ describe(getProjectIdAsync, () => {
|
||||
id: 'user_id',
|
||||
email: '[email protected]',
|
||||
username: 'notnotbrent',
|
||||
primaryAccount: {
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs';
|
||||
import { WorkflowRawLogLine } from '../../types';
|
||||
import { RawLogLine } from '../types';
|
||||
|
||||
function logLine(overrides: Partial<WorkflowRawLogLine> = {}): WorkflowRawLogLine {
|
||||
function logLine(overrides: Partial<RawLogLine> = {}): RawLogLine {
|
||||
return { msg: 'a message', ...overrides };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { LogsState } from '../state';
|
||||
import { RawLogLine } from '../types';
|
||||
|
||||
function fileLine(logId: string, msg: string, buildStepId = 'install'): RawLogLine {
|
||||
return { logId, buildStepId, msg };
|
||||
}
|
||||
|
||||
function stepMessages(logsState: LogsState): string[] {
|
||||
return Array.from(logsState.getLogs().values()).flatMap(group =>
|
||||
group.logLines.map(logLine => logLine.msg)
|
||||
);
|
||||
}
|
||||
|
||||
describe(LogsState, () => {
|
||||
it('hides realtime lines until the file logs reach them', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('5', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('shows realtime lines once a published logId also appears in the file', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
|
||||
});
|
||||
|
||||
it('shows realtime logs when a publication repeats a logId already in the file', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('shows buffered realtime lines once the job is completed', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]);
|
||||
|
||||
logsState.markCompleted();
|
||||
logsState.markCompleted();
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('folds later publications once completion showed them', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('9', 'buffered')]);
|
||||
logsState.markCompleted();
|
||||
|
||||
logsState.ingestRealtimeLogLines([fileLine('10', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'buffered', 'pushed']);
|
||||
});
|
||||
|
||||
it('replaces the file snapshot instead of accumulating it', () => {
|
||||
const logsState = new LogsState();
|
||||
const keyless: RawLogLine = { buildStepId: 'install', msg: 'submission log' };
|
||||
|
||||
logsState.ingestFileLogLines([keyless]);
|
||||
logsState.ingestFileLogLines([keyless]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['submission log']);
|
||||
});
|
||||
|
||||
it('drops a buffered realtime line once it lands in the file', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('ignores publications that are not arrays of log lines', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
|
||||
expect(logsState.ingestRealtimeLogLines({ logId: '2', msg: 'not an array' })).toBe(false);
|
||||
expect(logsState.ingestRealtimeLogLines(['a string', 42, null])).toBe(false);
|
||||
expect(logsState.ingestRealtimeLogLines([{ msg: 'no logId' }])).toBe(false);
|
||||
expect(stepMessages(logsState)).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('deduplicates a line republished on the same channel', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('deduplicates a line republished later', () => {
|
||||
const logsState = new LogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { RealtimeLogsTargetType } from '../../../graphql/generated';
|
||||
import { RealtimeLogsClient } from '../../../utils/centrifuge';
|
||||
import { LogSource, LogsWatcher } from '../watcher';
|
||||
|
||||
function createFakeClient(): {
|
||||
client: RealtimeLogsClient;
|
||||
publish: (data: unknown) => void;
|
||||
subscribeCalls: unknown[];
|
||||
closeCount: () => number;
|
||||
} {
|
||||
const listeners: ((data: unknown) => void)[] = [];
|
||||
const subscribeCalls: unknown[] = [];
|
||||
let closed = 0;
|
||||
return {
|
||||
subscribeCalls,
|
||||
closeCount: () => closed,
|
||||
publish: data => {
|
||||
listeners.forEach(listener => {
|
||||
listener(data);
|
||||
});
|
||||
},
|
||||
client: {
|
||||
subscribeAsync: async (args, onPublication) => {
|
||||
subscribeCalls.push(args);
|
||||
listeners.push(onPublication);
|
||||
return { close: () => closed++ };
|
||||
},
|
||||
close: () => closed++,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(LogsWatcher, () => {
|
||||
let fetchRawLogLinesAsync: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchRawLogLinesAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
|
||||
});
|
||||
|
||||
function inProgressSource(overrides: Partial<LogSource> = {}): LogSource {
|
||||
return {
|
||||
key: 'job1',
|
||||
realtimeTarget: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' },
|
||||
isInProgress: true,
|
||||
fetchRawLogLinesAsync,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('subscribes once per in-progress source', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new LogsWatcher(
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
|
||||
expect(fake.subscribeCalls).toEqual([
|
||||
{ target: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports realtime logs as they arrive', async () => {
|
||||
const fake = createFakeClient();
|
||||
const onRealtimeLogs = jest.fn();
|
||||
const watcher = new LogsWatcher(() => fake.client, onRealtimeLogs);
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
|
||||
fake.publish([{ logId: '2', buildStepId: 'install', msg: 'pushed' }]);
|
||||
|
||||
expect(onRealtimeLogs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not report a publication that carries no usable log lines', async () => {
|
||||
const fake = createFakeClient();
|
||||
const onRealtimeLogs = jest.fn();
|
||||
const watcher = new LogsWatcher(() => fake.client, onRealtimeLogs);
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
|
||||
fake.publish([{ msg: 'no logId' }]);
|
||||
|
||||
expect(onRealtimeLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still fetches logs when the realtime logs client is unavailable', async () => {
|
||||
const watcher = new LogsWatcher(
|
||||
() => null,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
|
||||
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches logs on every sync while in progress, and stops once the source is not', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new LogsWatcher(
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
|
||||
|
||||
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
|
||||
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not fetch logs for a source that is not in progress', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new LogsWatcher(
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
|
||||
|
||||
expect(fetchRawLogLinesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the subscription when a source leaves in-progress', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new LogsWatcher(
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
expect(fake.closeCount()).toBe(0);
|
||||
|
||||
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
|
||||
expect(fake.closeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('closes the client and its subscriptions', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new LogsWatcher(
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncAsync([inProgressSource()]);
|
||||
watcher.close();
|
||||
|
||||
expect(fake.closeCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { JobLogs, LogLine } from './types';
|
||||
import { Choice } from '../../prompts';
|
||||
|
||||
export function choicesFromJobLogs(
|
||||
logs: JobLogs
|
||||
): (Choice & { name: string; status: string; logLines: LogLine[] | undefined })[] {
|
||||
return Array.from(logs.values())
|
||||
.map(({ key, label, result, logLines }) => ({
|
||||
title: `${label} - ${result ?? ''}`,
|
||||
name: label,
|
||||
status: result ?? '',
|
||||
value: key,
|
||||
logLines,
|
||||
}))
|
||||
.filter(step => step.status !== 'skipped');
|
||||
}
|
||||
|
||||
export function stepLogTail(
|
||||
step: { logLines?: LogLine[] },
|
||||
maxLogLines: number // -1 means no limit
|
||||
): string[] {
|
||||
const logLines = step.logLines ?? [];
|
||||
const tail = maxLogLines === -1 ? logLines : logLines.slice(-maxLogLines);
|
||||
return tail.map(line => line.msg);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { JobLogs, RawLogLine } from './types';
|
||||
import uniqBy from '../../utils/expodash/uniqBy';
|
||||
|
||||
export function parseLogLines(rawLogs: string): {
|
||||
logLines: RawLogLine[];
|
||||
errors: Error[];
|
||||
} {
|
||||
const logLines: RawLogLine[] = [];
|
||||
const errors: Error[] = [];
|
||||
|
||||
for (const rawLogLine of rawLogs.split('\n')) {
|
||||
if (!rawLogLine) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
logLines.push(JSON.parse(rawLogLine));
|
||||
} catch (err) {
|
||||
errors.push(err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
return { logLines, errors };
|
||||
}
|
||||
|
||||
export function mergeLogLines<T extends RawLogLine>(...logLineGroups: T[][]): T[] {
|
||||
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
|
||||
}
|
||||
|
||||
export function groupLogLinesIntoSteps(
|
||||
logLines: RawLogLine[],
|
||||
accumulator: JobLogs = new Map()
|
||||
): JobLogs {
|
||||
for (const logLine of logLines) {
|
||||
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine;
|
||||
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
|
||||
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
|
||||
if (!stepKey || !stepLabel || !msg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let logGroup = accumulator.get(stepKey);
|
||||
if (!logGroup) {
|
||||
logGroup = { key: stepKey, label: stepLabel, logLines: [] };
|
||||
accumulator.set(stepKey, logGroup);
|
||||
}
|
||||
if (buildStepDisplayName) {
|
||||
logGroup.label = buildStepDisplayName;
|
||||
}
|
||||
if (logGroup.result === undefined && (marker === 'end-step' || marker === 'END_PHASE')) {
|
||||
logGroup.result = result ?? '';
|
||||
}
|
||||
logGroup.logLines.push({ time, msg, result, marker, err });
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
|
||||
import { JobLogs, RawLogLine } from './types';
|
||||
|
||||
type RealtimeLogLine = RawLogLine & { logId: string };
|
||||
|
||||
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
|
||||
return (
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
typeof (entry as Partial<RawLogLine>).logId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export class LogsState {
|
||||
private fileLogIds = new Set<string>();
|
||||
private realtimeLogLines: RealtimeLogLine[] = [];
|
||||
private realtimeLogIds = new Set<string>();
|
||||
private haveFileLogsCaughtUp = false;
|
||||
private realtimeLogsRevealed = false;
|
||||
private jobLogs: JobLogs = new Map();
|
||||
|
||||
public ingestFileLogLines(logLines: RawLogLine[]): void {
|
||||
this.fileLogIds = new Set(logLines.flatMap(logLine => (logLine.logId ? [logLine.logId] : [])));
|
||||
|
||||
if (
|
||||
!this.haveFileLogsCaughtUp &&
|
||||
this.realtimeLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
|
||||
) {
|
||||
this.haveFileLogsCaughtUp = true;
|
||||
}
|
||||
this.realtimeLogLines = this.realtimeLogLines.filter(
|
||||
logLine => !this.fileLogIds.has(logLine.logId)
|
||||
);
|
||||
this.realtimeLogIds = new Set(this.realtimeLogLines.map(logLine => logLine.logId));
|
||||
|
||||
if (this.haveFileLogsCaughtUp) {
|
||||
this.realtimeLogsRevealed = true;
|
||||
}
|
||||
this.jobLogs = groupLogLinesIntoSteps(
|
||||
mergeLogLines(logLines, this.realtimeLogsRevealed ? this.realtimeLogLines : [])
|
||||
);
|
||||
}
|
||||
|
||||
public ingestRealtimeLogLines(data: unknown): boolean {
|
||||
if (!Array.isArray(data)) {
|
||||
return false;
|
||||
}
|
||||
const publishedLogLines = data.filter(isRealtimeLogLine);
|
||||
if (publishedLogLines.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.haveFileLogsCaughtUp &&
|
||||
publishedLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
|
||||
) {
|
||||
this.haveFileLogsCaughtUp = true;
|
||||
}
|
||||
|
||||
const newLogLines = publishedLogLines.filter(
|
||||
logLine => !this.fileLogIds.has(logLine.logId) && !this.realtimeLogIds.has(logLine.logId)
|
||||
);
|
||||
for (const logLine of newLogLines) {
|
||||
this.realtimeLogLines.push(logLine);
|
||||
this.realtimeLogIds.add(logLine.logId);
|
||||
}
|
||||
|
||||
if (!this.realtimeLogsRevealed) {
|
||||
if (this.haveFileLogsCaughtUp) {
|
||||
this.revealRealtimeLogs();
|
||||
}
|
||||
} else if (newLogLines.length > 0) {
|
||||
groupLogLinesIntoSteps(newLogLines, this.jobLogs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public markCompleted(): void {
|
||||
this.revealRealtimeLogs();
|
||||
}
|
||||
|
||||
public getLogs(): JobLogs {
|
||||
return this.jobLogs;
|
||||
}
|
||||
|
||||
private revealRealtimeLogs(): void {
|
||||
if (this.realtimeLogsRevealed) {
|
||||
return;
|
||||
}
|
||||
this.realtimeLogsRevealed = true;
|
||||
groupLogLinesIntoSteps(this.realtimeLogLines, this.jobLogs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type RawLogLine = {
|
||||
logId?: string;
|
||||
buildStepId?: string;
|
||||
buildStepDisplayName?: string;
|
||||
phase?: string;
|
||||
time?: string;
|
||||
msg?: string;
|
||||
result?: string;
|
||||
marker?: string;
|
||||
err?: any;
|
||||
};
|
||||
|
||||
export type LogLine = Pick<RawLogLine, 'time' | 'result' | 'marker' | 'err'> &
|
||||
Required<Pick<RawLogLine, 'msg'>>;
|
||||
|
||||
export type StepLogs = {
|
||||
key: string;
|
||||
label: string;
|
||||
result?: string;
|
||||
logLines: LogLine[];
|
||||
};
|
||||
|
||||
export type JobLogs = Map<string, StepLogs>;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { LogsState } from './state';
|
||||
import { RawLogLine } from './types';
|
||||
import { RealtimeLogsTargetInput } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../utils/centrifuge';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
export type LogSource = {
|
||||
key: string;
|
||||
realtimeTarget: RealtimeLogsTargetInput | null;
|
||||
isInProgress: boolean;
|
||||
fetchRawLogLinesAsync: () => Promise<RawLogLine[] | null>;
|
||||
};
|
||||
|
||||
type TrackedSource = {
|
||||
logsState: LogsState;
|
||||
subscription: RealtimeLogsSubscription | null;
|
||||
};
|
||||
|
||||
export class LogsWatcher {
|
||||
private readonly trackedSources = new Map<string, TrackedSource>();
|
||||
private realtimeLogsClient?: RealtimeLogsClient | null;
|
||||
|
||||
constructor(
|
||||
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
|
||||
private readonly onRealtimeLogs: () => void
|
||||
) {}
|
||||
|
||||
public async syncAsync(sources: LogSource[]): Promise<Map<string, LogsState>> {
|
||||
await Promise.all(sources.map(source => this.syncSourceAsync(source)));
|
||||
return new Map(
|
||||
sources.map(source => [
|
||||
source.key,
|
||||
nullthrows(
|
||||
this.trackedSources.get(source.key),
|
||||
'source has to be in tracked sources after it was synced'
|
||||
).logsState,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
for (const trackedSource of this.trackedSources.values()) {
|
||||
trackedSource.subscription?.close();
|
||||
trackedSource.subscription = null;
|
||||
}
|
||||
this.realtimeLogsClient?.close();
|
||||
}
|
||||
|
||||
private async syncSourceAsync(source: LogSource): Promise<void> {
|
||||
let trackedSource = this.trackedSources.get(source.key);
|
||||
if (!trackedSource) {
|
||||
trackedSource = { logsState: new LogsState(), subscription: null };
|
||||
this.trackedSources.set(source.key, trackedSource);
|
||||
}
|
||||
|
||||
if (!source.isInProgress) {
|
||||
trackedSource.subscription?.close();
|
||||
trackedSource.subscription = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
trackedSource.subscription ? Promise.resolve() : this.subscribeAsync(source, trackedSource),
|
||||
this.fetchLogsAsync(source, trackedSource),
|
||||
]);
|
||||
}
|
||||
|
||||
private async fetchLogsAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
|
||||
try {
|
||||
const logLines = await source.fetchRawLogLinesAsync();
|
||||
if (logLines) {
|
||||
trackedSource.logsState.ingestFileLogLines(logLines);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Log.debug(`Failed to fetch logs for job ${source.key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getRealtimeLogsClient(): RealtimeLogsClient | null {
|
||||
if (this.realtimeLogsClient === undefined) {
|
||||
this.realtimeLogsClient = this.createRealtimeLogsClient();
|
||||
}
|
||||
return this.realtimeLogsClient;
|
||||
}
|
||||
|
||||
private async subscribeAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
|
||||
const target = source.realtimeTarget;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const realtimeLogsClient = this.getRealtimeLogsClient();
|
||||
if (!realtimeLogsClient) {
|
||||
return;
|
||||
}
|
||||
trackedSource.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
|
||||
if (trackedSource.logsState.ingestRealtimeLogLines(data)) {
|
||||
this.onRealtimeLogs();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { getMockWorkflowCustomJobFragment } from '../../../../__tests__/commands/utils';
|
||||
import { BuildQuery } from '../../../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../../../context/contextUtils/createGraphqlClient';
|
||||
import { getMockWorkflowCustomJobFragment } from '../../../__tests__/commands/utils';
|
||||
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
|
||||
import { fetchRawLogsForJobAsync } from '../fetchLogs';
|
||||
|
||||
jest.mock('../../../../graphql/queries/BuildQuery');
|
||||
jest.mock('../../../graphql/queries/BuildQuery');
|
||||
|
||||
describe(fetchRawLogsForJobAsync, () => {
|
||||
afterEach(() => {
|
||||
@@ -0,0 +1,108 @@
|
||||
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
RealtimeLogsTargetType,
|
||||
WorkflowJobStatus,
|
||||
WorkflowJobType,
|
||||
} from '../../../graphql/generated';
|
||||
import { fetchRawLogsForJobAsync } from '../fetchLogs';
|
||||
import { logSourceForWorkflowJob } from '../logs';
|
||||
import { WorkflowJobResult } from '../types';
|
||||
|
||||
jest.mock('../fetchLogs');
|
||||
|
||||
function inProgressJob(): WorkflowJobResult {
|
||||
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
|
||||
return { ...job, status: WorkflowJobStatus.InProgress };
|
||||
}
|
||||
|
||||
describe(logSourceForWorkflowJob, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keys the source by the job id', () => {
|
||||
expect(logSourceForWorkflowJob({} as any, inProgressJob()).key).toBe('job1');
|
||||
});
|
||||
|
||||
it('is in progress only while the job is in progress', () => {
|
||||
const job = inProgressJob();
|
||||
|
||||
expect(logSourceForWorkflowJob({} as any, job).isInProgress).toBe(true);
|
||||
for (const status of [
|
||||
WorkflowJobStatus.New,
|
||||
WorkflowJobStatus.ActionRequired,
|
||||
WorkflowJobStatus.PendingCancel,
|
||||
WorkflowJobStatus.Success,
|
||||
WorkflowJobStatus.Failure,
|
||||
WorkflowJobStatus.Canceled,
|
||||
WorkflowJobStatus.Skipped,
|
||||
]) {
|
||||
expect(logSourceForWorkflowJob({} as any, { ...job, status }).isInProgress).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('targets the turtle job run when there is one', () => {
|
||||
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
|
||||
|
||||
expect(logSourceForWorkflowJob({} as any, job).realtimeTarget).toEqual({
|
||||
type: RealtimeLogsTargetType.JobRun,
|
||||
id: 'job-run-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the build when there is no turtle job run', () => {
|
||||
const withTurtleBuild = {
|
||||
...inProgressJob(),
|
||||
turtleJobRun: null,
|
||||
turtleBuild: { id: 'build-id' },
|
||||
} as WorkflowJobResult;
|
||||
const withBuildOutput = {
|
||||
...inProgressJob(),
|
||||
turtleJobRun: null,
|
||||
turtleBuild: null,
|
||||
outputs: { build_id: 'output-build-id' },
|
||||
} as WorkflowJobResult;
|
||||
|
||||
expect(logSourceForWorkflowJob({} as any, withTurtleBuild).realtimeTarget).toEqual({
|
||||
type: RealtimeLogsTargetType.Build,
|
||||
id: 'build-id',
|
||||
});
|
||||
expect(logSourceForWorkflowJob({} as any, withBuildOutput).realtimeTarget).toEqual({
|
||||
type: RealtimeLogsTargetType.Build,
|
||||
id: 'output-build-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('has no realtime target when neither is present', () => {
|
||||
const job = {
|
||||
...inProgressJob(),
|
||||
turtleJobRun: null,
|
||||
turtleBuild: null,
|
||||
outputs: null,
|
||||
} as WorkflowJobResult;
|
||||
|
||||
expect(logSourceForWorkflowJob({} as any, job).realtimeTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('fetches and parses the job logs with the graphql client it was given', async () => {
|
||||
const graphqlClient = {} as any;
|
||||
const job = { ...inProgressJob(), type: WorkflowJobType.Build } as WorkflowJobResult;
|
||||
jest
|
||||
.mocked(fetchRawLogsForJobAsync)
|
||||
.mockResolvedValue(
|
||||
[
|
||||
'{"logId":"1","buildStepId":"install","msg":"npm ci"}',
|
||||
'{"logId":"2","buildStepId":"install","msg":"done"}',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
await expect(
|
||||
logSourceForWorkflowJob(graphqlClient, job).fetchRawLogLinesAsync()
|
||||
).resolves.toEqual([
|
||||
{ logId: '1', buildStepId: 'install', msg: 'npm ci' },
|
||||
{ logId: '2', buildStepId: 'install', msg: 'done' },
|
||||
]);
|
||||
|
||||
expect(fetchRawLogsForJobAsync).toHaveBeenCalledWith({ graphqlClient }, job);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
|
||||
import { WorkflowJobStatus } from '../../../graphql/generated';
|
||||
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
|
||||
import { WorkflowLogs, WorkflowRawLogLine } from '../types';
|
||||
import { groupLogLinesIntoSteps, parseLogLines } from '../../logs/parseLogs';
|
||||
import { JobLogs, RawLogLine } from '../../logs/types';
|
||||
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
|
||||
|
||||
function jobWithLogs(
|
||||
logLines: WorkflowRawLogLine[],
|
||||
logLines: RawLogLine[],
|
||||
status: WorkflowJobStatus = WorkflowJobStatus.InProgress
|
||||
): {
|
||||
job: ReturnType<typeof getMockWorkflowRunWithJobsFragment>['jobs'][number];
|
||||
logs: WorkflowLogs;
|
||||
logs: JobLogs;
|
||||
} {
|
||||
return {
|
||||
job: { ...getMockWorkflowRunWithJobsFragment().jobs[0], status },
|
||||
@@ -17,7 +17,7 @@ function jobWithLogs(
|
||||
};
|
||||
}
|
||||
|
||||
function stepLines(buildStepId: string, count: number): WorkflowRawLogLine[] {
|
||||
function stepLines(buildStepId: string, count: number): RawLogLine[] {
|
||||
return Array.from({ length: count }, (_, index) => ({ buildStepId, msg: `line${index}` }));
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { WorkflowJobResult } from '../types';
|
||||
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
|
||||
import { WorkflowJobResult } from './types';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
export async function fetchRawLogsForJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
@@ -0,0 +1,59 @@
|
||||
import { fetchRawLogsForJobAsync } from './fetchLogs';
|
||||
import { WorkflowJobResult } from './types';
|
||||
import {
|
||||
RealtimeLogsTargetInput,
|
||||
RealtimeLogsTargetType,
|
||||
WorkflowJobStatus,
|
||||
} from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
|
||||
import { JobLogs, RawLogLine } from '../logs/types';
|
||||
import { LogSource } from '../logs/watcher';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
async function fetchAndParseLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<RawLogLine[] | null> {
|
||||
const rawLogs = await fetchRawLogsForJobAsync(state, job);
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const { logLines, errors } = parseLogLines(rawLogs);
|
||||
for (const error of errors) {
|
||||
Log.debug(`Failed to parse a log line: ${error.message}`);
|
||||
}
|
||||
return logLines;
|
||||
}
|
||||
|
||||
export async function fetchAndProcessLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<JobLogs | null> {
|
||||
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
|
||||
return logLines && groupLogLinesIntoSteps(logLines);
|
||||
}
|
||||
|
||||
export function logSourceForWorkflowJob(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
job: WorkflowJobResult
|
||||
): LogSource {
|
||||
return {
|
||||
key: job.id,
|
||||
realtimeTarget: realtimeLogsTargetForJob(job),
|
||||
isInProgress: job.status === WorkflowJobStatus.InProgress,
|
||||
fetchRawLogLinesAsync: async () => await fetchAndParseLogsFromJobAsync({ graphqlClient }, job),
|
||||
};
|
||||
}
|
||||
|
||||
function realtimeLogsTargetForJob(job: WorkflowJobResult): RealtimeLogsTargetInput | null {
|
||||
if (job.turtleJobRun?.id) {
|
||||
return { type: RealtimeLogsTargetType.JobRun, id: job.turtleJobRun.id };
|
||||
}
|
||||
const buildId = job.turtleBuild?.id ?? job.outputs?.build_id;
|
||||
if (buildId) {
|
||||
return { type: RealtimeLogsTargetType.Build, id: buildId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import { getMockWorkflowRunWithJobsFragment } from '../../../../__tests__/commands/utils';
|
||||
import { RealtimeLogsTargetType, WorkflowJobStatus } from '../../../../graphql/generated';
|
||||
import { RealtimeLogsClient } from '../../../../utils/centrifuge';
|
||||
import { fetchAndParseLogsFromJobAsync } from '../parseLogs';
|
||||
import { WorkflowJobLogsState, WorkflowRunLogsWatcher, realtimeLogsTargetForJob } from '../watcher';
|
||||
import { WorkflowJobResult, WorkflowRawLogLine } from '../../types';
|
||||
|
||||
jest.mock('../parseLogs', () => ({
|
||||
...jest.requireActual('../parseLogs'),
|
||||
fetchAndParseLogsFromJobAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
function createFakeClient(): {
|
||||
client: RealtimeLogsClient;
|
||||
publish: (data: unknown) => void;
|
||||
subscribeCalls: unknown[];
|
||||
closeCount: () => number;
|
||||
} {
|
||||
const listeners: ((data: unknown) => void)[] = [];
|
||||
const subscribeCalls: unknown[] = [];
|
||||
let closed = 0;
|
||||
return {
|
||||
subscribeCalls,
|
||||
closeCount: () => closed,
|
||||
publish: data => {
|
||||
listeners.forEach(listener => {
|
||||
listener(data);
|
||||
});
|
||||
},
|
||||
client: {
|
||||
subscribeAsync: async (args, onPublication) => {
|
||||
subscribeCalls.push(args);
|
||||
listeners.push(onPublication);
|
||||
return { close: () => closed++ };
|
||||
},
|
||||
close: () => closed++,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function inProgressJob(): WorkflowJobResult {
|
||||
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
|
||||
return { ...job, status: WorkflowJobStatus.InProgress };
|
||||
}
|
||||
|
||||
describe(realtimeLogsTargetForJob, () => {
|
||||
it('targets the turtle job run when there is one', () => {
|
||||
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
|
||||
|
||||
expect(realtimeLogsTargetForJob(job)).toEqual({
|
||||
type: RealtimeLogsTargetType.JobRun,
|
||||
id: 'job-run-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the build when there is no turtle job run', () => {
|
||||
const job = {
|
||||
...inProgressJob(),
|
||||
turtleJobRun: null,
|
||||
turtleBuild: { id: 'build-id' },
|
||||
} as WorkflowJobResult;
|
||||
|
||||
expect(realtimeLogsTargetForJob(job)).toEqual({
|
||||
type: RealtimeLogsTargetType.Build,
|
||||
id: 'build-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('has no target when neither is present', () => {
|
||||
const job = {
|
||||
...inProgressJob(),
|
||||
turtleJobRun: null,
|
||||
turtleBuild: null,
|
||||
outputs: null,
|
||||
} as WorkflowJobResult;
|
||||
|
||||
expect(realtimeLogsTargetForJob(job)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkflowRunLogsWatcher, () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(fetchAndParseLogsFromJobAsync)
|
||||
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('subscribes once per in-progress job', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
const job = inProgressJob();
|
||||
|
||||
await watcher.syncJobsAsync([job]);
|
||||
await watcher.syncJobsAsync([job]);
|
||||
|
||||
expect(fake.subscribeCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports realtime logs as they arrive', async () => {
|
||||
const fake = createFakeClient();
|
||||
const onRealtimeLogs = jest.fn();
|
||||
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
|
||||
await watcher.syncJobsAsync([inProgressJob()]);
|
||||
|
||||
fake.publish([{ logId: '2', buildStepId: 'install', msg: 'pushed' }]);
|
||||
|
||||
expect(onRealtimeLogs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not report a publication that carries no usable log lines', async () => {
|
||||
const fake = createFakeClient();
|
||||
const onRealtimeLogs = jest.fn();
|
||||
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
|
||||
await watcher.syncJobsAsync([inProgressJob()]);
|
||||
|
||||
fake.publish([{ msg: 'no logId' }]);
|
||||
|
||||
expect(onRealtimeLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still fetches logs when the realtime logs client is unavailable', async () => {
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => null,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncJobsAsync([inProgressJob()]);
|
||||
|
||||
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches logs on every sync while in progress, and stops once the job completes', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
const job = inProgressJob();
|
||||
|
||||
await watcher.syncJobsAsync([job]);
|
||||
await watcher.syncJobsAsync([job]);
|
||||
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
|
||||
|
||||
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Success }]);
|
||||
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not fetch logs for a job that has not started', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncJobsAsync([{ ...inProgressJob(), status: WorkflowJobStatus.New }]);
|
||||
|
||||
expect(fetchAndParseLogsFromJobAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the subscription when a job leaves in-progress', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
const job = inProgressJob();
|
||||
|
||||
await watcher.syncJobsAsync([job]);
|
||||
expect(fake.closeCount()).toBe(0);
|
||||
|
||||
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Failure }]);
|
||||
expect(fake.closeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('closes the client and its subscriptions', async () => {
|
||||
const fake = createFakeClient();
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
{} as any,
|
||||
() => fake.client,
|
||||
() => {}
|
||||
);
|
||||
|
||||
await watcher.syncJobsAsync([inProgressJob()]);
|
||||
watcher.close();
|
||||
|
||||
expect(fake.closeCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
function fileLine(logId: string, msg: string, buildStepId = 'install'): WorkflowRawLogLine {
|
||||
return { logId, buildStepId, msg };
|
||||
}
|
||||
|
||||
function stepMessages(logsState: WorkflowJobLogsState): string[] {
|
||||
return Array.from(logsState.getLogs().values()).flatMap(group =>
|
||||
group.logLines.map(logLine => logLine.msg)
|
||||
);
|
||||
}
|
||||
|
||||
describe(WorkflowJobLogsState, () => {
|
||||
it('hides realtime lines until the file logs reach them', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('5', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('shows realtime lines once a published logId also appears in the file', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
|
||||
});
|
||||
|
||||
it('shows realtime logs when a publication repeats a logId already in the file', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('shows buffered realtime lines once the job is completed', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]);
|
||||
|
||||
logsState.markCompleted();
|
||||
logsState.markCompleted();
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('folds later publications once completion showed them', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('9', 'buffered')]);
|
||||
logsState.markCompleted();
|
||||
|
||||
logsState.ingestRealtimeLogLines([fileLine('10', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'buffered', 'pushed']);
|
||||
});
|
||||
|
||||
it('replaces the file snapshot instead of accumulating it', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
const keyless: WorkflowRawLogLine = { buildStepId: 'install', msg: 'submission log' };
|
||||
|
||||
logsState.ingestFileLogLines([keyless]);
|
||||
logsState.ingestFileLogLines([keyless]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['submission log']);
|
||||
});
|
||||
|
||||
it('drops a buffered realtime line once it lands in the file', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('ignores publications that are not arrays of log lines', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
|
||||
expect(logsState.ingestRealtimeLogLines({ logId: '2', msg: 'not an array' })).toBe(false);
|
||||
expect(logsState.ingestRealtimeLogLines(['a string', 42, null])).toBe(false);
|
||||
expect(logsState.ingestRealtimeLogLines([{ msg: 'no logId' }])).toBe(false);
|
||||
expect(stepMessages(logsState)).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('deduplicates a line republished on the same channel', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
|
||||
});
|
||||
|
||||
it('deduplicates a line republished later', () => {
|
||||
const logsState = new WorkflowJobLogsState();
|
||||
logsState.ingestFileLogLines([fileLine('1', 'first')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
|
||||
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
|
||||
|
||||
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { fetchRawLogsForJobAsync } from './fetchLogs';
|
||||
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
|
||||
import Log from '../../../log';
|
||||
import uniqBy from '../../../utils/expodash/uniqBy';
|
||||
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
|
||||
|
||||
export function parseLogLines(rawLogs: string): {
|
||||
logLines: WorkflowRawLogLine[];
|
||||
errors: Error[];
|
||||
} {
|
||||
const logLines: WorkflowRawLogLine[] = [];
|
||||
const errors: Error[] = [];
|
||||
|
||||
for (const rawLogLine of rawLogs.split('\n')) {
|
||||
if (!rawLogLine) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
logLines.push(JSON.parse(rawLogLine));
|
||||
} catch (err) {
|
||||
errors.push(err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
return { logLines, errors };
|
||||
}
|
||||
|
||||
export function mergeLogLines<T extends WorkflowRawLogLine>(...logLineGroups: T[][]): T[] {
|
||||
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
|
||||
}
|
||||
|
||||
export function groupLogLinesIntoSteps(
|
||||
logLines: WorkflowRawLogLine[],
|
||||
accumulator: WorkflowLogs = new Map()
|
||||
): WorkflowLogs {
|
||||
for (const logLine of logLines) {
|
||||
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine;
|
||||
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
|
||||
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
|
||||
if (!stepKey || !stepLabel || !msg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let logGroup = accumulator.get(stepKey);
|
||||
if (!logGroup) {
|
||||
logGroup = { key: stepKey, label: stepLabel, logLines: [] };
|
||||
accumulator.set(stepKey, logGroup);
|
||||
}
|
||||
if (buildStepDisplayName) {
|
||||
logGroup.label = buildStepDisplayName;
|
||||
}
|
||||
if (logGroup.result === undefined && (marker === 'end-step' || marker === 'END_PHASE')) {
|
||||
logGroup.result = result ?? '';
|
||||
}
|
||||
logGroup.logLines.push({ time, msg, result, marker, err });
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
export async function fetchAndParseLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowRawLogLine[] | null> {
|
||||
const rawLogs = await fetchRawLogsForJobAsync(state, job);
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const { logLines, errors } = parseLogLines(rawLogs);
|
||||
for (const error of errors) {
|
||||
Log.debug(`Failed to parse a log line: ${error.message}`);
|
||||
}
|
||||
return logLines;
|
||||
}
|
||||
|
||||
export async function fetchAndProcessLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowLogs | null> {
|
||||
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
|
||||
return logLines && groupLogLinesIntoSteps(logLines);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { fetchAndParseLogsFromJobAsync, groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
|
||||
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
|
||||
import {
|
||||
RealtimeLogsTargetInput,
|
||||
RealtimeLogsTargetType,
|
||||
WorkflowJobStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../../utils/centrifuge';
|
||||
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
type RealtimeLogLine = WorkflowRawLogLine & { logId: string };
|
||||
|
||||
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
|
||||
return (
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
typeof (entry as Partial<WorkflowRawLogLine>).logId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export class WorkflowJobLogsState {
|
||||
private fileLogIds = new Set<string>();
|
||||
private realtimeLogLines: RealtimeLogLine[] = [];
|
||||
private realtimeLogIds = new Set<string>();
|
||||
private haveFileLogsCaughtUp = false;
|
||||
private realtimeLogsRevealed = false;
|
||||
private groupedLogs: WorkflowLogs = new Map();
|
||||
|
||||
public ingestFileLogLines(logLines: WorkflowRawLogLine[]): void {
|
||||
this.fileLogIds = new Set(logLines.flatMap(logLine => (logLine.logId ? [logLine.logId] : [])));
|
||||
|
||||
if (
|
||||
!this.haveFileLogsCaughtUp &&
|
||||
this.realtimeLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
|
||||
) {
|
||||
this.haveFileLogsCaughtUp = true;
|
||||
}
|
||||
this.realtimeLogLines = this.realtimeLogLines.filter(
|
||||
logLine => !this.fileLogIds.has(logLine.logId)
|
||||
);
|
||||
this.realtimeLogIds = new Set(this.realtimeLogLines.map(logLine => logLine.logId));
|
||||
|
||||
if (this.haveFileLogsCaughtUp) {
|
||||
this.realtimeLogsRevealed = true;
|
||||
}
|
||||
this.groupedLogs = groupLogLinesIntoSteps(
|
||||
mergeLogLines(logLines, this.realtimeLogsRevealed ? this.realtimeLogLines : [])
|
||||
);
|
||||
}
|
||||
|
||||
public ingestRealtimeLogLines(data: unknown): boolean {
|
||||
if (!Array.isArray(data)) {
|
||||
return false;
|
||||
}
|
||||
const publishedLogLines = data.filter(isRealtimeLogLine);
|
||||
if (publishedLogLines.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.haveFileLogsCaughtUp &&
|
||||
publishedLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
|
||||
) {
|
||||
this.haveFileLogsCaughtUp = true;
|
||||
}
|
||||
|
||||
const newLogLines = publishedLogLines.filter(
|
||||
logLine => !this.fileLogIds.has(logLine.logId) && !this.realtimeLogIds.has(logLine.logId)
|
||||
);
|
||||
for (const logLine of newLogLines) {
|
||||
this.realtimeLogLines.push(logLine);
|
||||
this.realtimeLogIds.add(logLine.logId);
|
||||
}
|
||||
|
||||
if (!this.realtimeLogsRevealed) {
|
||||
if (this.haveFileLogsCaughtUp) {
|
||||
this.revealRealtimeLogs();
|
||||
}
|
||||
} else if (newLogLines.length > 0) {
|
||||
groupLogLinesIntoSteps(newLogLines, this.groupedLogs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public markCompleted(): void {
|
||||
this.revealRealtimeLogs();
|
||||
}
|
||||
|
||||
public getLogs(): WorkflowLogs {
|
||||
return this.groupedLogs;
|
||||
}
|
||||
|
||||
private revealRealtimeLogs(): void {
|
||||
if (this.realtimeLogsRevealed) {
|
||||
return;
|
||||
}
|
||||
this.realtimeLogsRevealed = true;
|
||||
groupLogLinesIntoSteps(this.realtimeLogLines, this.groupedLogs);
|
||||
}
|
||||
}
|
||||
|
||||
type TrackedJob = {
|
||||
logsState: WorkflowJobLogsState;
|
||||
subscription: RealtimeLogsSubscription | null;
|
||||
};
|
||||
|
||||
export function realtimeLogsTargetForJob(job: WorkflowJobResult): RealtimeLogsTargetInput | null {
|
||||
if (job.turtleJobRun?.id) {
|
||||
return { type: RealtimeLogsTargetType.JobRun, id: job.turtleJobRun.id };
|
||||
}
|
||||
const buildId = job.turtleBuild?.id ?? job.outputs?.build_id;
|
||||
if (buildId) {
|
||||
return { type: RealtimeLogsTargetType.Build, id: buildId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class WorkflowRunLogsWatcher {
|
||||
private readonly trackedJobs = new Map<string, TrackedJob>();
|
||||
private realtimeLogsClient?: RealtimeLogsClient | null;
|
||||
|
||||
constructor(
|
||||
private readonly graphqlClient: ExpoGraphqlClient,
|
||||
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
|
||||
private readonly onRealtimeLogs: () => void
|
||||
) {}
|
||||
|
||||
public async syncJobsAsync(
|
||||
jobs: WorkflowJobResult[]
|
||||
): Promise<Map<string, WorkflowJobLogsState>> {
|
||||
await Promise.all(jobs.map(job => this.syncJobAsync(job)));
|
||||
return new Map(
|
||||
jobs.map(job => [
|
||||
job.id,
|
||||
nullthrows(
|
||||
this.trackedJobs.get(job.id),
|
||||
'job has to be in tracked jobs after it was synced'
|
||||
).logsState,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
for (const trackedJob of this.trackedJobs.values()) {
|
||||
trackedJob.subscription?.close();
|
||||
trackedJob.subscription = null;
|
||||
}
|
||||
this.realtimeLogsClient?.close();
|
||||
}
|
||||
|
||||
private async syncJobAsync(job: WorkflowJobResult): Promise<void> {
|
||||
let trackedJob = this.trackedJobs.get(job.id);
|
||||
if (!trackedJob) {
|
||||
trackedJob = { logsState: new WorkflowJobLogsState(), subscription: null };
|
||||
this.trackedJobs.set(job.id, trackedJob);
|
||||
}
|
||||
|
||||
const workflowInProgress = job.status === WorkflowJobStatus.InProgress;
|
||||
if (!workflowInProgress) {
|
||||
trackedJob.subscription?.close();
|
||||
trackedJob.subscription = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
trackedJob.subscription ? Promise.resolve() : this.subscribeAsync(job, trackedJob),
|
||||
this.fetchLogsAsync(job, trackedJob),
|
||||
]);
|
||||
}
|
||||
|
||||
private async fetchLogsAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
|
||||
try {
|
||||
const logLines = await fetchAndParseLogsFromJobAsync(
|
||||
{ graphqlClient: this.graphqlClient },
|
||||
job
|
||||
);
|
||||
if (logLines) {
|
||||
trackedJob.logsState.ingestFileLogLines(logLines);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Log.debug(`Failed to fetch logs for job ${job.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getRealtimeLogsClient(): RealtimeLogsClient | null {
|
||||
if (this.realtimeLogsClient === undefined) {
|
||||
this.realtimeLogsClient = this.createRealtimeLogsClient();
|
||||
}
|
||||
return this.realtimeLogsClient;
|
||||
}
|
||||
|
||||
private async subscribeAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
|
||||
const target = realtimeLogsTargetForJob(job);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const realtimeLogsClient = this.getRealtimeLogsClient();
|
||||
if (!realtimeLogsClient) {
|
||||
return;
|
||||
}
|
||||
trackedJob.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
|
||||
if (trackedJob.logsState.ingestRealtimeLogLines(data)) {
|
||||
this.onRealtimeLogs();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import { Choice } from 'prompts';
|
||||
|
||||
import { WorkflowJobResult, WorkflowLogs } from './types';
|
||||
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
|
||||
import {
|
||||
choiceFromWorkflowJob,
|
||||
choiceFromWorkflowRun,
|
||||
choicesFromWorkflowLogs,
|
||||
processWorkflowRuns,
|
||||
} from './utils';
|
||||
import { WorkflowJobResult } from './types';
|
||||
import { fetchAndProcessLogsFromJobAsync } from './logs';
|
||||
import { choicesFromJobLogs } from '../logs/format';
|
||||
import { JobLogs } from '../logs/types';
|
||||
import { choiceFromWorkflowJob, choiceFromWorkflowRun, processWorkflowRuns } from './utils';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
@@ -58,7 +55,7 @@ export type WorkflowCommandSelectionState = {
|
||||
jobId?: string;
|
||||
step?: string;
|
||||
job?: WorkflowJobResult;
|
||||
logs?: WorkflowLogs | null;
|
||||
logs?: JobLogs | null;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
@@ -140,7 +137,7 @@ export function moveToWorkflowSelectionFinishedState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params: {
|
||||
step: string;
|
||||
logs: WorkflowLogs;
|
||||
logs: JobLogs;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
@@ -276,7 +273,7 @@ export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async
|
||||
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
|
||||
}
|
||||
const choices: Choice[] = [
|
||||
...choicesFromWorkflowLogs(logs),
|
||||
...choicesFromJobLogs(logs),
|
||||
{
|
||||
title: 'Go back and select a different workflow job',
|
||||
value: 'go-back',
|
||||
|
||||
@@ -41,27 +41,3 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
|
||||
jobs: WorkflowJobResult[];
|
||||
logs?: string;
|
||||
};
|
||||
|
||||
export type WorkflowRawLogLine = {
|
||||
logId?: string;
|
||||
buildStepId?: string;
|
||||
buildStepDisplayName?: string;
|
||||
phase?: string;
|
||||
time?: string;
|
||||
msg?: string;
|
||||
result?: string;
|
||||
marker?: string;
|
||||
err?: any;
|
||||
};
|
||||
|
||||
export type WorkflowLogLine = Pick<WorkflowRawLogLine, 'time' | 'result' | 'marker' | 'err'> &
|
||||
Required<Pick<WorkflowRawLogLine, 'msg'>>;
|
||||
|
||||
export type StepLogs = {
|
||||
key: string;
|
||||
label: string;
|
||||
result?: string;
|
||||
logLines: WorkflowLogLine[];
|
||||
};
|
||||
|
||||
export type WorkflowLogs = Map<string, StepLogs>;
|
||||
@@ -1,14 +1,8 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
|
||||
import {
|
||||
WorkflowJobResult,
|
||||
WorkflowLogLine,
|
||||
WorkflowLogs,
|
||||
WorkflowRunResult,
|
||||
WorkflowTriggerType,
|
||||
} from './types';
|
||||
import { fetchAndProcessLogsFromJobAsync, logSourceForWorkflowJob } from './logs';
|
||||
import { WorkflowJobResult, WorkflowRunResult, WorkflowTriggerType } from './types';
|
||||
import {
|
||||
WorkflowJobStatus,
|
||||
WorkflowRunByIdQuery,
|
||||
@@ -17,7 +11,9 @@ import {
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRunLogsWatcher } from './logs/watcher';
|
||||
import { choicesFromJobLogs, stepLogTail } from '../logs/format';
|
||||
import { JobLogs } from '../logs/types';
|
||||
import { LogsWatcher } from '../logs/watcher';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { ora, updateSpinnerText } from '../../ora';
|
||||
@@ -83,20 +79,6 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
|
||||
};
|
||||
}
|
||||
|
||||
export function choicesFromWorkflowLogs(
|
||||
logs: WorkflowLogs
|
||||
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
|
||||
return Array.from(logs.values())
|
||||
.map(({ key, label, result, logLines }) => ({
|
||||
title: `${label} - ${result ?? ''}`,
|
||||
name: label,
|
||||
status: result ?? '',
|
||||
value: key,
|
||||
logLines,
|
||||
}))
|
||||
.filter(step => step.status !== 'skipped');
|
||||
}
|
||||
|
||||
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
|
||||
return runs.map(run => {
|
||||
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
|
||||
@@ -157,18 +139,9 @@ type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
|
||||
|
||||
type JobWithLogs = {
|
||||
job: WorkflowRunWithJobs['jobs'][number];
|
||||
logs: WorkflowLogs;
|
||||
logs: JobLogs;
|
||||
};
|
||||
|
||||
function stepLogTail(
|
||||
step: { logLines?: WorkflowLogLine[] },
|
||||
maxLogLines: number // -1 means no limit
|
||||
): string[] {
|
||||
const logLines = step.logLines ?? [];
|
||||
const tail = maxLogLines === -1 ? logLines : logLines.slice(-maxLogLines);
|
||||
return tail.map(line => line.msg);
|
||||
}
|
||||
|
||||
export async function logsForFailedWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunWithJobs
|
||||
@@ -195,7 +168,7 @@ export function formatActiveWorkflowRun(
|
||||
if (job.status !== WorkflowJobStatus.InProgress) {
|
||||
continue;
|
||||
}
|
||||
const steps = choicesFromWorkflowLogs(logs);
|
||||
const steps = choicesFromJobLogs(logs);
|
||||
if (steps.length === 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -222,7 +195,7 @@ export function formatFailedWorkflowRun(
|
||||
for (const { job, logs } of jobLogs) {
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Failed job', value: job.name });
|
||||
const steps = choicesFromWorkflowLogs(logs);
|
||||
const steps = choicesFromJobLogs(logs);
|
||||
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
|
||||
if (!failedStep) {
|
||||
continue;
|
||||
@@ -290,8 +263,7 @@ export async function showWorkflowStatusAsync(
|
||||
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
|
||||
});
|
||||
|
||||
const watcher = new WorkflowRunLogsWatcher(
|
||||
graphqlClient,
|
||||
const watcher = new LogsWatcher(
|
||||
() => createRealtimeLogsClient(graphqlClient),
|
||||
() => {
|
||||
renderActiveWorkflowRun();
|
||||
@@ -316,12 +288,14 @@ export async function showWorkflowStatusAsync(
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.green Workflow run is in progress:}`,
|
||||
});
|
||||
const logsStates = await watcher.syncJobsAsync(workflowRun.jobs);
|
||||
const logsStates = await watcher.syncAsync(
|
||||
workflowRun.jobs.map(job => logSourceForWorkflowJob(graphqlClient, job))
|
||||
);
|
||||
for (const job of workflowRun.jobs) {
|
||||
if (isJobCompleted(job.status)) {
|
||||
nullthrows(
|
||||
logsStates.get(job.id),
|
||||
'syncJobsAsync must have been called before markCompleted'
|
||||
'syncAsync must have been called before markCompleted'
|
||||
).markCompleted();
|
||||
}
|
||||
}
|
||||
@@ -331,7 +305,7 @@ export async function showWorkflowStatusAsync(
|
||||
job,
|
||||
logs: nullthrows(
|
||||
logsStates.get(job.id),
|
||||
'syncJobsAsync must have been called before getLogs'
|
||||
'syncAsync must have been called before getLogs'
|
||||
).getLogs(),
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExist
|
||||
import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup';
|
||||
import { Target } from '../credentials/ios/types';
|
||||
import {
|
||||
AccountFragment,
|
||||
WorkflowJobStatus,
|
||||
WorkflowProjectSourceType,
|
||||
WorkflowRunStatus,
|
||||
@@ -34,7 +35,6 @@ import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersion
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { ensureActorHasPrimaryAccount } from '../user/actions';
|
||||
import { Actor, getActorDisplayName } from '../user/User';
|
||||
import { sleepAsync } from '../utils/promise';
|
||||
import { Client } from '../vcs/vcs';
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
|
||||
isBundleIdentifierValid,
|
||||
} from '../project/ios/bundleIdentifier';
|
||||
import { UserQuery } from '../graphql/queries/UserQuery';
|
||||
|
||||
function deriveBundleIdSlug(bundleId: string): string {
|
||||
return bundleId.split('.').filter(Boolean).pop()!;
|
||||
@@ -142,8 +143,11 @@ export default class Go extends EasCommand {
|
||||
} = await this.getContextAsync(Go, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
|
||||
|
||||
const userPrimaryAccount = await UserQuery.requireCurrentUserPrimaryAccountAsync(graphqlClient);
|
||||
|
||||
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
|
||||
if (detectedSdkVersion && !flags['sdk-version']) {
|
||||
Log.log(
|
||||
@@ -154,7 +158,7 @@ export default class Go extends EasCommand {
|
||||
if (!sdkVersion) {
|
||||
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
|
||||
}
|
||||
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
|
||||
const bundleId = flags['bundle-id'] ?? this.generateBundleId(userPrimaryAccount);
|
||||
if (!isBundleIdentifierValid(bundleId)) {
|
||||
throw new Error(
|
||||
`"${bundleId}" is not a valid iOS bundle identifier. ${INVALID_BUNDLE_IDENTIFIER_MESSAGE} Pass a valid identifier with --bundle-id.`
|
||||
@@ -167,7 +171,7 @@ export default class Go extends EasCommand {
|
||||
let projectId: string;
|
||||
try {
|
||||
projectId = await withSuppressedOutputAsync(() =>
|
||||
this.ensureEasProjectAsync(graphqlClient, actor, slug)
|
||||
this.ensureEasProjectAsync(graphqlClient, userPrimaryAccount, slug)
|
||||
);
|
||||
} catch (error) {
|
||||
setupSpinner.fail();
|
||||
@@ -202,7 +206,7 @@ export default class Go extends EasCommand {
|
||||
} = await this.dispatchWorkflowAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
actor,
|
||||
userPrimaryAccount,
|
||||
bundleId,
|
||||
appName,
|
||||
ascApp.id,
|
||||
@@ -275,8 +279,8 @@ export default class Go extends EasCommand {
|
||||
};
|
||||
}
|
||||
|
||||
private generateBundleId(actor: Actor): string {
|
||||
const username = ensureActorHasPrimaryAccount(actor).name;
|
||||
private generateBundleId(userPrimaryAccount: AccountFragment): string {
|
||||
const username = userPrimaryAccount.name;
|
||||
const sanitizedUsername = username
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
@@ -287,14 +291,12 @@ export default class Go extends EasCommand {
|
||||
|
||||
private async ensureEasProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
actor: Actor,
|
||||
userPrimaryAccount: AccountFragment,
|
||||
slug: string
|
||||
): Promise<string> {
|
||||
const account = ensureActorHasPrimaryAccount(actor);
|
||||
|
||||
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
account.name,
|
||||
userPrimaryAccount.name,
|
||||
slug
|
||||
);
|
||||
|
||||
@@ -303,7 +305,7 @@ export default class Go extends EasCommand {
|
||||
}
|
||||
|
||||
return await AppMutation.createAppAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
accountId: userPrimaryAccount.id,
|
||||
projectName: slug,
|
||||
});
|
||||
}
|
||||
@@ -394,7 +396,7 @@ export default class Go extends EasCommand {
|
||||
private async dispatchWorkflowAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
actor: Actor,
|
||||
userPrimaryAccount: AccountFragment,
|
||||
bundleId: string,
|
||||
appName: string,
|
||||
ascAppId: string,
|
||||
@@ -402,8 +404,6 @@ export default class Go extends EasCommand {
|
||||
tmpDir: string,
|
||||
vcsClient: Client
|
||||
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
|
||||
const account = ensureActorHasPrimaryAccount(actor);
|
||||
|
||||
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
ascAppId,
|
||||
@@ -421,17 +421,17 @@ export default class Go extends EasCommand {
|
||||
const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
|
||||
graphqlClient,
|
||||
vcsClient,
|
||||
accountId: account.id,
|
||||
accountId: userPrimaryAccount.id,
|
||||
});
|
||||
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
accountId: userPrimaryAccount.id,
|
||||
filePath: path.join(tmpDir, 'eas.json'),
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
});
|
||||
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
accountId: userPrimaryAccount.id,
|
||||
filePath: path.join(tmpDir, 'package.json'),
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
});
|
||||
@@ -449,7 +449,11 @@ export default class Go extends EasCommand {
|
||||
},
|
||||
});
|
||||
|
||||
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
|
||||
const workflowUrl = getWorkflowRunUrl(
|
||||
userPrimaryAccount.name,
|
||||
deriveBundleIdSlug(bundleId),
|
||||
result.id
|
||||
);
|
||||
|
||||
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
Role,
|
||||
} from '../../../../graphql/generated';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
@@ -56,12 +55,6 @@ describe(IntegrationsConvexConnect, () => {
|
||||
email: '[email protected]',
|
||||
featureGates: {},
|
||||
isExpoAdmin: false,
|
||||
primaryAccount: {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -62,12 +62,6 @@ describe(IntegrationsPostHogConnect, () => {
|
||||
email: '[email protected]',
|
||||
featureGates: {},
|
||||
isExpoAdmin: false,
|
||||
primaryAccount: {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ jest.mock('../../../project/expoConfig', () => ({
|
||||
createOrModifyExpoConfigAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../user/actions');
|
||||
jest.mock('../../../graphql/mutations/AppMutation');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../ora', () => ({
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
WorkflowCommandSelectionStateValue,
|
||||
executeWorkflowSelectionActionsAsync,
|
||||
} from '../../commandUtils/workflow/stateMachine';
|
||||
import { WorkflowLogLine, WorkflowLogs } from '../../commandUtils/workflow/types';
|
||||
import { JobLogs, LogLine } from '../../commandUtils/logs/types';
|
||||
import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
function printLogsForAllSteps(logs: WorkflowLogs): void {
|
||||
function printLogsForAllSteps(logs: JobLogs): void {
|
||||
[...logs.values()].forEach(({ label, logLines }) => {
|
||||
if (logLines.length === 0) {
|
||||
return;
|
||||
@@ -85,7 +85,7 @@ export default class WorkflowLogView extends EasCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = finalSelectionState?.logs as unknown as WorkflowLogs | null;
|
||||
const logs = finalSelectionState?.logs;
|
||||
if (allSteps) {
|
||||
if (logs) {
|
||||
if (flags.json) {
|
||||
@@ -103,7 +103,7 @@ export default class WorkflowLogView extends EasCommand {
|
||||
const logGroup = logs?.get(selectedStep);
|
||||
if (logGroup) {
|
||||
if (flags.json) {
|
||||
const output: { [key: string]: WorkflowLogLine[] | null } = {};
|
||||
const output: { [key: string]: LogLine[] | null } = {};
|
||||
output[selectedStep] = logGroup.logLines;
|
||||
printJsonOnlyOutput(output);
|
||||
} else {
|
||||
|
||||
@@ -8,11 +8,6 @@ export const jester: Actor = {
|
||||
id: 'jester-id',
|
||||
email: '[email protected]',
|
||||
username: 'jester',
|
||||
primaryAccount: {
|
||||
id: 'jester-account-id',
|
||||
name: 'jester',
|
||||
viewerUserPermission: { role: Role.Admin },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'jester-account-id',
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
jester as mockJester,
|
||||
testAppQueryByIdResponse,
|
||||
} from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { testAppQueryByIdResponse } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { AppQuery } from '../../../../graphql/queries/AppQuery';
|
||||
import { testKeystore } from '../../../__tests__/fixtures-android';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
@@ -11,7 +8,6 @@ import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
|
||||
import { CreateKeystore } from '../CreateKeystore';
|
||||
|
||||
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
|
||||
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
|
||||
jest.mock('../../../utils/promptForCredentials');
|
||||
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
|
||||
jest.mock('../../../../graphql/queries/AppQuery');
|
||||
|
||||
+1
-5
@@ -4,17 +4,13 @@ import {
|
||||
testAndroidBuildCredentialsFragment,
|
||||
testJksAndroidKeystoreFragment,
|
||||
} from '../../../__tests__/fixtures-android';
|
||||
import {
|
||||
jester as mockJester,
|
||||
testAppQueryByIdResponse,
|
||||
} from '../../../__tests__/fixtures-constants';
|
||||
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
import { generateRandomKeystoreAsync } from '../../utils/keystore';
|
||||
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
|
||||
import { SetUpBuildCredentials } from '../SetUpBuildCredentials';
|
||||
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
|
||||
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
|
||||
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
|
||||
jest.mock('../../../../graphql/queries/AppQuery');
|
||||
|
||||
@@ -25,7 +25,6 @@ import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
|
||||
import { resolveTargetsAsync } from '../../project/ios/target';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../prompts';
|
||||
import { ensureActorHasPrimaryAccount } from '../../user/actions';
|
||||
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
@@ -53,6 +52,7 @@ import { UpdateCredentialsJson } from '../ios/actions/UpdateCredentialsJson';
|
||||
import { AppLookupParams } from '../ios/api/graphql/types/AppLookupParams';
|
||||
import { App, IosAppCredentialsMap, Target } from '../ios/types';
|
||||
import { displayIosCredentials } from '../ios/utils/printCredentials';
|
||||
import { UserQuery } from '../../graphql/queries/UserQuery';
|
||||
|
||||
export class ManageIos {
|
||||
constructor(
|
||||
@@ -95,7 +95,7 @@ export class ManageIos {
|
||||
|
||||
const account = ctx.hasProjectContext
|
||||
? await getAccountForProjectAsync(await ctx.getProjectIdAsync())
|
||||
: ensureActorHasPrimaryAccount(ctx.user);
|
||||
: await UserQuery.requireCurrentUserPrimaryAccountAsync(ctx.graphqlClient);
|
||||
|
||||
let app = null;
|
||||
let targets = null;
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Action } from './HelperActions';
|
||||
import { ManageIos } from './ManageIos';
|
||||
import { AccountFragment } from '../../graphql/generated';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { ensureActorHasPrimaryAccount } from '../../user/actions';
|
||||
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
|
||||
import { UserQuery } from '../../graphql/queries/UserQuery';
|
||||
|
||||
export class SetUpIosBuildCredentials extends ManageIos {
|
||||
constructor(
|
||||
@@ -56,7 +56,7 @@ export class SetUpIosBuildCredentials extends ManageIos {
|
||||
|
||||
const account = ctx.hasProjectContext
|
||||
? await getAccountForProjectAsync(await ctx.getProjectIdAsync())
|
||||
: ensureActorHasPrimaryAccount(ctx.user);
|
||||
: await UserQuery.requireCurrentUserPrimaryAccountAsync(ctx.graphqlClient);
|
||||
|
||||
let app = null;
|
||||
let targets = null;
|
||||
|
||||
@@ -26,11 +26,6 @@ describe(AccountResolver, () => {
|
||||
id: 'user_id_666',
|
||||
email: '[email protected]',
|
||||
username: 'dominik',
|
||||
primaryAccount: {
|
||||
id: 'account_id_777',
|
||||
name: 'dominik',
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_777',
|
||||
|
||||
+129
-15
@@ -3639,7 +3639,8 @@ export type AppObserveOverviewEngagementInput = {
|
||||
|
||||
export type AppObserveOverviewEngagementStat = {
|
||||
__typename?: 'AppObserveOverviewEngagementStat';
|
||||
previousPeriodTotal: Scalars['Int']['output'];
|
||||
/** Total for the equal-length window before startTime. Null when the window is longer than 45 days, since the previous period would fall outside the 90-day retention. */
|
||||
previousPeriodTotal?: Maybe<Scalars['Int']['output']>;
|
||||
/** Per-bucket approximate uniques; buckets do not sum to `total`. */
|
||||
series: Array<AppObserveOverviewEngagementBucket>;
|
||||
total: Scalars['Int']['output'];
|
||||
@@ -3655,12 +3656,13 @@ export type AppObserveOverviewStability = {
|
||||
crashFreeUsers: Scalars['Float']['output'];
|
||||
/** Distinct users that hit a fatal error in range. */
|
||||
crashedUsers: Scalars['Int']['output'];
|
||||
/** Fatal exception events in the equal-length window before startTime. */
|
||||
previousPeriodCrashCount: Scalars['Int']['output'];
|
||||
/** Same fractions for the equal-length window before startTime; null when it had no activity. */
|
||||
/** Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days. */
|
||||
previousPeriodCrashCount?: Maybe<Scalars['Int']['output']>;
|
||||
/** Same fractions for the equal-length window before startTime; null when it had no activity, or when the window is longer than 45 days and the previous period falls outside the 90-day retention. */
|
||||
previousPeriodCrashFreeSessions?: Maybe<Scalars['Float']['output']>;
|
||||
previousPeriodCrashFreeUsers?: Maybe<Scalars['Float']['output']>;
|
||||
previousPeriodCrashedUsers: Scalars['Int']['output'];
|
||||
/** Null when the window is longer than 45 days. */
|
||||
previousPeriodCrashedUsers?: Maybe<Scalars['Int']['output']>;
|
||||
series: Array<AppObserveOverviewStabilityBucket>;
|
||||
/** All exception events in range, for distinguishing quiet apps from crash-free ones. */
|
||||
totalErrors: Scalars['Int']['output'];
|
||||
@@ -4126,6 +4128,8 @@ export enum AppObserveUserEventListOrderByField {
|
||||
export type AppObserveUserEventName = {
|
||||
__typename?: 'AppObserveUserEventName';
|
||||
count: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
lastSeenAt: Scalars['DateTime']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
@@ -4156,6 +4160,8 @@ export type AppObserveUserEventNamesOrderBy = {
|
||||
|
||||
export enum AppObserveUserEventNamesOrderByField {
|
||||
Count = 'COUNT',
|
||||
FirstSeen = 'FIRST_SEEN',
|
||||
LastSeen = 'LAST_SEEN',
|
||||
Name = 'NAME'
|
||||
}
|
||||
|
||||
@@ -10793,6 +10799,7 @@ export type RootQuery = {
|
||||
posthogIntegration: PostHogIntegrationQuery;
|
||||
/** Top-level query object for querying Runtimes. */
|
||||
runtimes: RuntimeQuery;
|
||||
sandboxes: SandboxQuery;
|
||||
snack: SnackQuery;
|
||||
/** Top-level query object for querying Expo status page services. */
|
||||
statuspageService: StatuspageServiceQuery;
|
||||
@@ -11037,6 +11044,36 @@ export type SsoUserDataInput = {
|
||||
lastName?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type Sandbox = {
|
||||
__typename?: 'Sandbox';
|
||||
app: App;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
finishedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
lastUsedAt: Scalars['DateTime']['output'];
|
||||
startedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
status: SandboxStatus;
|
||||
turtleJobRun: JobRun;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type SandboxQuery = {
|
||||
__typename?: 'SandboxQuery';
|
||||
byId: Sandbox;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxQuery_ByIdArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum SandboxStatus {
|
||||
Errored = 'ERRORED',
|
||||
Running = 'RUNNING',
|
||||
Starting = 'STARTING',
|
||||
Stopped = 'STOPPED'
|
||||
}
|
||||
|
||||
export type SecondFactorBooleanResult = {
|
||||
__typename?: 'SecondFactorBooleanResult';
|
||||
success: Scalars['Boolean']['output'];
|
||||
@@ -11546,6 +11583,35 @@ export type SubscriptionDetails_PlanEnablementArgs = {
|
||||
serviceMetric: EasServiceMetric;
|
||||
};
|
||||
|
||||
/** An unresolved finding from Supabase's Security or Performance Advisor (database linter). */
|
||||
export type SupabaseAdvisorLint = {
|
||||
__typename?: 'SupabaseAdvisorLint';
|
||||
/** Stable identifier for this finding on this project. */
|
||||
cacheKey: Scalars['String']['output'];
|
||||
description: Scalars['String']['output'];
|
||||
/** Project-specific explanation naming the affected schema object. */
|
||||
detail: Scalars['String']['output'];
|
||||
/** Affected schema object, e.g. public.todos, when the lint names one. */
|
||||
entity?: Maybe<Scalars['String']['output']>;
|
||||
level: SupabaseAdvisorLintLevel;
|
||||
/** Lint rule identifier, e.g. rls_disabled_in_public. */
|
||||
name: Scalars['String']['output'];
|
||||
/** Link to the remediation guide, when Supabase provides one. */
|
||||
remediation?: Maybe<Scalars['String']['output']>;
|
||||
title: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export enum SupabaseAdvisorLintLevel {
|
||||
Error = 'ERROR',
|
||||
Info = 'INFO',
|
||||
Warn = 'WARN'
|
||||
}
|
||||
|
||||
export enum SupabaseAdvisorType {
|
||||
Performance = 'PERFORMANCE',
|
||||
Security = 'SECURITY'
|
||||
}
|
||||
|
||||
export type SupabaseConnection = {
|
||||
__typename?: 'SupabaseConnection';
|
||||
account: Account;
|
||||
@@ -11622,6 +11688,12 @@ export type SupabaseOrganization = {
|
||||
|
||||
export type SupabaseProject = {
|
||||
__typename?: 'SupabaseProject';
|
||||
/**
|
||||
* Live unresolved lints from the project's Security or Performance Advisor, ordered by severity.
|
||||
* Readable with view permission; a token refresh triggered by a read is persisted with elevated
|
||||
* privileges. Null when Supabase cannot be reached.
|
||||
*/
|
||||
advisorLints?: Maybe<Array<SupabaseAdvisorLint>>;
|
||||
app: App;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -11633,6 +11705,11 @@ export type SupabaseProject = {
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type SupabaseProject_AdvisorLintsArgs = {
|
||||
type: SupabaseAdvisorType;
|
||||
};
|
||||
|
||||
export type SupabaseProjectMutation = {
|
||||
__typename?: 'SupabaseProjectMutation';
|
||||
/** Removes the Expo-side project link only; the Supabase project is preserved. */
|
||||
@@ -16714,30 +16791,40 @@ export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type CurrentUserQuery = { __typename?: 'RootQuery', meActor?:
|
||||
| { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename: 'PartnerActor', id: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
|
||||
| { __typename: 'Robot', firstName?: string | null, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename: 'Robot', id: string, firstName?: string | null, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
|
||||
| { __typename: 'SSOUser', username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename: 'SSOUser', id: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
|
||||
| { __typename: 'User', email: string, username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename: 'User', id: string, email: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
|
||||
| null };
|
||||
|
||||
export type CurrentUserWithPrimaryAccountQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type CurrentUserWithPrimaryAccountQuery = { __typename?: 'RootQuery', meActor?:
|
||||
| { __typename: 'PartnerActor', id: string }
|
||||
| { __typename: 'Robot', id: string }
|
||||
| { __typename: 'SSOUser', id: string, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } } }
|
||||
| { __typename: 'User', id: string, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } } }
|
||||
| null };
|
||||
|
||||
export type WebhooksByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
webhookFilter?: InputMaybe<WebhookFilter>;
|
||||
@@ -16895,6 +16982,33 @@ export type UsageMetricTotalFragment = { __typename?: 'UsageMetricTotal', id: st
|
||||
|
||||
export type AccountUsageMetricFragment = { __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number };
|
||||
|
||||
type MeActorFragment_PartnerActor = { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
|
||||
|
||||
type MeActorFragment_Robot = { __typename: 'Robot', firstName?: string | null, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
|
||||
|
||||
type MeActorFragment_SsoUser = { __typename: 'SSOUser', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
|
||||
|
||||
type MeActorFragment_User = { __typename: 'User', email: string, username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
|
||||
|
||||
export type MeActorFragment =
|
||||
| MeActorFragment_PartnerActor
|
||||
| MeActorFragment_Robot
|
||||
| MeActorFragment_SsoUser
|
||||
| MeActorFragment_User
|
||||
;
|
||||
|
||||
export type AppFragment = { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
|
||||
| { __typename?: 'SSOUser', id: string, username: string }
|
||||
| { __typename?: 'User', id: string, username: string }
|
||||
|
||||
@@ -3,8 +3,13 @@ import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { CurrentUserQuery } from '../generated';
|
||||
import {
|
||||
AccountFragment,
|
||||
CurrentUserQuery,
|
||||
CurrentUserWithPrimaryAccountQuery,
|
||||
} from '../generated';
|
||||
import { AccountFragmentNode } from '../types/Account';
|
||||
import { MeActorFragmentNode } from '../types/Actor';
|
||||
|
||||
export const UserQuery = {
|
||||
async currentUserAsync(graphqlClient: ExpoGraphqlClient): Promise<CurrentUserQuery['meActor']> {
|
||||
@@ -16,31 +21,10 @@ export const UserQuery = {
|
||||
meActor {
|
||||
__typename
|
||||
id
|
||||
... on User {
|
||||
email
|
||||
}
|
||||
... on UserActor {
|
||||
username
|
||||
primaryAccount {
|
||||
id
|
||||
...AccountFragment
|
||||
}
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
accounts {
|
||||
id
|
||||
...AccountFragment
|
||||
}
|
||||
... on PartnerActor {
|
||||
username
|
||||
}
|
||||
featureGates
|
||||
isExpoAdmin
|
||||
...MeActorFragment
|
||||
}
|
||||
}
|
||||
${print(AccountFragmentNode)}
|
||||
${print(MeActorFragmentNode)}
|
||||
`,
|
||||
{},
|
||||
{
|
||||
@@ -52,4 +36,52 @@ export const UserQuery = {
|
||||
|
||||
return data.meActor;
|
||||
},
|
||||
async requireCurrentUserPrimaryAccountAsync(
|
||||
graphqlClient: ExpoGraphqlClient
|
||||
): Promise<AccountFragment> {
|
||||
let data: CurrentUserWithPrimaryAccountQuery;
|
||||
try {
|
||||
data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<CurrentUserWithPrimaryAccountQuery>(
|
||||
gql`
|
||||
query CurrentUserWithPrimaryAccount {
|
||||
meActor {
|
||||
__typename
|
||||
id
|
||||
... on UserActor {
|
||||
primaryAccount {
|
||||
id
|
||||
...AccountFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${print(AccountFragmentNode)}
|
||||
`,
|
||||
{},
|
||||
{
|
||||
additionalTypenames: ['User', 'SSOUser'],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'An error occurred while fetching the primary account of the current user. Check to ensure your session has sufficient scope on your primary account.'
|
||||
);
|
||||
}
|
||||
|
||||
const actor = data.meActor;
|
||||
if (!actor) {
|
||||
throw new Error('Must be logged in to perform this action.');
|
||||
}
|
||||
|
||||
if (actor.__typename === 'User' || actor.__typename === 'SSOUser') {
|
||||
return actor.primaryAccount;
|
||||
}
|
||||
|
||||
throw new Error(`This action is not supported for the ${actor.__typename} user type.`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable graphql/required-fields */
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
import { AccountFragmentNode } from './Account';
|
||||
|
||||
export const MeActorFragmentNode = gql`
|
||||
${print(AccountFragmentNode)}
|
||||
|
||||
fragment MeActorFragment on Actor {
|
||||
__typename
|
||||
id
|
||||
... on User {
|
||||
email
|
||||
}
|
||||
... on UserActor {
|
||||
username
|
||||
}
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
accounts {
|
||||
id
|
||||
...AccountFragment
|
||||
}
|
||||
... on PartnerActor {
|
||||
username
|
||||
}
|
||||
featureGates
|
||||
isExpoAdmin
|
||||
}
|
||||
`;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppObserveUserEvent, PageInfo } from '../../graphql/generated';
|
||||
import { AppObserveUserEvent, AppObserveUserEventName, PageInfo } from '../../graphql/generated';
|
||||
import {
|
||||
buildObserveCustomEventNamesTable,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson,
|
||||
@@ -7,6 +7,16 @@ import {
|
||||
buildObserveCustomEventsTable,
|
||||
} from '../formatCustomEvents';
|
||||
|
||||
function makeEventName(name: string, count: number): AppObserveUserEventName {
|
||||
return {
|
||||
__typename: 'AppObserveUserEventName' as const,
|
||||
name,
|
||||
count,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makeCustomEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
|
||||
return {
|
||||
__typename: 'AppObserveUserEvent' as const,
|
||||
@@ -299,8 +309,8 @@ describe(buildObserveCustomEventNamesTable, () => {
|
||||
|
||||
it('shows event names with counts', () => {
|
||||
const output = buildObserveCustomEventNamesTable([
|
||||
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
|
||||
makeEventName('foo', 10),
|
||||
makeEventName('bar', 5),
|
||||
]);
|
||||
expect(output).toContain('foo');
|
||||
expect(output).toContain('10');
|
||||
@@ -309,10 +319,9 @@ describe(buildObserveCustomEventNamesTable, () => {
|
||||
});
|
||||
|
||||
it('appends a truncation notice when isTruncated is true', () => {
|
||||
const output = buildObserveCustomEventNamesTable(
|
||||
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
|
||||
{ isTruncated: true }
|
||||
);
|
||||
const output = buildObserveCustomEventNamesTable([makeEventName('foo', 10)], {
|
||||
isTruncated: true,
|
||||
});
|
||||
expect(output).toContain('Result is truncated');
|
||||
});
|
||||
});
|
||||
@@ -320,8 +329,8 @@ describe(buildObserveCustomEventNamesTable, () => {
|
||||
describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
|
||||
it('shows the filtered event name and the available event names', () => {
|
||||
const output = buildObserveCustomEventsEmptyWithSuggestionsTable('login', [
|
||||
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
|
||||
makeEventName('foo', 10),
|
||||
makeEventName('bar', 5),
|
||||
]);
|
||||
|
||||
expect(output).toContain('No events found matching "login"');
|
||||
@@ -342,7 +351,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
|
||||
it('appends a truncation notice when isTruncated is set', () => {
|
||||
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
|
||||
'login',
|
||||
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
|
||||
[makeEventName('foo', 10)],
|
||||
{ isTruncated: true }
|
||||
);
|
||||
|
||||
@@ -352,7 +361,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
|
||||
it('includes the time range description in the message when provided', () => {
|
||||
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
|
||||
'login',
|
||||
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
|
||||
[makeEventName('foo', 10)],
|
||||
{ daysBack: 7 }
|
||||
);
|
||||
|
||||
@@ -364,10 +373,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsJson, () => {
|
||||
it('returns the filtered event name, empty events array, and available names', () => {
|
||||
const result = buildObserveCustomEventsEmptyWithSuggestionsJson(
|
||||
'login',
|
||||
[
|
||||
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
|
||||
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
|
||||
],
|
||||
[makeEventName('foo', 10), makeEventName('bar', 5)],
|
||||
false
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ const errorReal = console.error;
|
||||
|
||||
const isCi = boolish('CI', false);
|
||||
|
||||
export function isSpinnerEnabled(): boolean {
|
||||
return !(Log.isDebug || !process.stdin.isTTY || isCi);
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom ora spinner that sends the stream to stdout in CI, or non-TTY, instead of stderr (the default).
|
||||
*
|
||||
@@ -25,7 +29,7 @@ const isCi = boolish('CI', false);
|
||||
*/
|
||||
export function ora(options?: Options | string): Ora {
|
||||
const inputOptions = typeof options === 'string' ? { text: options } : (options ?? {});
|
||||
const disabled = Log.isDebug || !process.stdin.isTTY || isCi;
|
||||
const disabled = !isSpinnerEnabled();
|
||||
const spinner = oraReal({
|
||||
// Ensure our non-interactive mode emulates CI mode.
|
||||
isEnabled: !disabled,
|
||||
|
||||
@@ -10,11 +10,6 @@ const userActor: Actor = {
|
||||
id: 'user-id',
|
||||
email: '[email protected]',
|
||||
username: 'jane',
|
||||
primaryAccount: {
|
||||
id: 'account-id-personal',
|
||||
name: 'jane',
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account-id-org',
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from 'os';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { jester, jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { resolveVcsClient } from '../../../vcs';
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
jest.mock('fs');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
|
||||
|
||||
const vcsClient = resolveVcsClient();
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@ import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { resolveVcsClient } from '../../../vcs';
|
||||
import { resolveGradleBuildContextAsync } from '../gradle';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
|
||||
|
||||
const vcsClient = resolveVcsClient();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'path';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { jester, jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { resolveVcsClient } from '../../../vcs';
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
jest.mock('fs');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
|
||||
|
||||
const vcsClient = resolveVcsClient();
|
||||
|
||||
|
||||
@@ -35,9 +35,6 @@ jest.mock('../../../graphql/mutations/SubmissionMutation', () => ({
|
||||
jest.mock('../../../user/User', () => ({
|
||||
getUserAsync: jest.fn(() => mockJester),
|
||||
}));
|
||||
jest.mock('../../../user/actions', () => ({
|
||||
ensureLoggedInAsync: jest.fn(() => mockJester),
|
||||
}));
|
||||
jest.mock('../../../project/projectUtils');
|
||||
jest.mock('../../ArchiveSource', () => {
|
||||
return {
|
||||
|
||||
@@ -6,11 +6,6 @@ const userStub: Actor = {
|
||||
id: 'userId',
|
||||
email: '[email protected]',
|
||||
username: 'username',
|
||||
primaryAccount: {
|
||||
id: 'account_id_777',
|
||||
name: 'username',
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_777',
|
||||
@@ -26,11 +21,6 @@ const ssoUserStub: Actor = {
|
||||
__typename: 'SSOUser',
|
||||
id: 'ssoUserId',
|
||||
username: 'ssoUsername',
|
||||
primaryAccount: {
|
||||
id: 'account_id_888',
|
||||
name: 'ssoUsername',
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_888',
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Actor } from './User';
|
||||
import { AccountFragment } from '../graphql/generated';
|
||||
|
||||
export function ensureActorHasPrimaryAccount(user: Actor): AccountFragment {
|
||||
if (user.__typename === 'User' || user.__typename === 'SSOUser') {
|
||||
return user.primaryAccount;
|
||||
}
|
||||
throw new Error('This action is not supported for robot users.');
|
||||
}
|
||||
@@ -35,6 +35,8 @@ function createWebSocketConstructor(): WebSocketConstructor {
|
||||
return httpsProxyAgent ? createProxiedWebSocketConstructor(httpsProxyAgent) : WebSocket;
|
||||
}
|
||||
|
||||
const LOGS_WEBSOCKET_TIMEOUT_MS = 5_000;
|
||||
|
||||
export function createRealtimeLogsClient(
|
||||
graphqlClient: ExpoGraphqlClient
|
||||
): RealtimeLogsClient | null {
|
||||
@@ -49,6 +51,7 @@ export function createRealtimeLogsClient(
|
||||
client = new Centrifuge(getEASLogsWebsocketUrl(), {
|
||||
websocket: createWebSocketConstructor(),
|
||||
getToken: getTokenAsync,
|
||||
timeout: LOGS_WEBSOCKET_TIMEOUT_MS,
|
||||
});
|
||||
client.on('error', ({ error }) => {
|
||||
Log.debug(`Realtime logs connection error: ${error.message}`);
|
||||
|
||||
Reference in new issue
Block a user