Skip to content

Commit 3fef5e4

Browse files
committed
[eas-cli] Add eas update:embedded:upload command
1 parent fa6c877 commit 3fef5e4

10 files changed

Lines changed: 696 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
88

99
### 🎉 New features
1010

11+
- [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp))
12+
1113
### 🐛 Bug fixes
1214

1315
### 🧹 Chores
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { Platform } from '@expo/eas-build-job';
2+
import { Updates } from '@expo/config-plugins';
3+
import { vol } from 'memfs';
4+
5+
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
6+
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
7+
import { EmbeddedUpdateAssetMutation } from '../../../../graphql/mutations/EmbeddedUpdateAssetMutation';
8+
import {
9+
EmbeddedUpdateMutation,
10+
isEmbeddedUpdateAssetNotAvailableError,
11+
} from '../../../../graphql/mutations/EmbeddedUpdateMutation';
12+
import { AppPlatform } from '../../../../graphql/generated';
13+
import Log from '../../../../log';
14+
import { ora } from '../../../../ora';
15+
import * as uploads from '../../../../uploads';
16+
import * as promise from '../../../../utils/promise';
17+
import UpdateEmbeddedUpload from '../upload';
18+
19+
jest.mock('fs', () => jest.requireActual('memfs').fs);
20+
jest.mock('../../../../ora');
21+
jest.mock('@expo/config-plugins', () => ({
22+
Updates: { getRuntimeVersionNullableAsync: jest.fn() },
23+
}));
24+
jest.mock('../../../../graphql/mutations/EmbeddedUpdateAssetMutation', () => ({
25+
EmbeddedUpdateAssetMutation: { getSignedUploadSpecAsync: jest.fn() },
26+
}));
27+
jest.mock('../../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
28+
EmbeddedUpdateMutation: { uploadEmbeddedUpdateAsync: jest.fn() },
29+
isEmbeddedUpdateAssetNotAvailableError: jest.fn(),
30+
}));
31+
jest.mock('../../../../uploads');
32+
jest.mock('../../../../log');
33+
jest.mock('../../../../utils/promise', () => ({
34+
sleepAsync: jest.fn().mockResolvedValue(undefined),
35+
}));
36+
37+
const mockGetRuntimeVersion = jest.mocked(Updates.getRuntimeVersionNullableAsync);
38+
const mockGetSignedUploadSpec = jest.mocked(EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync);
39+
const mockUpload = jest.mocked(uploads.uploadWithPresignedPostWithRetryAsync);
40+
const mockUploadEmbeddedUpdate = jest.mocked(EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync);
41+
const mockOra = jest.mocked(ora);
42+
const mockUploadSpinnerSucceed = jest.fn();
43+
const mockUploadSpinnerFail = jest.fn();
44+
const mockRegisterSpinnerSucceed = jest.fn();
45+
const mockRegisterSpinnerFail = jest.fn();
46+
const mockSleepAsync = jest.mocked(promise.sleepAsync);
47+
const mockIsEmbeddedUpdateAssetNotAvailableError = jest.mocked(isEmbeddedUpdateAssetNotAvailableError);
48+
const mockLogLog = jest.mocked(Log.log);
49+
const mockLogWarn = jest.mocked(Log.warn);
50+
51+
const BUNDLE_PATH = '/project/app.bundle';
52+
const MANIFEST_PATH = '/project/app.manifest';
53+
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
54+
const VALID_MANIFEST = JSON.stringify({ id: VALID_UUID });
55+
56+
const BASE_ARGV = [
57+
'--platform',
58+
Platform.IOS,
59+
'--bundle',
60+
BUNDLE_PATH,
61+
'--manifest',
62+
MANIFEST_PATH,
63+
'--channel',
64+
'production',
65+
];
66+
67+
const MOCK_CONTEXT = {
68+
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
69+
privateProjectConfig: {
70+
projectId: 'project-123',
71+
exp: { name: 'test', slug: 'test' },
72+
projectDir: '/project',
73+
},
74+
};
75+
76+
const MOCK_EMBEDDED_UPDATE = {
77+
id: 'embedded-update-id-abc',
78+
platform: AppPlatform.Ios,
79+
runtimeVersion: '1.0.0',
80+
channel: 'production',
81+
createdAt: '2024-01-01T00:00:00Z',
82+
};
83+
84+
describe(UpdateEmbeddedUpload, () => {
85+
const mockConfig = getMockOclifConfig();
86+
87+
beforeEach(() => {
88+
jest.clearAllMocks();
89+
mockOra.mockImplementation((text?: string | object) => ({
90+
start: () =>
91+
text === 'Uploading bundle...'
92+
? { succeed: mockUploadSpinnerSucceed, fail: mockUploadSpinnerFail }
93+
: { succeed: mockRegisterSpinnerSucceed, fail: mockRegisterSpinnerFail },
94+
} as any));
95+
vol.reset();
96+
vol.fromJSON({
97+
[BUNDLE_PATH]: 'bundle-bytes',
98+
[MANIFEST_PATH]: VALID_MANIFEST,
99+
});
100+
mockGetRuntimeVersion.mockResolvedValue('1.0.0');
101+
mockGetSignedUploadSpec.mockResolvedValue({
102+
storageKey: 'storage-key-abc',
103+
presignedUrl: 'https://storage.googleapis.com/upload-bucket',
104+
fields: { key: 'obj-key', policy: 'abc123' },
105+
});
106+
mockUpload.mockResolvedValue(undefined as any);
107+
mockUploadEmbeddedUpdate.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
108+
mockIsEmbeddedUpdateAssetNotAvailableError.mockReturnValue(false);
109+
});
110+
111+
function createCommand(argv: string[]): UpdateEmbeddedUpload {
112+
const command = new UpdateEmbeddedUpload(argv, mockConfig);
113+
// @ts-expect-error getContextAsync is protected
114+
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
115+
return command;
116+
}
117+
118+
describe('file existence checks', () => {
119+
it('runs successfully when both files exist', async () => {
120+
const command = createCommand(BASE_ARGV);
121+
await command.runAsync();
122+
expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle');
123+
expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith(expect.stringContaining('production'));
124+
});
125+
126+
it('errors with message when bundle file does not exist', async () => {
127+
vol.reset();
128+
vol.fromJSON({ [MANIFEST_PATH]: VALID_MANIFEST });
129+
const command = createCommand(BASE_ARGV);
130+
await expect(command.runAsync()).rejects.toThrow(/Bundle file not found/);
131+
});
132+
133+
it('errors with message when manifest file does not exist', async () => {
134+
vol.reset();
135+
vol.fromJSON({ [BUNDLE_PATH]: 'bundle-bytes' });
136+
const command = createCommand(BASE_ARGV);
137+
await expect(command.runAsync()).rejects.toThrow(/Could not read or parse manifest/);
138+
});
139+
140+
it('includes build-id in log when provided', async () => {
141+
const command = createCommand([...BASE_ARGV, '--build-id', 'build-uuid-123']);
142+
await command.runAsync();
143+
expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith(expect.stringContaining('production'));
144+
});
145+
146+
it('accepts android platform', async () => {
147+
const argv = [...BASE_ARGV];
148+
argv[1] = Platform.ANDROID;
149+
const command = createCommand(argv);
150+
await command.runAsync();
151+
expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith(expect.stringContaining(Platform.ANDROID));
152+
});
153+
});
154+
155+
describe('manifest parsing', () => {
156+
it('reads embeddedUpdateId from manifest and passes platform directly to getRuntimeVersion', async () => {
157+
const command = createCommand(BASE_ARGV);
158+
await command.runAsync();
159+
expect(mockGetRuntimeVersion).toHaveBeenCalledWith(
160+
MOCK_CONTEXT.privateProjectConfig.projectDir,
161+
MOCK_CONTEXT.privateProjectConfig.exp,
162+
Platform.IOS
163+
);
164+
});
165+
166+
it('errors when manifest contains invalid JSON', async () => {
167+
vol.fromJSON({ [MANIFEST_PATH]: 'not-json', [BUNDLE_PATH]: 'bytes' });
168+
const command = createCommand(BASE_ARGV);
169+
await expect(command.runAsync()).rejects.toThrow(/Could not read or parse manifest/);
170+
});
171+
172+
it('errors when manifest id is missing', async () => {
173+
vol.fromJSON({ [MANIFEST_PATH]: JSON.stringify({ noId: true }), [BUNDLE_PATH]: 'bytes' });
174+
const command = createCommand(BASE_ARGV);
175+
await expect(command.runAsync()).rejects.toThrow(/"id" field/);
176+
});
177+
178+
it('errors when manifest id is not a valid UUID', async () => {
179+
vol.fromJSON({ [MANIFEST_PATH]: JSON.stringify({ id: 'not-a-uuid' }), [BUNDLE_PATH]: 'bytes' });
180+
const command = createCommand(BASE_ARGV);
181+
await expect(command.runAsync()).rejects.toThrow(/is not a UUID/);
182+
});
183+
184+
it('errors when runtimeVersion cannot be resolved', async () => {
185+
mockGetRuntimeVersion.mockResolvedValue(null);
186+
const command = createCommand(BASE_ARGV);
187+
await expect(command.runAsync()).rejects.toThrow(/runtimeVersion/);
188+
});
189+
});
190+
191+
describe('bundle upload', () => {
192+
it('requests a presigned URL with embeddedUpdateId and uploads the bundle', async () => {
193+
const command = createCommand(BASE_ARGV);
194+
await command.runAsync();
195+
expect(mockGetSignedUploadSpec).toHaveBeenCalledWith(
196+
MOCK_CONTEXT.loggedIn.graphqlClient,
197+
expect.objectContaining({
198+
appId: MOCK_CONTEXT.privateProjectConfig.projectId,
199+
embeddedUpdateId: VALID_UUID,
200+
contentType: 'application/javascript',
201+
})
202+
);
203+
expect(mockUpload).toHaveBeenCalledWith(
204+
BUNDLE_PATH,
205+
{ url: 'https://storage.googleapis.com/upload-bucket', fields: { key: 'obj-key', policy: 'abc123' } },
206+
expect.any(Function)
207+
);
208+
});
209+
210+
it('propagates upload errors', async () => {
211+
mockUpload.mockRejectedValue(new Error('upload failed'));
212+
const command = createCommand(BASE_ARGV);
213+
await expect(command.runAsync()).rejects.toThrow('upload failed');
214+
});
215+
});
216+
217+
describe('mutation registration', () => {
218+
it('calls uploadEmbeddedUpdateAsync with channel name and AppPlatform enum', async () => {
219+
const command = createCommand(BASE_ARGV);
220+
await command.runAsync();
221+
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith(
222+
MOCK_CONTEXT.loggedIn.graphqlClient,
223+
expect.objectContaining({
224+
appId: MOCK_CONTEXT.privateProjectConfig.projectId,
225+
platform: AppPlatform.Ios,
226+
runtimeVersion: '1.0.0',
227+
channel: 'production',
228+
embeddedUpdateId: VALID_UUID,
229+
})
230+
);
231+
});
232+
233+
it('logs the embedded update id on success', async () => {
234+
const command = createCommand(BASE_ARGV);
235+
await command.runAsync();
236+
expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle');
237+
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(MOCK_EMBEDDED_UPDATE.id));
238+
});
239+
240+
it('passes build-id as turtleBuildId when provided', async () => {
241+
const command = createCommand([...BASE_ARGV, '--build-id', 'turtle-build-xyz']);
242+
await command.runAsync();
243+
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith(
244+
expect.anything(),
245+
expect.objectContaining({ turtleBuildId: 'turtle-build-xyz' })
246+
);
247+
});
248+
249+
it('retries on ASSET_NOT_AVAILABLE: succeeds on second attempt, sleeps once with first delay', async () => {
250+
const assetNotAvailableError = new Error('asset not available');
251+
mockIsEmbeddedUpdateAssetNotAvailableError.mockImplementation(e => e === assetNotAvailableError);
252+
mockUploadEmbeddedUpdate
253+
.mockRejectedValueOnce(assetNotAvailableError)
254+
.mockResolvedValueOnce(MOCK_EMBEDDED_UPDATE);
255+
256+
const command = createCommand(BASE_ARGV);
257+
await command.runAsync();
258+
259+
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(2);
260+
expect(mockSleepAsync).toHaveBeenCalledTimes(1);
261+
expect(mockSleepAsync).toHaveBeenCalledWith(3_000);
262+
expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle');
263+
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(MOCK_EMBEDDED_UPDATE.id));
264+
});
265+
266+
it('exhausts all 10 attempts on ASSET_NOT_AVAILABLE, sleeping 9 times then throwing', async () => {
267+
const assetNotAvailableError = new Error('asset not available');
268+
mockIsEmbeddedUpdateAssetNotAvailableError.mockImplementation(e => e === assetNotAvailableError);
269+
mockUploadEmbeddedUpdate.mockRejectedValue(assetNotAvailableError);
270+
271+
const command = createCommand(BASE_ARGV);
272+
await expect(command.runAsync()).rejects.toThrow();
273+
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(10);
274+
expect(mockSleepAsync).toHaveBeenCalledTimes(9);
275+
});
276+
277+
it('propagates unexpected mutation errors immediately without retrying', async () => {
278+
const unexpectedError = new Error('network timeout');
279+
mockUploadEmbeddedUpdate.mockRejectedValue(unexpectedError);
280+
281+
const command = createCommand(BASE_ARGV);
282+
await expect(command.runAsync()).rejects.toThrow('network timeout');
283+
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1);
284+
expect(mockRegisterSpinnerFail).toHaveBeenCalledWith('Failed to register embedded update');
285+
expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining('already be registered'));
286+
});
287+
});
288+
});

0 commit comments

Comments
 (0)