@capawesome/cli 4.22.0 → 4.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import appDestinationsService from '../../../services/app-destinations.js';
3
3
  import appGoogleServiceAccountKeysService from '../../../services/app-google-service-account-keys.js';
4
4
  import appsService from '../../../services/apps.js';
5
5
  import { withAuth } from '../../../utils/auth.js';
6
+ import { parseListOption } from '../../../utils/cli-options.js';
6
7
  import { isInteractive } from '../../../utils/environment.js';
7
8
  import { isReadable } from '../../../utils/file.js';
8
9
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
@@ -18,12 +19,42 @@ export default defineCommand({
18
19
  json: z.boolean().optional().describe('Output in JSON format.'),
19
20
  name: z.string().optional().describe('Name of the destination.'),
20
21
  platform: z.enum(['android', 'ios']).optional().describe('Platform of the destination (android, ios).'),
22
+ type: z
23
+ .enum([
24
+ 'app-store-connect',
25
+ 'apple-app-store-connect',
26
+ 'firebase-app-distribution',
27
+ 'google-play',
28
+ 'huawei-appgallery',
29
+ ])
30
+ .optional()
31
+ .describe('Type of the destination (apple-app-store-connect, firebase-app-distribution, google-play, huawei-appgallery). Defaults to `google-play` for android and `apple-app-store-connect` for ios in non-interactive environments.'),
21
32
  appleId: z.string().optional().describe('Apple ID for the destination.'),
22
33
  appleAppId: z.string().optional().describe('Apple App ID for the destination.'),
23
34
  appleTeamId: z.string().optional().describe('Apple Team ID for the destination.'),
24
35
  appleAppPassword: z.string().optional().describe('Apple app-specific password for the destination.'),
25
36
  appleApiKeyFile: z.string().optional().describe('Path to the Apple API key (.p8) file.'),
26
37
  appleIssuerId: z.string().optional().describe('Apple Issuer ID for the destination.'),
38
+ appleBetaGroup: z
39
+ .array(z.string())
40
+ .optional()
41
+ .describe('Name of an external TestFlight beta group to distribute to. Can be specified multiple times or comma-separated.'),
42
+ appleSubmitForReview: z
43
+ .union([z.boolean(), z.stringbool()], {
44
+ message: 'Apple submit for review must be either `true` or `false`.',
45
+ })
46
+ .optional()
47
+ .describe('Submit the build for App Review after the upload (App Store Connect only, requires an API key).'),
48
+ appleReleaseType: z
49
+ .enum(['after-approval', 'manual'])
50
+ .optional()
51
+ .describe('Release type of the App Store version after approval (App Store Connect only).'),
52
+ appleRejectIfPossible: z
53
+ .union([z.boolean(), z.stringbool()], {
54
+ message: 'Apple reject if possible must be either `true` or `false`.',
55
+ })
56
+ .optional()
57
+ .describe('Cancel a submission waiting for review before submitting the new build (App Store Connect only).'),
27
58
  androidPackageName: z.string().optional().describe('Android package name for the destination.'),
28
59
  androidBuildArtifactType: z.enum(['aab', 'apk']).optional().describe('Android build artifact type (aab, apk).'),
29
60
  androidReleaseStatus: z
@@ -32,9 +63,28 @@ export default defineCommand({
32
63
  .describe('Android release status (completed, draft).'),
33
64
  googleServiceAccountKeyFile: z.string().optional().describe('Path to the Google service account key JSON file.'),
34
65
  googlePlayTrack: z.string().optional().describe('Google Play track for the destination.'),
66
+ firebaseAppId: z.string().optional().describe('Firebase app ID for the destination.'),
67
+ firebaseTesterGroup: z
68
+ .array(z.string())
69
+ .optional()
70
+ .describe('The alias of a Firebase tester group to distribute to. Can be specified multiple times or comma-separated.'),
71
+ huaweiAppId: z.string().optional().describe('Huawei AppGallery app ID for the destination.'),
72
+ huaweiClientId: z.string().optional().describe('Huawei AppGallery Connect API client ID for the destination.'),
73
+ huaweiClientSecret: z
74
+ .string()
75
+ .optional()
76
+ .describe('Huawei AppGallery Connect API client secret for the destination.'),
77
+ defaultLanguage: z
78
+ .string()
79
+ .optional()
80
+ .describe('Language of the default release notes text, e.g. `en-US` (Google Play and Huawei AppGallery only).'),
35
81
  })),
36
82
  action: withAuth(async (options, args) => {
37
- let { appId, json, name, platform, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyFile, appleIssuerId, androidPackageName, androidBuildArtifactType, androidReleaseStatus, googleServiceAccountKeyFile, googlePlayTrack, } = options;
83
+ let { appId, json, name, platform, type, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyFile, appleIssuerId, appleBetaGroup, appleSubmitForReview, appleReleaseType, appleRejectIfPossible, androidPackageName, androidBuildArtifactType, androidReleaseStatus, googleServiceAccountKeyFile, googlePlayTrack, firebaseAppId, firebaseTesterGroup, huaweiAppId, huaweiClientId, huaweiClientSecret, defaultLanguage, } = options;
84
+ if (type === 'app-store-connect') {
85
+ consola.warn('The destination type `app-store-connect` is deprecated. Use `apple-app-store-connect` instead.');
86
+ type = 'apple-app-store-connect';
87
+ }
38
88
  let appleApiKeyId;
39
89
  let appAppleApiKeyId;
40
90
  let appGoogleServiceAccountKeyId;
@@ -85,7 +135,29 @@ export default defineCommand({
85
135
  process.exit(1);
86
136
  }
87
137
  }
88
- if (platform === 'android') {
138
+ // Select destination type
139
+ if (type) {
140
+ const supportedTypes = destinationTypeOptionsByPlatform[platform].map((option) => option.value);
141
+ if (!supportedTypes.includes(type)) {
142
+ consola.error(`The destination type \`${type}\` is not supported for the ${platform} platform. Supported types: ${supportedTypes.join(', ')}.`);
143
+ process.exit(1);
144
+ }
145
+ }
146
+ else if (isInteractive()) {
147
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
148
+ type = await prompt('Select the destination type:', {
149
+ type: 'select',
150
+ options: destinationTypeOptionsByPlatform[platform],
151
+ });
152
+ if (!type) {
153
+ consola.error('You must select a destination type.');
154
+ process.exit(1);
155
+ }
156
+ }
157
+ else {
158
+ type = defaultDestinationTypeByPlatform[platform];
159
+ }
160
+ if (type === 'google-play') {
89
161
  // 4. Ask for track
90
162
  if (!googlePlayTrack) {
91
163
  if (!isInteractive()) {
@@ -112,72 +184,54 @@ export default defineCommand({
112
184
  }
113
185
  // 6. Ask for publishing format
114
186
  if (!androidBuildArtifactType) {
115
- if (!isInteractive()) {
116
- consola.error('You must provide the Android build artifact type when running in non-interactive environment.');
117
- process.exit(1);
118
- }
119
- // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
120
- androidBuildArtifactType = await prompt('Select the publishing format:', {
121
- type: 'select',
122
- options: [
123
- { label: 'AAB', value: 'aab' },
124
- { label: 'APK', value: 'apk' },
125
- ],
126
- });
127
- if (!androidBuildArtifactType) {
128
- consola.error('You must select a publishing format.');
129
- process.exit(1);
130
- }
187
+ androidBuildArtifactType = await promptAndroidBuildArtifactType();
131
188
  }
132
189
  // 7. Ask for release status
133
190
  if (!androidReleaseStatus) {
134
- if (!isInteractive()) {
135
- consola.error('You must provide the Android release status when running in non-interactive environment.');
136
- process.exit(1);
137
- }
138
- // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
139
- androidReleaseStatus = await prompt('Select the release status:', {
140
- type: 'select',
141
- options: [
142
- { label: 'Draft', value: 'draft' },
143
- { label: 'Completed', value: 'completed' },
144
- ],
145
- });
146
- if (!androidReleaseStatus) {
147
- consola.error('You must select a release status.');
148
- process.exit(1);
149
- }
191
+ androidReleaseStatus = await promptAndroidReleaseStatus();
150
192
  }
151
193
  // 8. Ask for JSON key path
152
194
  if (!googleServiceAccountKeyFile) {
153
- if (!isInteractive()) {
154
- consola.error('You must provide the Google service account key file when running in non-interactive environment.');
155
- process.exit(1);
156
- }
157
- googleServiceAccountKeyFile = await prompt('Enter the path to the Google service account key JSON file:', {
158
- type: 'text',
159
- });
160
- if (!googleServiceAccountKeyFile) {
161
- consola.error('You must provide a Google service account key file path.');
162
- process.exit(1);
195
+ googleServiceAccountKeyFile = await promptGoogleServiceAccountKeyFile();
196
+ }
197
+ appGoogleServiceAccountKeyId = await uploadGoogleServiceAccountKeyFile(appId, googleServiceAccountKeyFile);
198
+ }
199
+ if (type === 'huawei-appgallery') {
200
+ if (!huaweiAppId) {
201
+ huaweiAppId = await promptRequiredText('Huawei AppGallery app ID');
202
+ }
203
+ if (!huaweiClientId) {
204
+ huaweiClientId = await promptRequiredText('Huawei AppGallery client ID');
205
+ }
206
+ if (!huaweiClientSecret) {
207
+ huaweiClientSecret = await promptRequiredText('Huawei AppGallery client secret');
208
+ }
209
+ if (!androidBuildArtifactType) {
210
+ androidBuildArtifactType = await promptAndroidBuildArtifactType();
211
+ }
212
+ if (!androidReleaseStatus) {
213
+ androidReleaseStatus = await promptAndroidReleaseStatus();
214
+ }
215
+ }
216
+ if (type === 'firebase-app-distribution') {
217
+ if (!firebaseAppId) {
218
+ firebaseAppId = await promptRequiredText('Firebase app ID');
219
+ }
220
+ if (!firebaseTesterGroup && isInteractive()) {
221
+ const firebaseTesterGroupInput = await prompt('Enter the Firebase tester group aliases, comma-separated (leave empty to skip):', { type: 'text' });
222
+ if (firebaseTesterGroupInput) {
223
+ firebaseTesterGroup = [firebaseTesterGroupInput];
163
224
  }
164
225
  }
165
- // Upload Google service account key file
166
- const googleServiceAccountKeyFileReadable = await isReadable(googleServiceAccountKeyFile);
167
- if (!googleServiceAccountKeyFileReadable) {
168
- consola.error(`The Google service account key file does not exist or is not accessible: ${googleServiceAccountKeyFile}`);
169
- process.exit(1);
226
+ if (platform === 'android' && !androidBuildArtifactType) {
227
+ androidBuildArtifactType = await promptAndroidBuildArtifactType();
170
228
  }
171
- const buffer = fs.readFileSync(googleServiceAccountKeyFile);
172
- const fileName = path.basename(googleServiceAccountKeyFile);
173
- const key = await appGoogleServiceAccountKeysService.create({
174
- appId,
175
- buffer,
176
- fileName,
177
- });
178
- appGoogleServiceAccountKeyId = key.id;
229
+ if (!googleServiceAccountKeyFile) {
230
+ googleServiceAccountKeyFile = await promptGoogleServiceAccountKeyFile();
231
+ }
232
+ appGoogleServiceAccountKeyId = await uploadGoogleServiceAccountKeyFile(appId, googleServiceAccountKeyFile);
179
233
  }
180
- if (platform === 'ios') {
234
+ if (type === 'apple-app-store-connect') {
181
235
  // 9. Ask for authentication method
182
236
  let authMethod;
183
237
  if (appleApiKeyFile || appleIssuerId) {
@@ -313,6 +367,7 @@ export default defineCommand({
313
367
  appId,
314
368
  name,
315
369
  platform: platform,
370
+ type,
316
371
  appleId,
317
372
  appleAppId,
318
373
  appleTeamId,
@@ -320,11 +375,21 @@ export default defineCommand({
320
375
  appleApiKeyId,
321
376
  appleIssuerId,
322
377
  appAppleApiKeyId,
378
+ appleBetaGroups: parseListOption(appleBetaGroup),
379
+ appleRejectIfPossible,
380
+ appleReleaseType,
381
+ appleSubmitForReview,
323
382
  androidPackageName,
324
383
  androidBuildArtifactType,
325
384
  androidReleaseStatus,
326
385
  appGoogleServiceAccountKeyId,
327
386
  googlePlayTrack,
387
+ firebaseAppId,
388
+ firebaseTesterGroups: parseListOption(firebaseTesterGroup),
389
+ huaweiAppId,
390
+ huaweiClientId,
391
+ huaweiClientSecret,
392
+ defaultLanguage,
328
393
  });
329
394
  if (json) {
330
395
  console.log(JSON.stringify({ id: response.id }, null, 2));
@@ -335,3 +400,97 @@ export default defineCommand({
335
400
  }
336
401
  }),
337
402
  });
403
+ const destinationTypeOptionsByPlatform = {
404
+ android: [
405
+ { label: 'Google Play', value: 'google-play' },
406
+ { label: 'Firebase App Distribution', value: 'firebase-app-distribution' },
407
+ { label: 'Huawei AppGallery', value: 'huawei-appgallery' },
408
+ ],
409
+ ios: [
410
+ { label: 'App Store Connect', value: 'apple-app-store-connect' },
411
+ { label: 'Firebase App Distribution', value: 'firebase-app-distribution' },
412
+ ],
413
+ };
414
+ const defaultDestinationTypeByPlatform = {
415
+ android: 'google-play',
416
+ ios: 'apple-app-store-connect',
417
+ };
418
+ const promptRequiredText = async (label) => {
419
+ if (!isInteractive()) {
420
+ consola.error(`You must provide the ${label} when running in non-interactive environment.`);
421
+ process.exit(1);
422
+ }
423
+ const value = await prompt(`Enter the ${label}:`, { type: 'text' });
424
+ if (!value) {
425
+ consola.error(`You must provide the ${label}.`);
426
+ process.exit(1);
427
+ }
428
+ return value;
429
+ };
430
+ const promptAndroidBuildArtifactType = async () => {
431
+ if (!isInteractive()) {
432
+ consola.error('You must provide the Android build artifact type when running in non-interactive environment.');
433
+ process.exit(1);
434
+ }
435
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
436
+ const androidBuildArtifactType = await prompt('Select the publishing format:', {
437
+ type: 'select',
438
+ options: [
439
+ { label: 'AAB', value: 'aab' },
440
+ { label: 'APK', value: 'apk' },
441
+ ],
442
+ });
443
+ if (!androidBuildArtifactType) {
444
+ consola.error('You must select a publishing format.');
445
+ process.exit(1);
446
+ }
447
+ return androidBuildArtifactType;
448
+ };
449
+ const promptAndroidReleaseStatus = async () => {
450
+ if (!isInteractive()) {
451
+ consola.error('You must provide the Android release status when running in non-interactive environment.');
452
+ process.exit(1);
453
+ }
454
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
455
+ const androidReleaseStatus = await prompt('Select the release status:', {
456
+ type: 'select',
457
+ options: [
458
+ { label: 'Draft', value: 'draft' },
459
+ { label: 'Completed', value: 'completed' },
460
+ ],
461
+ });
462
+ if (!androidReleaseStatus) {
463
+ consola.error('You must select a release status.');
464
+ process.exit(1);
465
+ }
466
+ return androidReleaseStatus;
467
+ };
468
+ const promptGoogleServiceAccountKeyFile = async () => {
469
+ if (!isInteractive()) {
470
+ consola.error('You must provide the Google service account key file when running in non-interactive environment.');
471
+ process.exit(1);
472
+ }
473
+ const googleServiceAccountKeyFile = await prompt('Enter the path to the Google service account key JSON file:', {
474
+ type: 'text',
475
+ });
476
+ if (!googleServiceAccountKeyFile) {
477
+ consola.error('You must provide a Google service account key file path.');
478
+ process.exit(1);
479
+ }
480
+ return googleServiceAccountKeyFile;
481
+ };
482
+ const uploadGoogleServiceAccountKeyFile = async (appId, googleServiceAccountKeyFile) => {
483
+ const googleServiceAccountKeyFileReadable = await isReadable(googleServiceAccountKeyFile);
484
+ if (!googleServiceAccountKeyFileReadable) {
485
+ consola.error(`The Google service account key file does not exist or is not accessible: ${googleServiceAccountKeyFile}`);
486
+ process.exit(1);
487
+ }
488
+ const buffer = fs.readFileSync(googleServiceAccountKeyFile);
489
+ const fileName = path.basename(googleServiceAccountKeyFile);
490
+ const key = await appGoogleServiceAccountKeysService.create({
491
+ appId,
492
+ buffer,
493
+ fileName,
494
+ });
495
+ return key.id;
496
+ };
@@ -0,0 +1,291 @@
1
+ import { DEFAULT_API_BASE_URL } from '../../../config/consts.js';
2
+ import authorizationService from '../../../services/authorization-service.js';
3
+ import { isInteractive } from '../../../utils/environment.js';
4
+ import { prompt } from '../../../utils/prompt.js';
5
+ import userConfig from '../../../utils/user-config.js';
6
+ import consola from 'consola';
7
+ import fs from 'fs/promises';
8
+ import nock from 'nock';
9
+ import os from 'os';
10
+ import path from 'path';
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import createDestinationCommand from './create.js';
13
+ // Mock dependencies
14
+ vi.mock('@/utils/user-config.js');
15
+ vi.mock('@/utils/prompt.js');
16
+ vi.mock('@/services/authorization-service.js');
17
+ vi.mock('@/utils/environment.js');
18
+ vi.mock('consola');
19
+ describe('apps-destinations-create', () => {
20
+ const mockUserConfig = vi.mocked(userConfig);
21
+ const mockPrompt = vi.mocked(prompt);
22
+ const mockIsInteractive = vi.mocked(isInteractive);
23
+ const mockConsola = vi.mocked(consola);
24
+ const mockAuthorizationService = vi.mocked(authorizationService);
25
+ const appId = 'app-123';
26
+ const destinationId = 'destination-456';
27
+ const googleServiceAccountKeyId = 'key-789';
28
+ const appleApiKeyId = 'apple-key-012';
29
+ const testToken = 'test-token';
30
+ let tempDirectory;
31
+ let googleServiceAccountKeyFile;
32
+ beforeEach(async () => {
33
+ vi.clearAllMocks();
34
+ mockUserConfig.read.mockReturnValue({ token: testToken });
35
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
36
+ mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
37
+ mockIsInteractive.mockReturnValue(false);
38
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
39
+ throw new Error(`Process exited with code ${code}`);
40
+ });
41
+ tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'capawesome-cli-destinations-create-'));
42
+ googleServiceAccountKeyFile = path.join(tempDirectory, 'service-account.json');
43
+ await fs.writeFile(googleServiceAccountKeyFile, '{}');
44
+ });
45
+ afterEach(async () => {
46
+ nock.cleanAll();
47
+ vi.restoreAllMocks();
48
+ await fs.rm(tempDirectory, { force: true, recursive: true });
49
+ });
50
+ it('should create a Huawei AppGallery destination', async () => {
51
+ const options = {
52
+ appId,
53
+ name: 'Huawei',
54
+ platform: 'android',
55
+ type: 'huawei-appgallery',
56
+ huaweiAppId: '112233445',
57
+ huaweiClientId: 'client-id',
58
+ huaweiClientSecret: 'client-secret',
59
+ androidBuildArtifactType: 'aab',
60
+ androidReleaseStatus: 'draft',
61
+ defaultLanguage: 'en-US',
62
+ };
63
+ const scope = nock(DEFAULT_API_BASE_URL)
64
+ .post(`/v1/apps/${appId}/destinations`, {
65
+ appId,
66
+ name: 'Huawei',
67
+ platform: 'android',
68
+ type: 'huawei-appgallery',
69
+ androidBuildArtifactType: 'aab',
70
+ androidReleaseStatus: 'draft',
71
+ huaweiAppId: '112233445',
72
+ huaweiClientId: 'client-id',
73
+ huaweiClientSecret: 'client-secret',
74
+ defaultLanguage: 'en-US',
75
+ })
76
+ .matchHeader('Authorization', `Bearer ${testToken}`)
77
+ .reply(201, { id: destinationId });
78
+ await createDestinationCommand.action(options, undefined);
79
+ expect(scope.isDone()).toBe(true);
80
+ expect(mockConsola.success).toHaveBeenCalledWith('Destination created successfully.');
81
+ });
82
+ it('should create an App Store Connect destination with the Apple submission settings', async () => {
83
+ mockIsInteractive.mockReturnValue(true);
84
+ mockPrompt.mockResolvedValueOnce('ABC123DEFG');
85
+ const appleApiKeyFile = path.join(tempDirectory, 'AuthKey.p8');
86
+ await fs.writeFile(appleApiKeyFile, 'private-key');
87
+ const options = {
88
+ appId,
89
+ name: 'App Store',
90
+ platform: 'ios',
91
+ type: 'apple-app-store-connect',
92
+ appleApiKeyFile,
93
+ appleIssuerId: 'issuer-id',
94
+ appleTeamId: 'team-id',
95
+ appleBetaGroup: ['External Testers, QA'],
96
+ appleSubmitForReview: true,
97
+ appleReleaseType: 'after-approval',
98
+ appleRejectIfPossible: false,
99
+ };
100
+ const keyScope = nock(DEFAULT_API_BASE_URL)
101
+ .post(`/v1/apps/${appId}/apple-api-keys`)
102
+ .matchHeader('Authorization', `Bearer ${testToken}`)
103
+ .reply(201, { id: appleApiKeyId });
104
+ const destinationScope = nock(DEFAULT_API_BASE_URL)
105
+ .post(`/v1/apps/${appId}/destinations`, {
106
+ appId,
107
+ name: 'App Store',
108
+ platform: 'ios',
109
+ type: 'apple-app-store-connect',
110
+ appleTeamId: 'team-id',
111
+ appleApiKeyId: 'ABC123DEFG',
112
+ appleIssuerId: 'issuer-id',
113
+ appAppleApiKeyId: appleApiKeyId,
114
+ appleBetaGroups: ['External Testers', 'QA'],
115
+ appleRejectIfPossible: false,
116
+ appleReleaseType: 'after-approval',
117
+ appleSubmitForReview: true,
118
+ })
119
+ .matchHeader('Authorization', `Bearer ${testToken}`)
120
+ .reply(201, { id: destinationId });
121
+ await createDestinationCommand.action(options, undefined);
122
+ expect(keyScope.isDone()).toBe(true);
123
+ expect(destinationScope.isDone()).toBe(true);
124
+ expect(mockPrompt).toHaveBeenCalledTimes(1);
125
+ });
126
+ it('should map the deprecated destination type app-store-connect to apple-app-store-connect', async () => {
127
+ mockIsInteractive.mockReturnValue(true);
128
+ mockPrompt.mockResolvedValueOnce('ABC123DEFG');
129
+ const appleApiKeyFile = path.join(tempDirectory, 'AuthKey.p8');
130
+ await fs.writeFile(appleApiKeyFile, 'private-key');
131
+ const options = {
132
+ appId,
133
+ name: 'App Store',
134
+ platform: 'ios',
135
+ type: 'app-store-connect',
136
+ appleApiKeyFile,
137
+ appleIssuerId: 'issuer-id',
138
+ appleTeamId: 'team-id',
139
+ appleBetaGroup: ['External Testers, QA'],
140
+ appleSubmitForReview: true,
141
+ appleReleaseType: 'after-approval',
142
+ appleRejectIfPossible: false,
143
+ };
144
+ const keyScope = nock(DEFAULT_API_BASE_URL)
145
+ .post(`/v1/apps/${appId}/apple-api-keys`)
146
+ .matchHeader('Authorization', `Bearer ${testToken}`)
147
+ .reply(201, { id: appleApiKeyId });
148
+ const destinationScope = nock(DEFAULT_API_BASE_URL)
149
+ .post(`/v1/apps/${appId}/destinations`, {
150
+ appId,
151
+ name: 'App Store',
152
+ platform: 'ios',
153
+ type: 'apple-app-store-connect',
154
+ appleTeamId: 'team-id',
155
+ appleApiKeyId: 'ABC123DEFG',
156
+ appleIssuerId: 'issuer-id',
157
+ appAppleApiKeyId: appleApiKeyId,
158
+ appleBetaGroups: ['External Testers', 'QA'],
159
+ appleRejectIfPossible: false,
160
+ appleReleaseType: 'after-approval',
161
+ appleSubmitForReview: true,
162
+ })
163
+ .matchHeader('Authorization', `Bearer ${testToken}`)
164
+ .reply(201, { id: destinationId });
165
+ await createDestinationCommand.action(options, undefined);
166
+ expect(keyScope.isDone()).toBe(true);
167
+ expect(destinationScope.isDone()).toBe(true);
168
+ expect(mockPrompt).toHaveBeenCalledTimes(1);
169
+ expect(mockConsola.warn).toHaveBeenCalledWith('The destination type `app-store-connect` is deprecated. Use `apple-app-store-connect` instead.');
170
+ });
171
+ it('should create a Firebase App Distribution destination for iOS', async () => {
172
+ const options = {
173
+ appId,
174
+ name: 'Firebase',
175
+ platform: 'ios',
176
+ type: 'firebase-app-distribution',
177
+ firebaseAppId: '1:123456789012:ios:0a1b2c3d4e5f67890',
178
+ firebaseTesterGroup: ['qa-team, beta-testers', 'internal'],
179
+ googleServiceAccountKeyFile,
180
+ };
181
+ const keyScope = nock(DEFAULT_API_BASE_URL)
182
+ .post(`/v1/apps/${appId}/google-service-account-keys`)
183
+ .matchHeader('Authorization', `Bearer ${testToken}`)
184
+ .reply(201, { id: googleServiceAccountKeyId });
185
+ const destinationScope = nock(DEFAULT_API_BASE_URL)
186
+ .post(`/v1/apps/${appId}/destinations`, {
187
+ appId,
188
+ name: 'Firebase',
189
+ platform: 'ios',
190
+ type: 'firebase-app-distribution',
191
+ appGoogleServiceAccountKeyId: googleServiceAccountKeyId,
192
+ firebaseAppId: '1:123456789012:ios:0a1b2c3d4e5f67890',
193
+ firebaseTesterGroups: ['qa-team', 'beta-testers', 'internal'],
194
+ })
195
+ .matchHeader('Authorization', `Bearer ${testToken}`)
196
+ .reply(201, { id: destinationId });
197
+ await createDestinationCommand.action(options, undefined);
198
+ expect(keyScope.isDone()).toBe(true);
199
+ expect(destinationScope.isDone()).toBe(true);
200
+ expect(mockConsola.success).toHaveBeenCalledWith('Destination created successfully.');
201
+ });
202
+ it('should require the publishing format for a Firebase App Distribution destination for Android', async () => {
203
+ const options = {
204
+ appId,
205
+ name: 'Firebase',
206
+ platform: 'android',
207
+ type: 'firebase-app-distribution',
208
+ firebaseAppId: '1:123456789012:android:0a1b2c3d4e5f67890',
209
+ googleServiceAccountKeyFile,
210
+ };
211
+ await expect(createDestinationCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
212
+ expect(mockConsola.error).toHaveBeenCalledWith('You must provide the Android build artifact type when running in non-interactive environment.');
213
+ });
214
+ it('should reject a type that is not supported for the platform', async () => {
215
+ const options = {
216
+ appId,
217
+ name: 'Huawei',
218
+ platform: 'ios',
219
+ type: 'huawei-appgallery',
220
+ };
221
+ await expect(createDestinationCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
222
+ expect(mockConsola.error).toHaveBeenCalledWith('The destination type `huawei-appgallery` is not supported for the ios platform. Supported types: apple-app-store-connect, firebase-app-distribution.');
223
+ });
224
+ it('should default to the Google Play type for Android when running non-interactively', async () => {
225
+ const options = {
226
+ appId,
227
+ name: 'Google Play',
228
+ platform: 'android',
229
+ androidPackageName: 'com.example.app',
230
+ androidBuildArtifactType: 'aab',
231
+ androidReleaseStatus: 'completed',
232
+ googlePlayTrack: 'internal',
233
+ googleServiceAccountKeyFile,
234
+ };
235
+ const keyScope = nock(DEFAULT_API_BASE_URL)
236
+ .post(`/v1/apps/${appId}/google-service-account-keys`)
237
+ .matchHeader('Authorization', `Bearer ${testToken}`)
238
+ .reply(201, { id: googleServiceAccountKeyId });
239
+ const destinationScope = nock(DEFAULT_API_BASE_URL)
240
+ .post(`/v1/apps/${appId}/destinations`, {
241
+ appId,
242
+ name: 'Google Play',
243
+ platform: 'android',
244
+ type: 'google-play',
245
+ androidPackageName: 'com.example.app',
246
+ androidBuildArtifactType: 'aab',
247
+ androidReleaseStatus: 'completed',
248
+ appGoogleServiceAccountKeyId: googleServiceAccountKeyId,
249
+ googlePlayTrack: 'internal',
250
+ })
251
+ .matchHeader('Authorization', `Bearer ${testToken}`)
252
+ .reply(201, { id: destinationId });
253
+ await createDestinationCommand.action(options, undefined);
254
+ expect(keyScope.isDone()).toBe(true);
255
+ expect(destinationScope.isDone()).toBe(true);
256
+ expect(mockPrompt).not.toHaveBeenCalled();
257
+ });
258
+ it('should prompt for the type with the options of the platform when running interactively', async () => {
259
+ mockIsInteractive.mockReturnValue(true);
260
+ mockPrompt.mockResolvedValueOnce('firebase-app-distribution').mockResolvedValueOnce('');
261
+ const options = {
262
+ appId,
263
+ name: 'Firebase',
264
+ platform: 'ios',
265
+ firebaseAppId: '1:123456789012:ios:0a1b2c3d4e5f67890',
266
+ googleServiceAccountKeyFile,
267
+ };
268
+ nock(DEFAULT_API_BASE_URL)
269
+ .post(`/v1/apps/${appId}/google-service-account-keys`)
270
+ .reply(201, { id: googleServiceAccountKeyId });
271
+ const destinationScope = nock(DEFAULT_API_BASE_URL)
272
+ .post(`/v1/apps/${appId}/destinations`, {
273
+ appId,
274
+ name: 'Firebase',
275
+ platform: 'ios',
276
+ type: 'firebase-app-distribution',
277
+ appGoogleServiceAccountKeyId: googleServiceAccountKeyId,
278
+ firebaseAppId: '1:123456789012:ios:0a1b2c3d4e5f67890',
279
+ })
280
+ .reply(201, { id: destinationId });
281
+ await createDestinationCommand.action(options, undefined);
282
+ expect(destinationScope.isDone()).toBe(true);
283
+ expect(mockPrompt).toHaveBeenCalledWith('Select the destination type:', {
284
+ type: 'select',
285
+ options: [
286
+ { label: 'App Store Connect', value: 'apple-app-store-connect' },
287
+ { label: 'Firebase App Distribution', value: 'firebase-app-distribution' },
288
+ ],
289
+ });
290
+ });
291
+ });