Skip to content

Commit b6cc5ff

Browse files
committed
[eas-cli] implement build log streaming for eas build and eas build:view commands
1 parent 0d3c56f commit b6cc5ff

File tree

7 files changed

+506
-5
lines changed

7 files changed

+506
-5
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Build from '../../commands/build';
2+
import { getError, getErrorAsync, getMockOclifConfig } from './utils';
3+
import { RequestedPlatform } from '../../platform';
4+
5+
describe(Build, () => {
6+
function sanitizeFlags(overrides: Record<string, unknown>) {
7+
const command = new Build([], getMockOclifConfig()) as any;
8+
return command.sanitizeFlags({
9+
platform: 'android',
10+
wait: true,
11+
'stream-logs': true,
12+
...overrides,
13+
} as any);
14+
}
15+
16+
test('rejects --stream-logs with --no-wait', () => {
17+
const error = getError(() => sanitizeFlags({ wait: false })) as Error;
18+
expect(error.message).toContain('--stream-logs cannot be used with --no-wait');
19+
});
20+
21+
test('rejects --stream-logs with --json', () => {
22+
const error = getError(() => sanitizeFlags({ json: true })) as Error;
23+
expect(error.message).toContain('--stream-logs cannot be used with --json');
24+
});
25+
26+
test('rejects --stream-logs for local builds', async () => {
27+
const command = new Build([], getMockOclifConfig()) as any;
28+
const flags = sanitizeFlags({ local: true });
29+
30+
const error = await getErrorAsync(() =>
31+
command.ensurePlatformSelectedAsync({
32+
...flags,
33+
requestedPlatform: RequestedPlatform.Android,
34+
})
35+
);
36+
37+
expect((error as Error).message).toContain('--stream-logs is not supported for local builds');
38+
});
39+
40+
test('allows --stream-logs for all-platform builds', async () => {
41+
const command = new Build([], getMockOclifConfig()) as any;
42+
const flags = sanitizeFlags({ platform: 'all' });
43+
44+
await expect(
45+
command.ensurePlatformSelectedAsync({
46+
...flags,
47+
requestedPlatform: RequestedPlatform.All,
48+
})
49+
).resolves.toMatchObject({
50+
requestedPlatform: RequestedPlatform.All,
51+
isBuildLogStreamingEnabled: true,
52+
});
53+
});
54+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { getErrorAsync, mockCommandContext, mockProjectId, mockTestCommand } from './utils';
2+
import BuildView from '../../commands/build/view';
3+
import { BuildStatus } from '../../graphql/generated';
4+
import { BuildQuery } from '../../graphql/queries/BuildQuery';
5+
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
6+
import { streamBuildLogsAsync } from '../../build/logs';
7+
import Log from '../../log';
8+
9+
jest.mock('../../graphql/queries/BuildQuery');
10+
jest.mock('../../project/projectUtils');
11+
jest.mock('../../build/logs');
12+
jest.mock('../../build/utils/formatBuild', () => ({
13+
formatGraphQLBuild: jest.fn(() => 'formatted build'),
14+
}));
15+
jest.mock('../../log');
16+
jest.mock('../../utils/json');
17+
jest.mock('../../ora', () => ({
18+
ora: () => ({
19+
start(text?: string) {
20+
return {
21+
text,
22+
succeed: jest.fn(),
23+
fail: jest.fn(),
24+
};
25+
},
26+
}),
27+
}));
28+
29+
describe(BuildView, () => {
30+
afterEach(() => {
31+
jest.clearAllMocks();
32+
});
33+
34+
test('streams logs when --stream-logs is provided', async () => {
35+
const ctx = mockCommandContext(BuildView, { projectId: mockProjectId }) as any;
36+
ctx.loggedIn.graphqlClient = {};
37+
38+
const build = {
39+
id: 'build-id',
40+
status: BuildStatus.InProgress,
41+
logFiles: ['https://example.com/logs/build.txt'],
42+
};
43+
44+
jest.mocked(getDisplayNameForProjectIdAsync).mockResolvedValue('Example app');
45+
jest.mocked(BuildQuery.byIdAsync).mockResolvedValue(build as any);
46+
47+
const cmd = mockTestCommand(BuildView, ['build-id', '--stream-logs'], ctx);
48+
await cmd.run();
49+
50+
expect(BuildQuery.byIdAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, 'build-id');
51+
expect(streamBuildLogsAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, build);
52+
expect(Log.log).toHaveBeenCalledWith('\nformatted build');
53+
});
54+
55+
test('fails when --stream-logs is combined with --json', async () => {
56+
const ctx = mockCommandContext(BuildView, { projectId: mockProjectId }) as any;
57+
ctx.loggedIn.graphqlClient = {};
58+
59+
const cmd = mockTestCommand(BuildView, ['build-id', '--stream-logs', '--json'], ctx);
60+
const error = await getErrorAsync(() => cmd.run());
61+
62+
expect(error).toBeInstanceOf(Error);
63+
expect((error as Error).message).toContain('--stream-logs cannot be used with --json');
64+
expect(BuildQuery.byIdAsync).not.toHaveBeenCalled();
65+
expect(streamBuildLogsAsync).not.toHaveBeenCalled();
66+
});
67+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { AppPlatform, BuildStatus } from "../../graphql/generated";
2+
import { BuildQuery } from "../../graphql/queries/BuildQuery";
3+
import Log from "../../log";
4+
import fetch from "../../fetch";
5+
import {
6+
parseBuildLogLines,
7+
streamBuildLogsAsync,
8+
streamBuildsLogsAsync,
9+
} from "../logs";
10+
11+
jest.mock("../../graphql/queries/BuildQuery");
12+
jest.mock("../../fetch");
13+
jest.mock("../../log");
14+
jest.mock("../../ora", () => ({
15+
ora: () => ({
16+
start(text?: string) {
17+
return {
18+
text,
19+
succeed: jest.fn(),
20+
fail: jest.fn(),
21+
warn: jest.fn(),
22+
};
23+
},
24+
}),
25+
}));
26+
27+
describe("build log streaming", () => {
28+
afterEach(() => {
29+
jest.clearAllMocks();
30+
});
31+
32+
it("parses only valid JSON log lines", () => {
33+
expect(
34+
parseBuildLogLines('{"msg":"first"}\nnot-json\n{"msg":"second"}\n'),
35+
).toEqual([{ msg: "first" }, { msg: "second" }]);
36+
});
37+
38+
it("streams only newly appended log lines across rotated log file urls", async () => {
39+
const inProgressBuild = buildFragment({
40+
status: BuildStatus.InProgress,
41+
logFiles: ["https://example.com/logs/build.txt?token=first"],
42+
});
43+
const finishedBuild = buildFragment({
44+
status: BuildStatus.Finished,
45+
logFiles: ["https://example.com/logs/build.txt?token=second"],
46+
});
47+
48+
jest.mocked(fetch).mockResolvedValueOnce({
49+
text: async () =>
50+
[
51+
JSON.stringify({ marker: "START_PHASE", phase: "PREBUILD" }),
52+
JSON.stringify({ phase: "PREBUILD", msg: "first line" }),
53+
].join("\n"),
54+
} as any);
55+
jest.mocked(fetch).mockResolvedValueOnce({
56+
text: async () =>
57+
[
58+
JSON.stringify({ marker: "START_PHASE", phase: "PREBUILD" }),
59+
JSON.stringify({ phase: "PREBUILD", msg: "first line" }),
60+
JSON.stringify({ phase: "PREBUILD", msg: "second line" }),
61+
].join("\n"),
62+
} as any);
63+
jest
64+
.mocked(BuildQuery.byIdAsync)
65+
.mockResolvedValueOnce(finishedBuild as any);
66+
67+
const finalBuild = await streamBuildLogsAsync(
68+
{} as any,
69+
inProgressBuild as any,
70+
{
71+
pollIntervalMs: 0,
72+
},
73+
);
74+
75+
expect(finalBuild).toBe(finishedBuild);
76+
expect(fetch).toHaveBeenNthCalledWith(1, inProgressBuild.logFiles[0], {
77+
method: "GET",
78+
});
79+
expect(fetch).toHaveBeenNthCalledWith(2, finishedBuild.logFiles[0], {
80+
method: "GET",
81+
});
82+
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining("Prebuild"));
83+
expect(Log.log).toHaveBeenCalledWith(" first line");
84+
expect(Log.log).toHaveBeenCalledWith(" second line");
85+
expect(
86+
jest
87+
.mocked(Log.log)
88+
.mock.calls.filter(([message]) => message === " first line"),
89+
).toHaveLength(1);
90+
});
91+
92+
it("streams multiple builds with platform labels", async () => {
93+
const androidBuild = buildFragment({
94+
id: "android-build",
95+
platform: AppPlatform.Android,
96+
status: BuildStatus.Finished,
97+
logFiles: ["https://example.com/logs/android.txt?token=1"],
98+
});
99+
const iosBuild = buildFragment({
100+
id: "ios-build",
101+
platform: AppPlatform.Ios,
102+
status: BuildStatus.Finished,
103+
logFiles: ["https://example.com/logs/ios.txt?token=1"],
104+
});
105+
106+
jest
107+
.mocked(fetch)
108+
.mockResolvedValueOnce({
109+
text: async () =>
110+
JSON.stringify({ phase: "PREBUILD", msg: "android line" }),
111+
} as any)
112+
.mockResolvedValueOnce({
113+
text: async () =>
114+
JSON.stringify({ phase: "PREBUILD", msg: "ios line" }),
115+
} as any);
116+
117+
const builds = await streamBuildsLogsAsync(
118+
{} as any,
119+
[androidBuild as any, iosBuild as any],
120+
{
121+
pollIntervalMs: 0,
122+
},
123+
);
124+
125+
expect(builds).toEqual([androidBuild, iosBuild]);
126+
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining("[Android]"));
127+
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining("[iOS]"));
128+
expect(Log.log).toHaveBeenCalledWith(
129+
expect.stringContaining("android line"),
130+
);
131+
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining("ios line"));
132+
});
133+
});
134+
135+
function buildFragment(
136+
overrides: Partial<{
137+
id: string;
138+
platform: AppPlatform;
139+
status: BuildStatus;
140+
logFiles: string[];
141+
}>,
142+
) {
143+
return {
144+
id: "build-id",
145+
platform: AppPlatform.Android,
146+
status: BuildStatus.InProgress,
147+
logFiles: [],
148+
...overrides,
149+
};
150+
}

0 commit comments

Comments
 (0)