From 1cf312075f2c7d41396fdd912f294603a7f2b34b Mon Sep 17 00:00:00 2001 From: Tomasz Mazur <47872060+AHGIJMKLKKZNPJKQR@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:05:46 +0200 Subject: [PATCH 1/4] [eas-cli] Add centrifuge and ws to deps --- packages/eas-cli/package.json | 3 + yarn.lock | 127 ++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 2566cb66d6..19ea5e0949 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -77,6 +77,7 @@ "ajv-formats": "2.1.1", "better-opn": "3.0.2", "bplist-parser": "^0.3.0", + "centrifuge": "5.7.1", "chalk": "4.1.2", "cli-progress": "3.12.0", "dateformat": "4.6.3", @@ -131,6 +132,7 @@ "untildify": "4.0.0", "uuid": "9.0.1", "wrap-ansi": "7.0.0", + "ws": "8.21.1", "yaml": "2.6.0", "zod": "^4.1.3" }, @@ -160,6 +162,7 @@ "@types/tough-cookie": "4.0.2", "@types/uuid": "9.0.7", "@types/wrap-ansi": "3.0.0", + "@types/ws": "8.5.10", "axios": "1.18.1", "eslint-plugin-graphql": "4.0.0", "jest": "29.7.0", diff --git a/yarn.lock b/yarn.lock index c607db98ba..871a45cccc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6021,6 +6021,71 @@ __metadata: languageName: node linkType: hard +"@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/aspromise@npm:1.1.2" + checksum: 10c0/a83343a468ff5b5ec6bff36fd788a64c839e48a07ff9f4f813564f58caf44d011cd6504ed2147bf34835bd7a7dd2107052af755961c6b098fd8902b4f6500d0f + languageName: node + linkType: hard + +"@protobufjs/base64@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/base64@npm:1.1.2" + checksum: 10c0/eec925e681081af190b8ee231f9bad3101e189abbc182ff279da6b531e7dbd2a56f1f306f37a80b1be9e00aa2d271690d08dcc5f326f71c9eed8546675c8caf6 + languageName: node + linkType: hard + +"@protobufjs/codegen@npm:^2.0.5": + version: 2.0.5 + resolution: "@protobufjs/codegen@npm:2.0.5" + checksum: 10c0/1b8a2ae56ee60a56e9d205cd4b6072a1503c5069b8ebb905710f974ff0098a0d0700641c137e0a8d98dedf14423156a106a9433695cbf52574810f55000fdcab + languageName: node + linkType: hard + +"@protobufjs/eventemitter@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/eventemitter@npm:1.1.1" + checksum: 10c0/8e06193d4629c5e7c09d4f8c2ddba8fc4dfa739f0149f33a1d901568d35bb7b8b5277a4e8452baf3bdd0b302fd599cf255d193267aa93a0a4747e23cd073c4ac + languageName: node + linkType: hard + +"@protobufjs/fetch@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/fetch@npm:1.1.1" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.1" + checksum: 10c0/a497ff5433854e8577f0427983ea39b9113b49a8120f94515291d763327061d2c3013e60e24ea436d091dafae01a0f6eb1867e3b1616045d96a31d8b3c646ed4 + languageName: node + linkType: hard + +"@protobufjs/float@npm:^1.0.2": + version: 1.0.2 + resolution: "@protobufjs/float@npm:1.0.2" + checksum: 10c0/18f2bdede76ffcf0170708af15c9c9db6259b771e6b84c51b06df34a9c339dbbeec267d14ce0bddd20acc142b1d980d983d31434398df7f98eb0c94a0eb79069 + languageName: node + linkType: hard + +"@protobufjs/path@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/path@npm:1.1.2" + checksum: 10c0/cece0a938e7f5dfd2fa03f8c14f2f1cf8b0d6e13ac7326ff4c96ea311effd5fb7ae0bba754fbf505312af2e38500250c90e68506b97c02360a43793d88a0d8b4 + languageName: node + linkType: hard + +"@protobufjs/pool@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/pool@npm:1.1.0" + checksum: 10c0/eda2718b7f222ac6e6ad36f758a92ef90d26526026a19f4f17f668f45e0306a5bd734def3f48f51f8134ae0978b6262a5c517c08b115a551756d1a3aadfcf038 + languageName: node + linkType: hard + +"@protobufjs/utf8@npm:^1.1.1": + version: 1.1.2 + resolution: "@protobufjs/utf8@npm:1.1.2" + checksum: 10c0/975c6e2c0319bd50c17531d186b537a88cb4d3205e3ca9297cd842b631d341da0ee3ff16be8656cd2660756dc753b29c8a6a14c0ad8ed602c1f91ddf22e7b3f3 + languageName: node + linkType: hard + "@react-native/normalize-colors@npm:0.79.6": version: 0.79.6 resolution: "@react-native/normalize-colors@npm:0.79.6" @@ -7529,6 +7594,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=13.7.0": + version: 26.2.0 + resolution: "@types/node@npm:26.2.0" + dependencies: + undici-types: "npm:~8.3.0" + checksum: 10c0/f8566d88162241eb55a46f7e4ea097188eab0aaafefea7a58e63adab8c4144eb98c08b7911452c846104c339a0f82282f5e1ed4b26b578d60709614fe2843f4d + languageName: node + linkType: hard + "@types/node@npm:^22.5.5": version: 22.19.15 resolution: "@types/node@npm:22.19.15" @@ -8995,6 +9069,16 @@ __metadata: languageName: node linkType: hard +"centrifuge@npm:5.7.1": + version: 5.7.1 + resolution: "centrifuge@npm:5.7.1" + dependencies: + events: "npm:^3.3.0" + protobufjs: "npm:^7.6.0" + checksum: 10c0/fb5547b7f30383a0086bd47d3e27f2d1442fc7c6c89c0e48e76621e5c3bd17cf362d53d4bfb1d2d3c4461e48c95b3cc90654da3b0958152d7afdbf6318996357 + languageName: node + linkType: hard + "chalk@npm:4.1.0, chalk@npm:^4.0.0, chalk@npm:^4.1.0": version: 4.1.0 resolution: "chalk@npm:4.1.0" @@ -10402,6 +10486,7 @@ __metadata: "@types/tough-cookie": "npm:4.0.2" "@types/uuid": "npm:9.0.7" "@types/wrap-ansi": "npm:3.0.0" + "@types/ws": "npm:8.5.10" "@urql/core": "npm:4.0.11" "@urql/exchange-retry": "npm:1.2.0" agent-cli-detector: "npm:0.1.2" @@ -10410,6 +10495,7 @@ __metadata: axios: "npm:1.18.1" better-opn: "npm:3.0.2" bplist-parser: "npm:^0.3.0" + centrifuge: "npm:5.7.1" chalk: "npm:4.1.2" cli-progress: "npm:3.12.0" dateformat: "npm:4.6.3" @@ -10473,6 +10559,7 @@ __metadata: untildify: "npm:4.0.0" uuid: "npm:9.0.1" wrap-ansi: "npm:7.0.0" + ws: "npm:8.21.1" yaml: "npm:2.6.0" zod: "npm:^4.1.3" bin: @@ -10845,6 +10932,13 @@ __metadata: languageName: node linkType: hard +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 + languageName: node + linkType: hard + "exec-async@npm:^2.2.0": version: 2.2.0 resolution: "exec-async@npm:2.2.0" @@ -14217,6 +14311,13 @@ __metadata: languageName: node linkType: hard +"long@npm:^5.3.2": + version: 5.3.2 + resolution: "long@npm:5.3.2" + checksum: 10c0/7130fe1cbce2dca06734b35b70d380ca3f70271c7f8852c922a7c62c86c4e35f0c39290565eca7133c625908d40e126ac57c02b1b1a4636b9457d77e1e60b981 + languageName: node + linkType: hard + "loose-envify@npm:^1.0.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -16745,6 +16846,25 @@ __metadata: languageName: node linkType: hard +"protobufjs@npm:^7.6.0": + version: 7.6.5 + resolution: "protobufjs@npm:7.6.5" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.2" + "@protobufjs/base64": "npm:^1.1.2" + "@protobufjs/codegen": "npm:^2.0.5" + "@protobufjs/eventemitter": "npm:^1.1.1" + "@protobufjs/fetch": "npm:^1.1.1" + "@protobufjs/float": "npm:^1.0.2" + "@protobufjs/path": "npm:^1.1.2" + "@protobufjs/pool": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.1" + "@types/node": "npm:>=13.7.0" + long: "npm:^5.3.2" + checksum: 10c0/863eaca9c6f45bfcfb8787c545f53e9c6696507e328e3981b4643c12d35df7f2826021c10e3fd30bef342c13a8e9d306547bac9c0849ee3bf50f770a12f01dc5 + languageName: node + linkType: hard + "protocols@npm:^2.0.0, protocols@npm:^2.0.1": version: 2.0.1 resolution: "protocols@npm:2.0.1" @@ -19092,6 +19212,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~8.3.0": + version: 8.3.0 + resolution: "undici-types@npm:8.3.0" + checksum: 10c0/c8aa7e2fbebfce519654dafadc0ece59be888d2ccaf180fb4495da875e7b536d2456345c384069c7e6f3e9c9ab7435f074957da306f142343eee86ff8048855a + languageName: node + linkType: hard + "unique-filename@npm:^4.0.0": version: 4.0.0 resolution: "unique-filename@npm:4.0.0" From daf5f89e1d0788b7b48704f4bfedb3a9e8b0911a Mon Sep 17 00:00:00 2001 From: Tomasz Mazur <47872060+AHGIJMKLKKZNPJKQR@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:11:31 +0200 Subject: [PATCH 2/4] [eas-cli] Stream workflow job logs while waiting for a run Log files are still polled and remain authoritative. On top of that, each in-progress job now subscribes to its EAS Logs Centrifugo channel, so the tail shown by `eas workflow:run --wait` and `eas workflow:status --wait` updates as lines are produced rather than once per poll. Published lines are buffered separately and stay hidden until a logId appears in both sources, so the displayed log never shows a gap. Any failure to connect or subscribe falls back to polling alone. This mirrors how the website combines the two sources. --- .../__tests__/commands/workflow-logs-test.ts | 4 +- packages/eas-cli/src/api.ts | 10 + .../workflow/__tests__/utils-test.ts | 61 ++-- .../workflow/logs/__tests__/parseLogs-test.ts | 143 +++++++++ .../workflow/logs/__tests__/watcher-test.ts | 279 +++++++++++++++++ .../workflow/{ => logs}/fetchLogs.ts | 26 +- .../commandUtils/workflow/logs/parseLogs.ts | 89 ++++++ .../src/commandUtils/workflow/logs/watcher.ts | 187 ++++++++++++ .../src/commandUtils/workflow/stateMachine.ts | 2 +- .../src/commandUtils/workflow/types.ts | 13 +- .../src/commandUtils/workflow/utils.ts | 285 +++++++++--------- packages/eas-cli/src/graphql/generated.ts | 13 + .../graphql/mutations/RealtimeLogsMutation.ts | 69 +++++ .../src/utils/__tests__/centrifuge-test.ts | 26 ++ packages/eas-cli/src/utils/centrifuge.ts | 115 +++++++ .../utils/expodash/__tests__/uniqBy-test.ts | 8 + packages/eas-cli/src/utils/expodash/uniqBy.ts | 7 +- 17 files changed, 1143 insertions(+), 194 deletions(-) create mode 100644 packages/eas-cli/src/commandUtils/workflow/logs/__tests__/parseLogs-test.ts create mode 100644 packages/eas-cli/src/commandUtils/workflow/logs/__tests__/watcher-test.ts rename packages/eas-cli/src/commandUtils/workflow/{ => logs}/fetchLogs.ts (54%) create mode 100644 packages/eas-cli/src/commandUtils/workflow/logs/parseLogs.ts create mode 100644 packages/eas-cli/src/commandUtils/workflow/logs/watcher.ts create mode 100644 packages/eas-cli/src/graphql/mutations/RealtimeLogsMutation.ts create mode 100644 packages/eas-cli/src/utils/__tests__/centrifuge-test.ts create mode 100644 packages/eas-cli/src/utils/centrifuge.ts diff --git a/packages/eas-cli/src/__tests__/commands/workflow-logs-test.ts b/packages/eas-cli/src/__tests__/commands/workflow-logs-test.ts index 4f411ce5f7..f3bc88514f 100644 --- a/packages/eas-cli/src/__tests__/commands/workflow-logs-test.ts +++ b/packages/eas-cli/src/__tests__/commands/workflow-logs-test.ts @@ -11,7 +11,7 @@ import { import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync, -} from '../../commandUtils/workflow/fetchLogs'; +} from '../../commandUtils/workflow/logs/fetchLogs'; import WorkflowLogView from '../../commands/workflow/logs'; import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated'; import { AppQuery } from '../../graphql/queries/AppQuery'; @@ -38,7 +38,7 @@ jest.mock('fs'); jest.mock('../../log'); jest.mock('../../prompts'); jest.mock('../../utils/json'); -jest.mock('../../commandUtils/workflow/fetchLogs'); +jest.mock('../../commandUtils/workflow/logs/fetchLogs'); describe(WorkflowLogView, () => { beforeEach(() => { diff --git a/packages/eas-cli/src/api.ts b/packages/eas-cli/src/api.ts index 4f19625f5c..6a959f7a1a 100644 --- a/packages/eas-cli/src/api.ts +++ b/packages/eas-cli/src/api.ts @@ -112,3 +112,13 @@ export function getEASUpdateURL(projectId: string, manifestHostOverride: string export function getExpoApiWorkflowSchemaURL(): string { return getExpoApiBaseUrl() + '/v2/workflows/schema'; } + +export function getEASLogsWebsocketUrl(): string { + if (process.env.EXPO_STAGING) { + return `wss://staging-logs.expo.dev/connection/websocket`; + } else if (process.env.EXPO_LOCAL) { + return `ws://localhost:4997/connection/websocket`; + } else { + return `wss://logs.expo.dev/connection/websocket`; + } +} diff --git a/packages/eas-cli/src/commandUtils/workflow/__tests__/utils-test.ts b/packages/eas-cli/src/commandUtils/workflow/__tests__/utils-test.ts index e93555ecee..a6b4b7b1f8 100644 --- a/packages/eas-cli/src/commandUtils/workflow/__tests__/utils-test.ts +++ b/packages/eas-cli/src/commandUtils/workflow/__tests__/utils-test.ts @@ -1,35 +1,56 @@ import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils'; -import { fetchRawLogsForCustomJobAsync } from '../fetchLogs'; -import { infoForActiveWorkflowRunAsync } from '../utils'; import { WorkflowJobStatus } from '../../../graphql/generated'; +import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs'; +import { formatActiveWorkflowRun } from '../utils'; -jest.mock('../fetchLogs'); - -describe('workflow utils', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - test('shows the display name for the current step while keying logs by step id', async () => { - const workflowRun = getMockWorkflowRunWithJobsFragment(); - workflowRun.jobs = workflowRun.jobs.map(job => ({ - ...job, +function inProgressJobWithLogs(rawLogs: string): { + job: ReturnType['jobs'][number]; + logs: ReturnType; +} { + return { + job: { + ...getMockWorkflowRunWithJobsFragment().jobs[0], status: WorkflowJobStatus.InProgress, - })); + }, + logs: groupLogLinesIntoSteps(parseLogLines(rawLogs).logLines), + }; +} - jest - .mocked(fetchRawLogsForCustomJobAsync) - .mockResolvedValue( +describe(formatActiveWorkflowRun, () => { + test('shows the display name for the current step while keying logs by step id', () => { + const output = formatActiveWorkflowRun([ + inProgressJobWithLogs( [ '{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}', '{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}', ].join('\n') - ); - - const output = await infoForActiveWorkflowRunAsync({} as any, workflowRun); + ), + ]); expect(output).toContain('Current step'); expect(output).toContain('Install dependencies'); expect(output).not.toContain('step-id-1'); }); + + test('shows exactly maxLogLines trailing lines of the current step', () => { + const output = formatActiveWorkflowRun( + [ + inProgressJobWithLogs( + Array.from({ length: 10 }, (_, index) => + JSON.stringify({ + buildStepId: 'step-id-1', + buildStepDisplayName: 'Install dependencies', + time: '2022-01-01T00:00:00.000Z', + msg: `line${index}`, + }) + ).join('\n') + ), + ], + 5 + ); + + expect(output).toContain('line5'); + expect(output).toContain('line9'); + expect(output).not.toContain('line4'); + }); }); diff --git a/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/parseLogs-test.ts b/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/parseLogs-test.ts new file mode 100644 index 0000000000..3a1157a07c --- /dev/null +++ b/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/parseLogs-test.ts @@ -0,0 +1,143 @@ +import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs'; +import { WorkflowRawLogLine } from '../../types'; + +function logLine(overrides: Partial = {}): WorkflowRawLogLine { + return { msg: 'a message', ...overrides }; +} + +describe(parseLogLines, () => { + it('parses a JSONL log file', () => { + const { logLines, errors } = parseLogLines( + [ + '{"logId":"1","buildStepId":"install","msg":"npm ci"}', + '{"logId":"2","buildStepId":"install","msg":"done"}', + ].join('\n') + ); + + expect(errors).toEqual([]); + expect(logLines).toEqual([ + { logId: '1', buildStepId: 'install', msg: 'npm ci' }, + { logId: '2', buildStepId: 'install', msg: 'done' }, + ]); + }); + + it('skips blank lines, including a trailing newline', () => { + const { logLines, errors } = parseLogLines('{"logId":"1","msg":"one"}\n\n'); + + expect(errors).toEqual([]); + expect(logLines).toHaveLength(1); + }); + + it('reports malformed lines as errors while keeping the parsable ones', () => { + const { logLines, errors } = parseLogLines( + ['{"logId":"1","msg":"one"}', 'this is not json', '{"logId":"2","msg":"two"}'].join('\n') + ); + + expect(logLines.map(line => line.msg)).toEqual(['one', 'two']); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + }); + + it('parses a line that carries no message', () => { + const { logLines, errors } = parseLogLines('{"logId":"1"}'); + + expect(errors).toEqual([]); + expect(logLines).toEqual([{ logId: '1' }]); + }); +}); + +describe(mergeLogLines, () => { + it('keeps the rightmost line for a repeated logId', () => { + const fileLogLines = [ + logLine({ logId: '1', msg: 'from the file' }), + logLine({ logId: '2', msg: 'from the file' }), + ]; + const realtimeLogLines = [ + logLine({ logId: '2', msg: 'from realtime' }), + logLine({ logId: '3', msg: 'from realtime' }), + ]; + + expect(mergeLogLines(fileLogLines, realtimeLogLines)).toEqual([ + { logId: '1', msg: 'from the file' }, + { logId: '2', msg: 'from realtime' }, + { logId: '3', msg: 'from realtime' }, + ]); + }); + + it('keeps identical messages that have different logIds', () => { + const merged = mergeLogLines( + [logLine({ logId: '1', msg: 'Repeated text' })], + [logLine({ logId: '2', msg: 'Repeated text' })] + ); + + expect(merged).toEqual([ + { logId: '1', msg: 'Repeated text' }, + { logId: '2', msg: 'Repeated text' }, + ]); + }); + + it('keeps identical lines without logId', () => { + const merged = mergeLogLines( + [logLine({ msg: 'same' }), logLine({ msg: 'same' })], + [logLine({ msg: 'same' })] + ); + + expect(merged).toHaveLength(3); + }); + + it('deduplicates within a single group', () => { + const merged = mergeLogLines([logLine({ logId: '1' }), logLine({ logId: '1' })]); + + expect(merged).toHaveLength(1); + }); +}); + +describe(groupLogLinesIntoSteps, () => { + it('groups lines by step id and labels the step with its display name', () => { + const logs = groupLogLinesIntoSteps([ + logLine({ buildStepId: 'step-id-1', msg: 'npm ci' }), + logLine({ + buildStepId: 'step-id-1', + buildStepDisplayName: 'Install dependencies', + msg: 'ok', + }), + ]); + + expect(Array.from(logs.keys())).toEqual(['step-id-1']); + expect(logs.get('step-id-1')).toEqual({ + key: 'step-id-1', + label: 'Install dependencies', + logLines: [ + { time: undefined, msg: 'npm ci', result: undefined, marker: undefined, err: undefined }, + { time: undefined, msg: 'ok', result: undefined, marker: undefined, err: undefined }, + ], + }); + }); + + it('keeps steps in the order they first appear', () => { + const logs = groupLogLinesIntoSteps([ + logLine({ buildStepId: 'first' }), + logLine({ buildStepId: 'second' }), + logLine({ buildStepId: 'first' }), + logLine({ buildStepId: 'third' }), + ]); + + expect(Array.from(logs.keys())).toEqual(['first', 'second', 'third']); + }); + + it('falls back to the display name, then the phase, when there is no step id', () => { + const logs = groupLogLinesIntoSteps([ + logLine({ buildStepDisplayName: 'Run fastlane' }), + logLine({ phase: 'PREPARE_CREDENTIALS' }), + ]); + + expect(Array.from(logs.keys())).toEqual(['Run fastlane', 'PREPARE_CREDENTIALS']); + expect(logs.get('PREPARE_CREDENTIALS')?.label).toBe('PREPARE_CREDENTIALS'); + }); + + it('drops lines that belong to no step', () => { + const logs = groupLogLinesIntoSteps([logLine({ msg: 'a line with no step' })]); + + expect(logs.size).toBe(0); + }); +}); diff --git a/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/watcher-test.ts b/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/watcher-test.ts new file mode 100644 index 0000000000..46a61971b3 --- /dev/null +++ b/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/watcher-test.ts @@ -0,0 +1,279 @@ +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): WorkflowRawLogLine { + return { logId, buildStepId: 'install', msg }; +} + +function stepMessages(logsState: WorkflowJobLogsState, isCompleted = false): string[] { + const logs = logsState.getLogs({ isCompleted }); + return Array.from(logs.values()).flatMap(group => group.logLines.map(line => line.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('opens the gate 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 even without catch-up', () => { + const logsState = new WorkflowJobLogsState(); + logsState.ingestFileLogLines([fileLine('1', 'first')]); + logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]); + + expect(stepMessages(logsState, true)).toEqual(['first', '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']); + }); +}); diff --git a/packages/eas-cli/src/commandUtils/workflow/fetchLogs.ts b/packages/eas-cli/src/commandUtils/workflow/logs/fetchLogs.ts similarity index 54% rename from packages/eas-cli/src/commandUtils/workflow/fetchLogs.ts rename to packages/eas-cli/src/commandUtils/workflow/logs/fetchLogs.ts index 08d35f09be..4f91cb4b40 100644 --- a/packages/eas-cli/src/commandUtils/workflow/fetchLogs.ts +++ b/packages/eas-cli/src/commandUtils/workflow/logs/fetchLogs.ts @@ -1,8 +1,6 @@ -import { WorkflowJobResult } from './types'; -import { BuildQuery } from '../../graphql/queries/BuildQuery'; -import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient'; - -// This function is in a separate module for testing purposes +import { WorkflowJobResult } from '../types'; +import { BuildQuery } from '../../../graphql/queries/BuildQuery'; +import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient'; export async function fetchRawLogsForCustomJobAsync( job: WorkflowJobResult @@ -11,28 +9,20 @@ export async function fetchRawLogsForCustomJobAsync( if (!firstLogFileUrl) { return null; } - const response = await fetch(firstLogFileUrl, { - method: 'GET', - }); - const rawLogs = await response.text(); - return rawLogs; + const response = await fetch(firstLogFileUrl); + return await response.text(); } export async function fetchRawLogsForBuildJobAsync( state: { graphqlClient: ExpoGraphqlClient }, job: WorkflowJobResult ): Promise { - // Prefer turtleJobRun logs, which contain JSONL-formatted step logs - // for both built-in and custom build steps const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0]; if (turtleLogFileUrl) { - const response = await fetch(turtleLogFileUrl, { - method: 'GET', - }); + const response = await fetch(turtleLogFileUrl); return await response.text(); } - // Fall back to build logFiles const buildId = job.outputs?.build_id; if (!buildId) { return null; @@ -44,8 +34,6 @@ export async function fetchRawLogsForBuildJobAsync( if (!firstLogFileUrl) { return null; } - const response = await fetch(firstLogFileUrl, { - method: 'GET', - }); + const response = await fetch(firstLogFileUrl); return await response.text(); } diff --git a/packages/eas-cli/src/commandUtils/workflow/logs/parseLogs.ts b/packages/eas-cli/src/commandUtils/workflow/logs/parseLogs.ts new file mode 100644 index 0000000000..e761f6cbaf --- /dev/null +++ b/packages/eas-cli/src/commandUtils/workflow/logs/parseLogs.ts @@ -0,0 +1,89 @@ +import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs'; +import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types'; +import { WorkflowJobType } from '../../../graphql/generated'; +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(...logLineGroups: T[][]): T[] { + return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse(); +} + +export function groupLogLinesIntoSteps(logLines: WorkflowRawLogLine[]): WorkflowLogs { + const logs: WorkflowLogs = new Map(); + + 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) { + continue; + } + + let logGroup = logs.get(stepKey); + if (!logGroup) { + logGroup = { key: stepKey, label: stepLabel, logLines: [] }; + logs.set(stepKey, logGroup); + } + if (buildStepDisplayName) { + logGroup.label = buildStepDisplayName; + } + logGroup.logLines.push({ time, msg, result, marker, err }); + } + + return logs; +} + +export async function fetchAndParseLogsFromJobAsync( + state: { graphqlClient: ExpoGraphqlClient }, + job: WorkflowJobResult +): Promise { + let rawLogs: string | null; + switch (job.type) { + case WorkflowJobType.Build: + case WorkflowJobType.Repack: + rawLogs = await fetchRawLogsForBuildJobAsync(state, job); + break; + default: + rawLogs = await fetchRawLogsForCustomJobAsync(job); + break; + } + 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 { + const logLines = await fetchAndParseLogsFromJobAsync(state, job); + return logLines && groupLogLinesIntoSteps(logLines); +} diff --git a/packages/eas-cli/src/commandUtils/workflow/logs/watcher.ts b/packages/eas-cli/src/commandUtils/workflow/logs/watcher.ts new file mode 100644 index 0000000000..133fc57a87 --- /dev/null +++ b/packages/eas-cli/src/commandUtils/workflow/logs/watcher.ts @@ -0,0 +1,187 @@ +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).logId === 'string' + ); +} + +export class WorkflowJobLogsState { + private fileLogLines: WorkflowRawLogLine[] = []; + private fileLogIds = new Set(); + private realtimeLogLines: RealtimeLogLine[] = []; + private haveFileLogsCaughtUp = false; + private cachedLogs: WorkflowLogs | null = null; + private cachedLogsIsCompleted = false; + + public ingestFileLogLines(logLines: WorkflowRawLogLine[]): void { + this.fileLogLines = logLines; + 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.cachedLogs = null; + } + + 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; + } + this.realtimeLogLines = mergeLogLines( + this.realtimeLogLines, + publishedLogLines.filter(logLine => !this.fileLogIds.has(logLine.logId)) + ); + this.cachedLogs = null; + + return true; + } + + public getLogs({ isCompleted = false }: { isCompleted?: boolean } = {}): WorkflowLogs { + if (this.cachedLogs && this.cachedLogsIsCompleted === isCompleted) { + return this.cachedLogs; + } + const realtimeLogLines = this.haveFileLogsCaughtUp || isCompleted ? this.realtimeLogLines : []; + this.cachedLogs = groupLogLinesIntoSteps(mergeLogLines(this.fileLogLines, realtimeLogLines)); + this.cachedLogsIsCompleted = isCompleted; + return this.cachedLogs; + } +} + +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(); + private realtimeLogsClient?: RealtimeLogsClient | null; + + constructor( + private readonly graphqlClient: ExpoGraphqlClient, + private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null, + private readonly onRealtimeLogs: () => void + ) {} + + public async syncJobsAsync( + jobs: WorkflowJobResult[] + ): Promise> { + 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 { + 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 { + 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 { + 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(); + } + }); + } +} diff --git a/packages/eas-cli/src/commandUtils/workflow/stateMachine.ts b/packages/eas-cli/src/commandUtils/workflow/stateMachine.ts index c015ec41a5..c62a762927 100644 --- a/packages/eas-cli/src/commandUtils/workflow/stateMachine.ts +++ b/packages/eas-cli/src/commandUtils/workflow/stateMachine.ts @@ -1,11 +1,11 @@ import { Choice } from 'prompts'; import { WorkflowJobResult, WorkflowLogs } from './types'; +import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs'; import { choiceFromWorkflowJob, choiceFromWorkflowRun, choicesFromWorkflowLogs, - fetchAndProcessLogsFromJobAsync, processWorkflowRuns, } from './utils'; import { AppQuery } from '../../graphql/queries/AppQuery'; diff --git a/packages/eas-cli/src/commandUtils/workflow/types.ts b/packages/eas-cli/src/commandUtils/workflow/types.ts index b2897639b2..fee2ef26db 100644 --- a/packages/eas-cli/src/commandUtils/workflow/types.ts +++ b/packages/eas-cli/src/commandUtils/workflow/types.ts @@ -42,14 +42,23 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & { logs?: string; }; -export type WorkflowLogLine = { - time: 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' | 'msg' | 'result' | 'marker' | 'err' +>; + export type WorkflowLogs = Map< string, { diff --git a/packages/eas-cli/src/commandUtils/workflow/utils.ts b/packages/eas-cli/src/commandUtils/workflow/utils.ts index 589d76dd9f..0e8f90875e 100644 --- a/packages/eas-cli/src/commandUtils/workflow/utils.ts +++ b/packages/eas-cli/src/commandUtils/workflow/utils.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import * as fs from 'node:fs'; -import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs'; +import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs'; import { WorkflowJobResult, WorkflowLogLine, @@ -11,20 +11,22 @@ import { } from './types'; import { WorkflowJobStatus, - WorkflowJobType, WorkflowRunByIdQuery, WorkflowRunByIdWithJobsQuery, WorkflowRunFragment, WorkflowRunStatus, WorkflowRunTriggerEventType, } from '../../graphql/generated'; +import { WorkflowRunLogsWatcher } from './logs/watcher'; import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery'; import Log from '../../log'; import { ora } from '../../ora'; import { Choice } from '../../prompts'; import formatFields from '../../utils/formatFields'; +import { createRealtimeLogsClient } from '../../utils/centrifuge'; import { sleepAsync } from '../../utils/promise'; import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient'; +import nullthrows from 'nullthrows'; export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): { triggerType: WorkflowTriggerType; @@ -121,52 +123,6 @@ export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunRes }); } -export async function fetchAndProcessLogsFromJobAsync( - state: { graphqlClient: ExpoGraphqlClient }, - job: WorkflowJobResult -): Promise { - let rawLogs: string | null; - switch (job.type) { - case WorkflowJobType.Build: - case WorkflowJobType.Repack: - rawLogs = await fetchRawLogsForBuildJobAsync(state, job); - break; - default: - rawLogs = await fetchRawLogsForCustomJobAsync(job); - break; - } - if (!rawLogs) { - return null; - } - Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`); - const logs: WorkflowLogs = new Map(); - rawLogs.split('\n').forEach((line, index) => { - Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`); - try { - const parsedLine = JSON.parse(line); - const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = - parsedLine; - const stepKey = buildStepId ?? buildStepDisplayName ?? phase; - const stepLabel = buildStepDisplayName ?? buildStepId ?? phase; - if (stepKey && stepLabel) { - if (!logs.has(stepKey)) { - logs.set(stepKey, { - key: stepKey, - label: stepLabel, - logLines: [], - }); - } - const logGroup = logs.get(stepKey)!; - if (buildStepDisplayName) { - logGroup.label = buildStepDisplayName; - } - logGroup.logLines.push({ time, msg, result, marker, err }); - } - } catch {} - }); - return logs; -} - function descriptionForJobStatus(status: WorkflowJobStatus): string { switch (status) { case WorkflowJobStatus.New: @@ -188,82 +144,90 @@ function descriptionForJobStatus(status: WorkflowJobStatus): string { } } -export async function infoForActiveWorkflowRunAsync( +type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']; + +type JobWithLogs = { + job: WorkflowRunWithJobs['jobs'][number]; + logs: WorkflowLogs; +}; + +function stepLogTail( + step: { logLines?: WorkflowLogLine[] }, + maxLogLines: number // -1 means no limit +): string[] { + const messages = step.logLines?.map(line => line.msg) ?? []; + return maxLogLines === -1 ? messages : messages.slice(-maxLogLines); +} + +export async function logsForFailedWorkflowRunAsync( graphqlClient: ExpoGraphqlClient, - workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'], + workflowRun: WorkflowRunWithJobs +): Promise { + const jobs = workflowRun.jobs.filter(job => job.status === WorkflowJobStatus.Failure); + return await Promise.all( + jobs.map(async job => { + const maybeLogs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job); + return { job, logs: maybeLogs ?? new Map() }; + }) + ); +} + +export function formatActiveWorkflowRun( + jobLogs: JobWithLogs[], maxLogLines: number = 5 // -1 means no limit -): Promise { - const statusLines = []; +): string { const statusValues = []; - for (const job of workflowRun.jobs) { + + for (const { job, logs } of jobLogs) { statusValues.push({ label: '', value: '' }); statusValues.push({ label: ' Job', value: job.name }); statusValues.push({ label: ' Status', value: descriptionForJobStatus(job.status) }); if (job.status !== WorkflowJobStatus.InProgress) { continue; } - const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job); - const steps = logs ? choicesFromWorkflowLogs(logs) : []; - if (steps.length > 0) { - const currentStep = steps[steps.length - 1]; - statusValues.push({ label: ' Current step', value: currentStep.name }); - if (currentStep?.logLines?.length) { - statusValues.push({ label: ' Current logs', value: '' }); - const currentLogs = - currentStep.logLines - ?.map(line => line.msg) - .filter((_, index) => { - if (maxLogLines === -1) { - return true; - } - return index > (currentStep.logLines?.length ?? 0) - maxLogLines; - }) ?? []; - for (const log of currentLogs) { - statusValues.push({ label: '', value: log }); - } + const steps = choicesFromWorkflowLogs(logs); + if (steps.length === 0) { + continue; + } + const currentStep = steps[steps.length - 1]; + statusValues.push({ label: ' Current step', value: currentStep.name }); + if (currentStep.logLines?.length) { + statusValues.push({ label: ' Current logs', value: '' }); + for (const log of stepLogTail(currentStep, maxLogLines)) { + statusValues.push({ label: '', value: log }); } } } + statusValues.push({ label: '', value: '' }); - statusLines.push(formatFields(statusValues)); - return statusLines.join('\n'); + return formatFields(statusValues); } -export async function infoForFailedWorkflowRunAsync( - graphqlClient: ExpoGraphqlClient, - workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'], +export function formatFailedWorkflowRun( + jobLogs: JobWithLogs[], maxLogLines: number = -1 // -1 means no limit -): Promise { - const statusLines = []; +): string { const statusValues = []; - const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines; - for (const job of workflowRun.jobs) { - if (job.status !== WorkflowJobStatus.Failure) { - continue; - } - const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job); - const steps = logs ? choicesFromWorkflowLogs(logs) : []; + + for (const { job, logs } of jobLogs) { statusValues.push({ label: '', value: '' }); statusValues.push({ label: ' Failed job', value: job.name }); - if (steps.length > 0) { - const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed'); - if (failedStep) { - const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? []; - statusValues.push({ label: ' Failed step', value: failedStep.name }); - statusValues.push({ - label: ' Logs for failed step', - value: '', - }); - for (const log of logs) { - statusValues.push({ label: '', value: log }); - } - } + const steps = choicesFromWorkflowLogs(logs); + const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed'); + if (!failedStep) { + continue; + } + statusValues.push({ label: ' Failed step', value: failedStep.name }); + statusValues.push({ label: ' Logs for failed step', value: '' }); + for (const log of stepLogTail(failedStep, maxLogLines)) { + statusValues.push({ label: '', value: log }); } } + statusValues.push({ label: '', value: '' }); - statusLines.push(formatFields(statusValues)); - return statusLines.join('\n'); + return formatFields(statusValues); } + export async function fileExistsAsync(filePath: string): Promise { return await fs.promises .access(filePath, fs.constants.F_OK) @@ -314,63 +278,90 @@ export async function showWorkflowStatusAsync( }).start(); spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`; + const watcher = new WorkflowRunLogsWatcher( + graphqlClient, + () => createRealtimeLogsClient(graphqlClient), + () => { + renderActiveWorkflowRun(); + } + ); let failedFetchesCount = 0; + let renderActiveWorkflowRun = (): void => {}; - while (true) { - try { - const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, { - useCache: false, - }); - - failedFetchesCount = 0; - - switch (workflowRun.status) { - case WorkflowRunStatus.New: - break; - case WorkflowRunStatus.InProgress: { - spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`; - spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5); - break; - } - case WorkflowRunStatus.ActionRequired: - spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`; - break; + try { + while (true) { + try { + const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, { + useCache: false, + }); - case WorkflowRunStatus.Canceled: - spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`; - spinner.stopAndPersist(); - return workflowRun; + failedFetchesCount = 0; - case WorkflowRunStatus.Failure: { - spinner.prefixText = chalk`{bold.red Workflow has failed.}`; - const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30); - spinner.fail(failedInfo); - return workflowRun; + switch (workflowRun.status) { + case WorkflowRunStatus.New: + break; + case WorkflowRunStatus.InProgress: { + spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`; + const logsStates = await watcher.syncJobsAsync(workflowRun.jobs); + renderActiveWorkflowRun = () => { + spinner.text = formatActiveWorkflowRun( + workflowRun.jobs.map(job => ({ + job, + logs: nullthrows( + logsStates.get(job.id), + 'syncJobsAsync must have been called before getLogs' + ).getLogs({ + isCompleted: job.status !== WorkflowJobStatus.InProgress, + }), + })), + 5 + ); + }; + renderActiveWorkflowRun(); + break; + } + case WorkflowRunStatus.ActionRequired: + spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`; + break; + + case WorkflowRunStatus.Canceled: + spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`; + spinner.stopAndPersist(); + return workflowRun; + + case WorkflowRunStatus.Failure: { + spinner.prefixText = chalk`{bold.red Workflow has failed.}`; + const jobLogs = await logsForFailedWorkflowRunAsync(graphqlClient, workflowRun); + spinner.fail(formatFailedWorkflowRun(jobLogs, 30)); + return workflowRun; + } + case WorkflowRunStatus.Success: + spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`; + spinner.text = ''; + spinner.succeed(''); + return workflowRun; } - case WorkflowRunStatus.Success: - spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`; - spinner.text = ''; - spinner.succeed(''); + if (!waitForCompletion) { + if (spinner.isSpinning) { + spinner.stopAndPersist(); + } return workflowRun; - } - if (!waitForCompletion) { - if (spinner.isSpinning) { - spinner.stopAndPersist(); } - return workflowRun; - } - } catch { - spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.'; + } catch { + spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.'; - failedFetchesCount += 1; + failedFetchesCount += 1; - if (failedFetchesCount > 6) { - spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.'); - process.exit(workflowRunExitCodes.WAIT_ABORTED); + if (failedFetchesCount > 6) { + spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.'); + process.exit(workflowRunExitCodes.WAIT_ABORTED); + } } - } - await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */); + await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */); + } + } finally { + watcher.close(); } } diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 9b4aa1f4c3..2a50e35f40 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -14372,6 +14372,19 @@ export type SetRolloutPercentageMutation = { __typename?: 'RootMutation', update | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null } } }; +export type GenerateRealtimeLogsCentrifugoConnectionTokenMutationVariables = Exact<{ [key: string]: never; }>; + + +export type GenerateRealtimeLogsCentrifugoConnectionTokenMutation = { __typename?: 'RootMutation', realtimeLogs: { __typename?: 'RealtimeLogsMutation', generateCentrifugoConnectionToken: { __typename?: 'RealtimeLogsCentrifugoConnectionToken', token: string } } }; + +export type GenerateRealtimeLogsCentrifugoSubscriptionTokenMutationVariables = Exact<{ + target: RealtimeLogsTargetInput; + thread?: InputMaybe; +}>; + + +export type GenerateRealtimeLogsCentrifugoSubscriptionTokenMutation = { __typename?: 'RootMutation', realtimeLogs: { __typename?: 'RealtimeLogsMutation', generateCentrifugoSubscriptionToken: { __typename?: 'RealtimeLogsCentrifugoSubscriptionToken', channel: string, token: string } } }; + export type CreateAndroidSubmissionMutationVariables = Exact<{ appId: Scalars['ID']['input']; config: AndroidSubmissionConfigInput; diff --git a/packages/eas-cli/src/graphql/mutations/RealtimeLogsMutation.ts b/packages/eas-cli/src/graphql/mutations/RealtimeLogsMutation.ts new file mode 100644 index 0000000000..e15ebd8017 --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/RealtimeLogsMutation.ts @@ -0,0 +1,69 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { + GenerateRealtimeLogsCentrifugoConnectionTokenMutation, + GenerateRealtimeLogsCentrifugoConnectionTokenMutationVariables, + GenerateRealtimeLogsCentrifugoSubscriptionTokenMutation, + GenerateRealtimeLogsCentrifugoSubscriptionTokenMutationVariables, + RealtimeLogsCentrifugoConnectionToken, + RealtimeLogsCentrifugoSubscriptionToken, + RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs, +} from '../generated'; + +export const RealtimeLogsMutation = { + async generateCentrifugoConnectionTokenAsync( + graphqlClient: ExpoGraphqlClient + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + GenerateRealtimeLogsCentrifugoConnectionTokenMutation, + GenerateRealtimeLogsCentrifugoConnectionTokenMutationVariables + >( + gql` + mutation GenerateRealtimeLogsCentrifugoConnectionToken { + realtimeLogs { + generateCentrifugoConnectionToken { + token + } + } + } + `, + {} + ) + .toPromise() + ); + return data.realtimeLogs.generateCentrifugoConnectionToken; + }, + async generateCentrifugoSubscriptionTokenAsync( + graphqlClient: ExpoGraphqlClient, + { target, thread }: RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + GenerateRealtimeLogsCentrifugoSubscriptionTokenMutation, + GenerateRealtimeLogsCentrifugoSubscriptionTokenMutationVariables + >( + gql` + mutation GenerateRealtimeLogsCentrifugoSubscriptionToken( + $target: RealtimeLogsTargetInput! + $thread: String + ) { + realtimeLogs { + generateCentrifugoSubscriptionToken(target: $target, thread: $thread) { + channel + token + } + } + } + `, + { target, thread } + ) + .toPromise() + ); + return data.realtimeLogs.generateCentrifugoSubscriptionToken; + }, +}; diff --git a/packages/eas-cli/src/utils/__tests__/centrifuge-test.ts b/packages/eas-cli/src/utils/__tests__/centrifuge-test.ts new file mode 100644 index 0000000000..0a71434f15 --- /dev/null +++ b/packages/eas-cli/src/utils/__tests__/centrifuge-test.ts @@ -0,0 +1,26 @@ +import { Centrifuge } from 'centrifuge'; + +import { createRealtimeLogsClient } from '../centrifuge'; + +jest.mock('centrifuge'); + +describe(createRealtimeLogsClient, () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns null instead of throwing when the connection cannot be created', () => { + jest.mocked(Centrifuge).mockImplementationOnce(() => { + throw new Error('unsupported url scheme'); + }); + + expect(createRealtimeLogsClient({} as any)).toBeNull(); + }); + + it('connects and returns a client when the connection can be created', () => { + expect(createRealtimeLogsClient({} as any)).not.toBeNull(); + + const centrifuge = jest.mocked(Centrifuge).mock.instances[0]; + expect(jest.mocked(centrifuge.connect)).toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/utils/centrifuge.ts b/packages/eas-cli/src/utils/centrifuge.ts new file mode 100644 index 0000000000..4191b81306 --- /dev/null +++ b/packages/eas-cli/src/utils/centrifuge.ts @@ -0,0 +1,115 @@ +import { Centrifuge, Subscription } from 'centrifuge'; +import { Agent } from 'https'; +import WebSocket from 'ws'; + +import { getEASLogsWebsocketUrl } from '../api'; +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs } from '../graphql/generated'; +import { RealtimeLogsMutation } from '../graphql/mutations/RealtimeLogsMutation'; +import { httpsProxyAgent } from '../fetch'; +import Log from '../log'; + +export type RealtimeLogsSubscription = { + close: () => void; +}; + +export type RealtimeLogsClient = { + subscribeAsync: ( + args: RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs, + onPublication: (data: unknown) => void + ) => Promise; + close: () => void; +}; + +function createProxiedWebSocketConstructor(agent: Agent) { + return class ProxiedWebSocket extends WebSocket { + constructor(address: string | URL, protocols?: string | string[]) { + super(address, protocols, { agent }); + } + }; +} + +function createWebSocketConstructor() { + return httpsProxyAgent ? createProxiedWebSocketConstructor(httpsProxyAgent) : WebSocket; +} + +export function createRealtimeLogsClient( + graphqlClient: ExpoGraphqlClient +): RealtimeLogsClient | null { + const getTokenAsync = async (): Promise => { + const { token } = + await RealtimeLogsMutation.generateCentrifugoConnectionTokenAsync(graphqlClient); + return token; + }; + + let client: Centrifuge; + try { + client = new Centrifuge(getEASLogsWebsocketUrl(), { + websocket: createWebSocketConstructor(), + getToken: getTokenAsync, + }); + client.on('error', ({ error }) => { + Log.debug(`Realtime logs connection error: ${error.message}`); + }); + client.connect(); + } catch (err: any) { + Log.debug(`Failed to connect to realtime logs: ${err.message}`); + return null; + } + + return { + subscribeAsync: async (args, onPublication) => { + const getSubscriptionTokenAsync = async (): Promise => { + const { token } = await RealtimeLogsMutation.generateCentrifugoSubscriptionTokenAsync( + graphqlClient, + args + ); + return token; + }; + + try { + const { channel, token } = + await RealtimeLogsMutation.generateCentrifugoSubscriptionTokenAsync(graphqlClient, args); + + const subscription = client.newSubscription(channel, { + token, + getToken: getSubscriptionTokenAsync, + }); + + subscription.on('error', ({ error }) => { + Log.debug(`Realtime logs subscription error on ${channel}: ${error.message}`); + }); + subscription.on('publication', ({ data }) => { + onPublication(data); + }); + subscription.subscribe(); + + return { + close: () => { + closeSubscription(client, subscription); + }, + }; + } catch (err: any) { + Log.debug(`Failed to subscribe to realtime logs: ${err.message}`); + return null; + } + }, + close: () => { + for (const subscription of Object.values(client.subscriptions())) { + closeSubscription(client, subscription); + } + client.removeAllListeners(); + client.disconnect(); + }, + }; +} + +function closeSubscription(client: Centrifuge, subscription: Subscription): void { + try { + subscription.unsubscribe(); + subscription.removeAllListeners(); + client.removeSubscription(subscription); + } catch (err: any) { + Log.debug(`Failed to close realtime logs subscription: ${err.message}`); + } +} diff --git a/packages/eas-cli/src/utils/expodash/__tests__/uniqBy-test.ts b/packages/eas-cli/src/utils/expodash/__tests__/uniqBy-test.ts index 5a09632b73..83d53e9161 100644 --- a/packages/eas-cli/src/utils/expodash/__tests__/uniqBy-test.ts +++ b/packages/eas-cli/src/utils/expodash/__tests__/uniqBy-test.ts @@ -18,4 +18,12 @@ describe(uniqBy, () => { { a: 4, b: 12 }, ]); }); + + it('calls getKey once per item', () => { + const getKey = jest.fn(({ a }: { a: number }) => a); + + uniqBy([{ a: 1 }, { a: 2 }, { a: 2 }], getKey); + + expect(getKey).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/eas-cli/src/utils/expodash/uniqBy.ts b/packages/eas-cli/src/utils/expodash/uniqBy.ts index d854958662..d02fbbc226 100644 --- a/packages/eas-cli/src/utils/expodash/uniqBy.ts +++ b/packages/eas-cli/src/utils/expodash/uniqBy.ts @@ -1,10 +1,11 @@ export default function uniqBy(list: T[], getKey: (item: T) => K): T[] { - const uniqueValues = new Set(); + const uniqueValues = new Set(); const result: T[] = []; for (const i of list) { - if (!uniqueValues.has(getKey(i))) { + const key = getKey(i); + if (!uniqueValues.has(key)) { result.push(i); - uniqueValues.add(getKey(i)); + uniqueValues.add(key); } } return result; From 40d71142a1a43702ee24eced5a84e54858332474 Mon Sep 17 00:00:00 2001 From: Tomasz Mazur <47872060+AHGIJMKLKKZNPJKQR@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:57:27 +0200 Subject: [PATCH 3/4] [eas-cli] Add changelog entry for realtime workflow logs --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4eadf71be..1aaae464e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [eas-cli] Add `--device` flag to `eas simulator` for selecting the virtual device to start. ([#4172](https://github.com/expo/eas-cli/pull/4172) by [@szdziedzic](https://github.com/szdziedzic)) +- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR)) ### 🐛 Bug fixes From d93150c977b45a763538fbd5ed250c316b4cd456 Mon Sep 17 00:00:00 2001 From: Tomasz Mazur <47872060+AHGIJMKLKKZNPJKQR@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:24:54 +0200 Subject: [PATCH 4/4] Make msg optional --- .../workflow/logs/__tests__/parseLogs-test.ts | 19 +++++++++++++++++++ .../commandUtils/workflow/logs/parseLogs.ts | 2 +- .../src/commandUtils/workflow/types.ts | 8 +++----- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/parseLogs-test.ts b/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/parseLogs-test.ts index 3a1157a07c..6fb40b1c7f 100644 --- a/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/parseLogs-test.ts +++ b/packages/eas-cli/src/commandUtils/workflow/logs/__tests__/parseLogs-test.ts @@ -140,4 +140,23 @@ describe(groupLogLinesIntoSteps, () => { expect(logs.size).toBe(0); }); + + it('drops lines that carry no message, keeping the rest of the step', () => { + const logs = groupLogLinesIntoSteps([ + logLine({ buildStepId: 'install', msg: undefined, marker: 'start-step' }), + logLine({ buildStepId: 'install', msg: 'npm ci' }), + ]); + + expect(logs.get('install')?.logLines).toEqual([ + { time: undefined, msg: 'npm ci', result: undefined, marker: undefined, err: undefined }, + ]); + }); + + it('creates no step for a line that carries no message', () => { + const logs = groupLogLinesIntoSteps([ + logLine({ buildStepId: 'install', msg: undefined, marker: 'end-step', result: 'success' }), + ]); + + expect(logs.size).toBe(0); + }); }); diff --git a/packages/eas-cli/src/commandUtils/workflow/logs/parseLogs.ts b/packages/eas-cli/src/commandUtils/workflow/logs/parseLogs.ts index e761f6cbaf..7d63ce8cb0 100644 --- a/packages/eas-cli/src/commandUtils/workflow/logs/parseLogs.ts +++ b/packages/eas-cli/src/commandUtils/workflow/logs/parseLogs.ts @@ -37,7 +37,7 @@ export function groupLogLinesIntoSteps(logLines: WorkflowRawLogLine[]): Workflow const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine; const stepKey = buildStepId ?? buildStepDisplayName ?? phase; const stepLabel = buildStepDisplayName ?? buildStepId ?? phase; - if (!stepKey || !stepLabel) { + if (!stepKey || !stepLabel || !msg) { continue; } diff --git a/packages/eas-cli/src/commandUtils/workflow/types.ts b/packages/eas-cli/src/commandUtils/workflow/types.ts index fee2ef26db..f395ffd5d4 100644 --- a/packages/eas-cli/src/commandUtils/workflow/types.ts +++ b/packages/eas-cli/src/commandUtils/workflow/types.ts @@ -48,16 +48,14 @@ export type WorkflowRawLogLine = { buildStepDisplayName?: string; phase?: string; time?: string; - msg: string; + msg?: string; result?: string; marker?: string; err?: any; }; -export type WorkflowLogLine = Pick< - WorkflowRawLogLine, - 'time' | 'msg' | 'result' | 'marker' | 'err' ->; +export type WorkflowLogLine = Pick & + Required>; export type WorkflowLogs = Map< string,