Skip to content

Commit 193818c

Browse files
committed
[eas-cli] Add eas update:embedded:list command
1 parent 11a8d85 commit 193818c

4 files changed

Lines changed: 549 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages.
99
### 🎉 New features
1010

1111
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
12+
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
1213
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
1314

1415
### 🐛 Bug fixes
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
2+
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
3+
import { AppPlatform } from '../../../../graphql/generated';
4+
import { ChannelQuery } from '../../../../graphql/queries/ChannelQuery';
5+
import {
6+
EmbeddedUpdateFragment,
7+
EmbeddedUpdateQuery,
8+
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
9+
import Log from '../../../../log';
10+
import { selectAsync } from '../../../../prompts';
11+
import * as json from '../../../../utils/json';
12+
import UpdateEmbeddedList from '../list';
13+
14+
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
15+
EmbeddedUpdateQuery: { viewPaginatedAsync: jest.fn() },
16+
}));
17+
jest.mock('../../../../graphql/queries/ChannelQuery', () => ({
18+
ChannelQuery: { viewUpdateChannelsOnAppAsync: jest.fn() },
19+
}));
20+
jest.mock('../../../../prompts');
21+
jest.mock('../../../../log');
22+
jest.mock('../../../../utils/json');
23+
24+
const mockPaginated = jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync);
25+
const mockViewChannels = jest.mocked(ChannelQuery.viewUpdateChannelsOnAppAsync);
26+
const mockSelectAsync = jest.mocked(selectAsync);
27+
const mockLogLog = jest.mocked(Log.log);
28+
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
29+
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
30+
31+
const MOCK_CONTEXT = {
32+
projectId: 'project-123',
33+
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
34+
};
35+
36+
const ROW_A: EmbeddedUpdateFragment = {
37+
id: 'aaaaaaaa-1111-4000-8000-000000000001',
38+
platform: AppPlatform.Ios,
39+
runtimeVersion: '1.0.0',
40+
channel: 'production',
41+
createdAt: '2026-05-29T00:00:00Z',
42+
launchAsset: { id: 'asset-a', fileSize: 1024, finalFileSize: 768, fileSHA256: 'abc123' },
43+
};
44+
const ROW_B: EmbeddedUpdateFragment = {
45+
id: 'bbbbbbbb-2222-4000-8000-000000000002',
46+
platform: AppPlatform.Android,
47+
runtimeVersion: '1.0.1',
48+
channel: 'preview',
49+
createdAt: '2026-05-30T00:00:00Z',
50+
launchAsset: { id: 'asset-b', fileSize: 2048, finalFileSize: null, fileSHA256: 'def456' },
51+
};
52+
53+
function emptyConnection(): {
54+
edges: { cursor: string; node: EmbeddedUpdateFragment }[];
55+
pageInfo: {
56+
hasNextPage: boolean;
57+
hasPreviousPage: boolean;
58+
startCursor: string | null;
59+
endCursor: string | null;
60+
};
61+
} {
62+
return {
63+
edges: [],
64+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null },
65+
};
66+
}
67+
68+
describe(UpdateEmbeddedList, () => {
69+
const mockConfig = getMockOclifConfig();
70+
71+
beforeEach(() => {
72+
jest.clearAllMocks();
73+
// Default: no channels on the project (skips the prompt).
74+
mockViewChannels.mockResolvedValue([]);
75+
});
76+
77+
function createCommand(argv: string[]): UpdateEmbeddedList {
78+
const command = new UpdateEmbeddedList(argv, mockConfig);
79+
// @ts-expect-error getContextAsync is protected
80+
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
81+
return command;
82+
}
83+
84+
it('prints each row when results exist', async () => {
85+
mockPaginated.mockResolvedValue({
86+
edges: [
87+
{ cursor: 'c1', node: ROW_A },
88+
{ cursor: 'c2', node: ROW_B },
89+
],
90+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c2' },
91+
});
92+
await createCommand(['--non-interactive']).run();
93+
94+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
95+
appId: MOCK_CONTEXT.projectId,
96+
filter: undefined,
97+
first: 25,
98+
after: undefined,
99+
});
100+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_A.id))).toBe(true);
101+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_B.id))).toBe(true);
102+
});
103+
104+
it('prints empty message when no results', async () => {
105+
mockPaginated.mockResolvedValue(emptyConnection());
106+
await createCommand(['--non-interactive']).run();
107+
expect(mockLogLog).toHaveBeenCalledWith('No embedded updates found.');
108+
});
109+
110+
it('--json prints connection payload and skips formatted output', async () => {
111+
mockPaginated.mockResolvedValue({
112+
edges: [{ cursor: 'c1', node: ROW_A }],
113+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
114+
});
115+
await createCommand(['--json', '--non-interactive']).run();
116+
117+
expect(mockEnableJsonOutput).toHaveBeenCalled();
118+
expect(mockPrintJson).toHaveBeenCalledWith({
119+
embeddedUpdates: [ROW_A],
120+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
121+
});
122+
expect(mockLogLog.mock.calls.every(c => !String(c[0]).includes(ROW_A.id))).toBe(true);
123+
});
124+
125+
it('passes filter when flags supplied', async () => {
126+
mockPaginated.mockResolvedValue(emptyConnection());
127+
await createCommand([
128+
'--non-interactive',
129+
'--platform',
130+
'ios',
131+
'--runtime-version',
132+
'1.2.0',
133+
'--channel',
134+
'preview',
135+
]).run();
136+
137+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
138+
appId: MOCK_CONTEXT.projectId,
139+
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.2.0', channel: 'preview' },
140+
first: 25,
141+
after: undefined,
142+
});
143+
});
144+
145+
it('shows next-page hint when hasNextPage', async () => {
146+
mockPaginated.mockResolvedValue({
147+
edges: [{ cursor: 'c1', node: ROW_A }],
148+
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
149+
});
150+
await createCommand(['--non-interactive']).run();
151+
152+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('--after-cursor c1'))).toBe(true);
153+
});
154+
155+
it('prompts for channel in interactive mode and applies the selected channel', async () => {
156+
mockViewChannels.mockResolvedValue([
157+
{ id: 'ch1', name: 'production' } as any,
158+
{ id: 'ch2', name: 'preview' } as any,
159+
]);
160+
mockSelectAsync.mockResolvedValue('preview');
161+
mockPaginated.mockResolvedValue(emptyConnection());
162+
163+
await createCommand([]).run();
164+
165+
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
166+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
167+
appId: MOCK_CONTEXT.projectId,
168+
filter: { platform: undefined, runtimeVersion: undefined, channel: 'preview' },
169+
first: 25,
170+
after: undefined,
171+
});
172+
});
173+
174+
it('skips channel filter when "All channels" is selected', async () => {
175+
mockViewChannels.mockResolvedValue([{ id: 'ch1', name: 'production' } as any]);
176+
// The sentinel value returned by selectAsync for the "All channels" option.
177+
mockSelectAsync.mockResolvedValue('__embedded_update_list__all_channels__');
178+
mockPaginated.mockResolvedValue(emptyConnection());
179+
180+
await createCommand([]).run();
181+
182+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
183+
appId: MOCK_CONTEXT.projectId,
184+
filter: undefined,
185+
first: 25,
186+
after: undefined,
187+
});
188+
});
189+
190+
it('skips the channel prompt when --channel is supplied', async () => {
191+
mockPaginated.mockResolvedValue(emptyConnection());
192+
await createCommand(['--channel', 'preview']).run();
193+
expect(mockSelectAsync).not.toHaveBeenCalled();
194+
expect(mockViewChannels).not.toHaveBeenCalled();
195+
});
196+
197+
it('treats --channel all as no channel filter', async () => {
198+
mockPaginated.mockResolvedValue(emptyConnection());
199+
await createCommand(['--channel', 'all']).run();
200+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
201+
appId: MOCK_CONTEXT.projectId,
202+
filter: undefined,
203+
first: 25,
204+
after: undefined,
205+
});
206+
});
207+
208+
it('skips the channel prompt when the project has no channels', async () => {
209+
mockViewChannels.mockResolvedValue([]);
210+
mockPaginated.mockResolvedValue(emptyConnection());
211+
await createCommand([]).run();
212+
expect(mockSelectAsync).not.toHaveBeenCalled();
213+
});
214+
215+
it('passes --after-cursor through to viewPaginatedAsync', async () => {
216+
mockPaginated.mockResolvedValue(emptyConnection());
217+
await createCommand(['--non-interactive', '--after-cursor', 'cursor-123']).run();
218+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
219+
appId: MOCK_CONTEXT.projectId,
220+
filter: undefined,
221+
first: 25,
222+
after: 'cursor-123',
223+
});
224+
});
225+
226+
it('honors --limit', async () => {
227+
mockPaginated.mockResolvedValue(emptyConnection());
228+
await createCommand(['--non-interactive', '--limit', '5']).run();
229+
expect(mockPaginated).toHaveBeenCalledWith(
230+
MOCK_CONTEXT.loggedIn.graphqlClient,
231+
expect.objectContaining({ first: 5 })
232+
);
233+
});
234+
235+
it('prints the section header with row count', async () => {
236+
mockPaginated.mockResolvedValue({
237+
edges: [{ cursor: 'c1', node: ROW_A }],
238+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
239+
});
240+
await createCommand(['--non-interactive']).run();
241+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1)'))).toBe(
242+
true
243+
);
244+
});
245+
246+
it('suffixes the section header count with "+" when hasNextPage', async () => {
247+
mockPaginated.mockResolvedValue({
248+
edges: [{ cursor: 'c1', node: ROW_A }],
249+
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
250+
});
251+
await createCommand(['--non-interactive']).run();
252+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1+)'))).toBe(
253+
true
254+
);
255+
});
256+
257+
it('shows relative-time "ago" hints in formatted rows', async () => {
258+
mockPaginated.mockResolvedValue({
259+
edges: [{ cursor: 'c1', node: ROW_A }],
260+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
261+
});
262+
await createCommand(['--non-interactive']).run();
263+
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).toBe(true);
264+
});
265+
266+
it('builds the prompt choices in channel-order then "All channels"', async () => {
267+
mockViewChannels.mockResolvedValue([
268+
{ id: 'ch1', name: 'production' } as any,
269+
{ id: 'ch2', name: 'preview' } as any,
270+
]);
271+
mockSelectAsync.mockResolvedValue('production');
272+
mockPaginated.mockResolvedValue(emptyConnection());
273+
274+
await createCommand([]).run();
275+
276+
const [, choices] = mockSelectAsync.mock.calls[0];
277+
const titles = (choices as any[]).map(c => c.title);
278+
expect(titles).toEqual(['production', 'preview', 'All channels']);
279+
});
280+
});

0 commit comments

Comments
 (0)