diff --git a/packages/eas-cli/src/__tests__/commands/build-stream-logs-test.ts b/packages/eas-cli/src/__tests__/commands/build-stream-logs-test.ts new file mode 100644 index 0000000000..58bb992cde --- /dev/null +++ b/packages/eas-cli/src/__tests__/commands/build-stream-logs-test.ts @@ -0,0 +1,54 @@ +import Build from '../../commands/build'; +import { getError, getErrorAsync, getMockOclifConfig } from './utils'; +import { RequestedPlatform } from '../../platform'; + +describe(Build, () => { + function sanitizeFlags(overrides: Record) { + const command = new Build([], getMockOclifConfig()) as any; + return command.sanitizeFlags({ + platform: 'android', + wait: true, + 'stream-logs': true, + ...overrides, + } as any); + } + + test('rejects --stream-logs with --no-wait', () => { + const error = getError(() => sanitizeFlags({ wait: false })) as Error; + expect(error.message).toContain('--stream-logs cannot be used with --no-wait'); + }); + + test('rejects --stream-logs with --json', () => { + const error = getError(() => sanitizeFlags({ json: true })) as Error; + expect(error.message).toContain('--stream-logs cannot be used with --json'); + }); + + test('rejects --stream-logs for local builds', async () => { + const command = new Build([], getMockOclifConfig()) as any; + const flags = sanitizeFlags({ local: true }); + + const error = await getErrorAsync(() => + command.ensurePlatformSelectedAsync({ + ...flags, + requestedPlatform: RequestedPlatform.Android, + }) + ); + + expect((error as Error).message).toContain('--stream-logs is not supported for local builds'); + }); + + test('allows --stream-logs for all-platform builds', async () => { + const command = new Build([], getMockOclifConfig()) as any; + const flags = sanitizeFlags({ platform: 'all' }); + + await expect( + command.ensurePlatformSelectedAsync({ + ...flags, + requestedPlatform: RequestedPlatform.All, + }) + ).resolves.toMatchObject({ + requestedPlatform: RequestedPlatform.All, + isBuildLogStreamingEnabled: true, + }); + }); +}); diff --git a/packages/eas-cli/src/__tests__/commands/build-view-test.ts b/packages/eas-cli/src/__tests__/commands/build-view-test.ts new file mode 100644 index 0000000000..0d0aa534ad --- /dev/null +++ b/packages/eas-cli/src/__tests__/commands/build-view-test.ts @@ -0,0 +1,67 @@ +import { getErrorAsync, mockCommandContext, mockProjectId, mockTestCommand } from './utils'; +import BuildView from '../../commands/build/view'; +import { BuildStatus } from '../../graphql/generated'; +import { BuildQuery } from '../../graphql/queries/BuildQuery'; +import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils'; +import { streamBuildLogsAsync } from '../../build/logs'; +import Log from '../../log'; + +jest.mock('../../graphql/queries/BuildQuery'); +jest.mock('../../project/projectUtils'); +jest.mock('../../build/logs'); +jest.mock('../../build/utils/formatBuild', () => ({ + formatGraphQLBuild: jest.fn(() => 'formatted build'), +})); +jest.mock('../../log'); +jest.mock('../../utils/json'); +jest.mock('../../ora', () => ({ + ora: () => ({ + start(text?: string) { + return { + text, + succeed: jest.fn(), + fail: jest.fn(), + }; + }, + }), +})); + +describe(BuildView, () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('streams logs when --stream-logs is provided', async () => { + const ctx = mockCommandContext(BuildView, { projectId: mockProjectId }) as any; + ctx.loggedIn.graphqlClient = {}; + + const build = { + id: 'build-id', + status: BuildStatus.InProgress, + logFiles: ['https://example.com/logs/build.txt'], + }; + + jest.mocked(getDisplayNameForProjectIdAsync).mockResolvedValue('Example app'); + jest.mocked(BuildQuery.byIdAsync).mockResolvedValue(build as any); + + const cmd = mockTestCommand(BuildView, ['build-id', '--stream-logs'], ctx); + await cmd.run(); + + expect(BuildQuery.byIdAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, 'build-id'); + expect(streamBuildLogsAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, build); + expect(Log.log).toHaveBeenCalledWith('\nformatted build'); + }); + + test('fails when --stream-logs is combined with --json', async () => { + const ctx = mockCommandContext(BuildView, { projectId: mockProjectId }) as any; + ctx.loggedIn.graphqlClient = {}; + + const cmd = mockTestCommand(BuildView, ['build-id', '--stream-logs', '--json'], ctx); + const error = await getErrorAsync(() => cmd.run()); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('--stream-logs cannot be used with --json'); + expect(BuildQuery.byIdAsync).not.toHaveBeenCalled(); + expect(streamBuildLogsAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/build/__tests__/logs-test.ts b/packages/eas-cli/src/build/__tests__/logs-test.ts new file mode 100644 index 0000000000..b024ee9f55 --- /dev/null +++ b/packages/eas-cli/src/build/__tests__/logs-test.ts @@ -0,0 +1,150 @@ +import { AppPlatform, BuildStatus } from "../../graphql/generated"; +import { BuildQuery } from "../../graphql/queries/BuildQuery"; +import Log from "../../log"; +import fetch from "../../fetch"; +import { + parseBuildLogLines, + streamBuildLogsAsync, + streamBuildsLogsAsync, +} from "../logs"; + +jest.mock("../../graphql/queries/BuildQuery"); +jest.mock("../../fetch"); +jest.mock("../../log"); +jest.mock("../../ora", () => ({ + ora: () => ({ + start(text?: string) { + return { + text, + succeed: jest.fn(), + fail: jest.fn(), + warn: jest.fn(), + }; + }, + }), +})); + +describe("build log streaming", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("parses only valid JSON log lines", () => { + expect( + parseBuildLogLines('{"msg":"first"}\nnot-json\n{"msg":"second"}\n'), + ).toEqual([{ msg: "first" }, { msg: "second" }]); + }); + + it("streams only newly appended log lines across rotated log file urls", async () => { + const inProgressBuild = buildFragment({ + status: BuildStatus.InProgress, + logFiles: ["https://example.com/logs/build.txt?token=first"], + }); + const finishedBuild = buildFragment({ + status: BuildStatus.Finished, + logFiles: ["https://example.com/logs/build.txt?token=second"], + }); + + jest.mocked(fetch).mockResolvedValueOnce({ + text: async () => + [ + JSON.stringify({ marker: "START_PHASE", phase: "PREBUILD" }), + JSON.stringify({ phase: "PREBUILD", msg: "first line" }), + ].join("\n"), + } as any); + jest.mocked(fetch).mockResolvedValueOnce({ + text: async () => + [ + JSON.stringify({ marker: "START_PHASE", phase: "PREBUILD" }), + JSON.stringify({ phase: "PREBUILD", msg: "first line" }), + JSON.stringify({ phase: "PREBUILD", msg: "second line" }), + ].join("\n"), + } as any); + jest + .mocked(BuildQuery.byIdAsync) + .mockResolvedValueOnce(finishedBuild as any); + + const finalBuild = await streamBuildLogsAsync( + {} as any, + inProgressBuild as any, + { + pollIntervalMs: 0, + }, + ); + + expect(finalBuild).toBe(finishedBuild); + expect(fetch).toHaveBeenNthCalledWith(1, inProgressBuild.logFiles[0], { + method: "GET", + }); + expect(fetch).toHaveBeenNthCalledWith(2, finishedBuild.logFiles[0], { + method: "GET", + }); + expect(Log.log).toHaveBeenCalledWith(expect.stringContaining("Prebuild")); + expect(Log.log).toHaveBeenCalledWith(" first line"); + expect(Log.log).toHaveBeenCalledWith(" second line"); + expect( + jest + .mocked(Log.log) + .mock.calls.filter(([message]) => message === " first line"), + ).toHaveLength(1); + }); + + it("streams multiple builds with platform labels", async () => { + const androidBuild = buildFragment({ + id: "android-build", + platform: AppPlatform.Android, + status: BuildStatus.Finished, + logFiles: ["https://example.com/logs/android.txt?token=1"], + }); + const iosBuild = buildFragment({ + id: "ios-build", + platform: AppPlatform.Ios, + status: BuildStatus.Finished, + logFiles: ["https://example.com/logs/ios.txt?token=1"], + }); + + jest + .mocked(fetch) + .mockResolvedValueOnce({ + text: async () => + JSON.stringify({ phase: "PREBUILD", msg: "android line" }), + } as any) + .mockResolvedValueOnce({ + text: async () => + JSON.stringify({ phase: "PREBUILD", msg: "ios line" }), + } as any); + + const builds = await streamBuildsLogsAsync( + {} as any, + [androidBuild as any, iosBuild as any], + { + pollIntervalMs: 0, + }, + ); + + expect(builds).toEqual([androidBuild, iosBuild]); + expect(Log.log).toHaveBeenCalledWith(expect.stringContaining("[Android]")); + expect(Log.log).toHaveBeenCalledWith(expect.stringContaining("[iOS]")); + expect(Log.log).toHaveBeenCalledWith( + expect.stringContaining("android line"), + ); + expect(Log.log).toHaveBeenCalledWith(expect.stringContaining("ios line")); + }); +}); + +function buildFragment( + overrides: Partial<{ + id: string; + platform: AppPlatform; + status: BuildStatus; + logFiles: string[]; + }>, +) { + return { + id: "build-id", + platform: AppPlatform.Android, + status: BuildStatus.InProgress, + logFiles: [], + ...overrides, + }; +} diff --git a/packages/eas-cli/src/build/logs.ts b/packages/eas-cli/src/build/logs.ts new file mode 100644 index 0000000000..55eba0beae --- /dev/null +++ b/packages/eas-cli/src/build/logs.ts @@ -0,0 +1,194 @@ +import { BuildPhase, buildPhaseDisplayName } from "@expo/eas-build-job"; +import chalk from "chalk"; + +import { ExpoGraphqlClient } from "../commandUtils/context/contextUtils/createGraphqlClient"; +import fetch, { RequestError } from "../fetch"; +import { AppPlatform, BuildFragment, BuildStatus } from "../graphql/generated"; +import { BuildQuery } from "../graphql/queries/BuildQuery"; +import Log from "../log"; +import { ora } from "../ora"; +import { appPlatformDisplayNames } from "../platform"; +import { sleepAsync } from "../utils/promise"; + +const DEFAULT_POLL_INTERVAL_MS = 1_000; + +interface BuildLogLine { + marker?: string; + msg?: string; + phase?: string; +} + +interface StreamBuildLogsOptions { + pollIntervalMs?: number; + label?: string; +} + +export async function streamBuildLogsAsync( + graphqlClient: ExpoGraphqlClient, + build: BuildFragment, + options: StreamBuildLogsOptions = {}, +): Promise { + Log.newLine(); + return await streamBuildLogsInternalAsync(graphqlClient, build, options); +} + +export async function streamBuildsLogsAsync( + graphqlClient: ExpoGraphqlClient, + builds: BuildFragment[], + { + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + }: { pollIntervalMs?: number } = {}, +): Promise { + Log.newLine(); + return await Promise.all( + builds.map((build) => + streamBuildLogsInternalAsync(graphqlClient, build, { + pollIntervalMs, + label: formatBuildLabel(build.platform), + }), + ), + ); +} + +async function streamBuildLogsInternalAsync( + graphqlClient: ExpoGraphqlClient, + build: BuildFragment, + { pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, label }: StreamBuildLogsOptions, +): Promise { + const spinner = ora( + "Streaming build logs. You can press Ctrl+C to exit.", + ).start(); + const cursors = new Map(); + const announcedPhases = new Set(); + let currentBuild = build; + + while (true) { + await printBuildLogsAsync(currentBuild, cursors, announcedPhases, label); + + if (isTerminalBuildStatus(currentBuild.status)) { + if (currentBuild.status === BuildStatus.Finished) { + spinner.succeed("Build finished"); + } else if (currentBuild.status === BuildStatus.Errored) { + spinner.fail("Build failed"); + } else { + spinner.warn("Build canceled"); + } + return currentBuild; + } + + await sleepAsync(pollIntervalMs); + currentBuild = await BuildQuery.byIdAsync(graphqlClient, currentBuild.id, { + useCache: false, + }); + } +} + +async function printBuildLogsAsync( + build: BuildFragment, + cursors: Map, + announcedPhases: Set, + label?: string, +): Promise { + for (const logFileUrl of build.logFiles) { + const logFileId = getLogFileId(logFileUrl); + const rawLogs = await fetchBuildLogFileAsync(logFileUrl); + if (!rawLogs) { + continue; + } + + const parsedLines = parseBuildLogLines(rawLogs); + const nextLineIndex = cursors.get(logFileId) ?? 0; + const freshLines = parsedLines.slice(nextLineIndex); + + for (const line of freshLines) { + printBuildLogLine(line, announcedPhases, label); + } + + cursors.set(logFileId, parsedLines.length); + } +} + +async function fetchBuildLogFileAsync( + logFileUrl: string, +): Promise { + try { + const response = await fetch(logFileUrl, { + method: "GET", + }); + return await response.text(); + } catch (error: unknown) { + if ( + error instanceof RequestError && + [403, 404].includes(error.response.status) + ) { + Log.debug( + `Failed to fetch build log file ${logFileUrl}: ${error.message}`, + ); + return null; + } + + throw error; + } +} + +function printBuildLogLine( + line: BuildLogLine, + announcedPhases: Set, + label?: string, +): void { + const phase = line.phase?.trim(); + if (phase && !announcedPhases.has(phase)) { + announcedPhases.add(phase); + const displayName = buildPhaseDisplayName[phase as BuildPhase] ?? phase; + Log.log(withLabel(chalk.bold(displayName), label)); + } + + if ( + !line.msg || + line.marker === "START_PHASE" || + line.marker === "END_PHASE" + ) { + return; + } + + for (const messageLine of line.msg.split("\n")) { + if (messageLine.length > 0) { + Log.log(withLabel(` ${messageLine}`, label)); + } + } +} + +function withLabel(message: string, label?: string): string { + return label ? `${chalk.dim(`[${label}]`)} ${message}` : message; +} + +function formatBuildLabel(platform: AppPlatform): string { + return appPlatformDisplayNames[platform]; +} + +function getLogFileId(logFileUrl: string): string { + return new URL(logFileUrl).pathname; +} + +function isTerminalBuildStatus(status: BuildFragment["status"]): boolean { + return [ + BuildStatus.Finished, + BuildStatus.Errored, + BuildStatus.Canceled, + ].includes(status); +} + +export function parseBuildLogLines(rawLogs: string): BuildLogLine[] { + const result: BuildLogLine[] = []; + + for (const line of rawLogs.split("\n")) { + try { + const parsedLine = JSON.parse(line) as BuildLogLine; + result.push(parsedLine); + } catch { + continue; + } + } + + return result; +} diff --git a/packages/eas-cli/src/build/runBuildAndSubmit.ts b/packages/eas-cli/src/build/runBuildAndSubmit.ts index 0b2fb7aa1e..a5269c3527 100644 --- a/packages/eas-cli/src/build/runBuildAndSubmit.ts +++ b/packages/eas-cli/src/build/runBuildAndSubmit.ts @@ -23,6 +23,7 @@ import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync import { prepareIosBuildAsync } from './ios/build'; import { LocalBuildMode, LocalBuildOptions } from './local'; import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient'; +import { streamBuildsLogsAsync } from './logs'; import { printBuildResults, printLogsUrls } from './utils/printBuildInfo'; import { ensureRepoIsCleanAsync } from './utils/repository'; import { Analytics } from '../analytics/AnalyticsManager'; @@ -101,6 +102,7 @@ export interface BuildFlags { buildLoggerLevel?: LoggerLevel; freezeCredentials: boolean; isVerboseLoggingEnabled?: boolean; + isBuildLogStreamingEnabled?: boolean; whatToTest?: string; } @@ -317,10 +319,15 @@ export async function runBuildAndSubmitAsync({ } const { accountName } = Object.values(buildCtxByPlatform)[0]; - const builds = await waitForBuildEndAsync(graphqlClient, { - buildIds: startedBuilds.map(({ build }) => build.id), - accountName, - }); + const builds = flags.isBuildLogStreamingEnabled + ? await streamBuildsLogsAsync( + graphqlClient, + startedBuilds.map(({ build }) => build) + ) + : await waitForBuildEndAsync(graphqlClient, { + buildIds: startedBuilds.map(({ build }) => build.id), + accountName, + }); if (!flags.json) { printBuildResults(builds); } diff --git a/packages/eas-cli/src/commands/build/index.ts b/packages/eas-cli/src/commands/build/index.ts index a40104b5bf..f43f37dd96 100644 --- a/packages/eas-cli/src/commands/build/index.ts +++ b/packages/eas-cli/src/commands/build/index.ts @@ -41,6 +41,7 @@ interface RawBuildFlags { 'build-logger-level'?: LoggerLevel; 'freeze-credentials': boolean; 'verbose-logs'?: boolean; + 'stream-logs'?: boolean; 'what-to-test'?: string; } @@ -78,6 +79,10 @@ export default class Build extends EasCommand { allowNo: true, description: 'Wait for build(s) to complete', }), + 'stream-logs': Flags.boolean({ + default: false, + description: 'Stream remote build logs while waiting for build completion', + }), 'clear-cache': Flags.boolean({ default: false, description: 'Clear cache before the build', @@ -201,6 +206,12 @@ export default class Build extends EasCommand { { exit: 1 } ); } + if (flags['stream-logs'] && !flags.wait) { + Errors.error('--stream-logs cannot be used with --no-wait', { exit: 1 }); + } + if (flags['stream-logs'] && json) { + Errors.error('--stream-logs cannot be used with --json', { exit: 1 }); + } const requestedPlatform = flags.platform && @@ -250,6 +261,7 @@ export default class Build extends EasCommand { buildLoggerLevel: flags['build-logger-level'], freezeCredentials: flags['freeze-credentials'], isVerboseLoggingEnabled: flags['verbose-logs'], + isBuildLogStreamingEnabled: flags['stream-logs'], whatToTest: flags['what-to-test'], }; } @@ -260,6 +272,9 @@ export default class Build extends EasCommand { const requestedPlatform = await selectRequestedPlatformAsync(flags.requestedPlatform); if (flags.localBuildOptions.localBuildMode) { + if (flags.isBuildLogStreamingEnabled) { + Errors.error('--stream-logs is not supported for local builds', { exit: 1 }); + } if (flags.autoSubmit) { // TODO: implement this Errors.error('Auto-submits are not yet supported when building locally', { exit: 1 }); diff --git a/packages/eas-cli/src/commands/build/view.ts b/packages/eas-cli/src/commands/build/view.ts index dfc8532332..99aa98905c 100644 --- a/packages/eas-cli/src/commands/build/view.ts +++ b/packages/eas-cli/src/commands/build/view.ts @@ -1,4 +1,5 @@ -import { Args } from '@oclif/core'; +import { Args, Errors, Flags } from '@oclif/core'; +import { streamBuildLogsAsync } from '../../build/logs'; import { formatGraphQLBuild } from '../../build/utils/formatBuild'; import EasCommand from '../../commandUtils/EasCommand'; import { EasJsonOnlyFlag } from '../../commandUtils/flags'; @@ -18,6 +19,10 @@ export default class BuildView extends EasCommand { static override flags = { ...EasJsonOnlyFlag, + 'stream-logs': Flags.boolean({ + default: false, + description: 'Stream build logs until the build reaches a terminal state', + }), }; static override contextDefinition = { @@ -31,6 +36,9 @@ export default class BuildView extends EasCommand { args: { BUILD_ID: buildId }, flags, } = await this.parse(BuildView); + if (flags.json && flags['stream-logs']) { + Errors.error('--stream-logs cannot be used with --json', { exit: 1 }); + } const { projectId, loggedIn: { graphqlClient }, @@ -73,6 +81,10 @@ export default class BuildView extends EasCommand { printJsonOnlyOutput(build); } else { Log.log(`\n${formatGraphQLBuild(build)}`); + + if (flags['stream-logs']) { + await streamBuildLogsAsync(graphqlClient, build); + } } } catch (err) { if (buildId) {