Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/eas-cli/src/__tests__/commands/build-stream-logs-test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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,
});
});
});
67 changes: 67 additions & 0 deletions packages/eas-cli/src/__tests__/commands/build-view-test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
150 changes: 150 additions & 0 deletions packages/eas-cli/src/build/__tests__/logs-test.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading