@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.
- package/CHANGELOG.md +14 -0
- package/dist/commands/apps/automations/update.js +1 -1
- package/dist/commands/apps/builds/create.js +44 -9
- package/dist/commands/apps/builds/create.test.js +85 -0
- package/dist/commands/apps/deployments/create.js +12 -0
- package/dist/commands/apps/deployments/create.test.js +70 -0
- package/dist/commands/apps/destinations/create.js +217 -58
- package/dist/commands/apps/destinations/create.test.js +291 -0
- package/dist/commands/apps/destinations/update.js +47 -1
- package/dist/commands/apps/destinations/update.test.js +138 -0
- package/dist/commands/apps/import.js +2 -4
- package/dist/utils/appflow-export.js +27 -5
- package/dist/utils/appflow-export.test.js +33 -0
- package/dist/utils/cli-options.js +7 -0
- package/dist/utils/cli-options.test.js +16 -0
- package/dist/utils/release-notes.js +55 -0
- package/dist/utils/release-notes.test.js +74 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import appDestinationsService from '../../../services/app-destinations.js';
|
|
2
2
|
import { withAuth } from '../../../utils/auth.js';
|
|
3
|
+
import { clearableValue, parseListOption } from '../../../utils/cli-options.js';
|
|
3
4
|
import { isInteractive } from '../../../utils/environment.js';
|
|
4
5
|
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
|
|
5
6
|
import consola from 'consola';
|
|
@@ -18,6 +19,26 @@ export default defineCommand({
|
|
|
18
19
|
appleApiKeyId: z.string().optional().describe('Apple API Key ID for the destination.'),
|
|
19
20
|
appleIssuerId: z.string().optional().describe('Apple Issuer ID for the destination.'),
|
|
20
21
|
appAppleApiKeyId: z.string().optional().describe('App Apple API Key ID for the destination.'),
|
|
22
|
+
appleBetaGroup: z
|
|
23
|
+
.array(z.string())
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Name of an external TestFlight beta group to distribute to. Can be specified multiple times or comma-separated. Pass `--apple-beta-group=` to remove all groups.'),
|
|
26
|
+
appleSubmitForReview: z
|
|
27
|
+
.union([z.boolean(), z.stringbool()], {
|
|
28
|
+
message: 'Apple submit for review must be either `true` or `false`.',
|
|
29
|
+
})
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('Submit the build for App Review after the upload (App Store Connect only, requires an API key).'),
|
|
32
|
+
appleReleaseType: z
|
|
33
|
+
.enum(['after-approval', 'manual'])
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Release type of the App Store version after approval (App Store Connect only).'),
|
|
36
|
+
appleRejectIfPossible: z
|
|
37
|
+
.union([z.boolean(), z.stringbool()], {
|
|
38
|
+
message: 'Apple reject if possible must be either `true` or `false`.',
|
|
39
|
+
})
|
|
40
|
+
.optional()
|
|
41
|
+
.describe('Cancel a submission waiting for review before submitting the new build (App Store Connect only).'),
|
|
21
42
|
androidPackageName: z.string().optional().describe('Android package name for the destination.'),
|
|
22
43
|
androidBuildArtifactType: z.enum(['aab', 'apk']).optional().describe('Android build artifact type (aab, apk).'),
|
|
23
44
|
androidReleaseStatus: z
|
|
@@ -29,9 +50,24 @@ export default defineCommand({
|
|
|
29
50
|
.optional()
|
|
30
51
|
.describe('App Google Service Account Key ID for the destination.'),
|
|
31
52
|
googlePlayTrack: z.string().optional().describe('Google Play track for the destination.'),
|
|
53
|
+
firebaseAppId: z.string().optional().describe('Firebase app ID for the destination.'),
|
|
54
|
+
firebaseTesterGroup: z
|
|
55
|
+
.array(z.string())
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('The alias of a Firebase tester group to distribute to. Can be specified multiple times or comma-separated. Pass `--firebase-tester-group=` to remove all groups.'),
|
|
58
|
+
huaweiAppId: z.string().optional().describe('Huawei AppGallery app ID for the destination.'),
|
|
59
|
+
huaweiClientId: z.string().optional().describe('Huawei AppGallery Connect API client ID for the destination.'),
|
|
60
|
+
huaweiClientSecret: z
|
|
61
|
+
.string()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe('Huawei AppGallery Connect API client secret for the destination.'),
|
|
64
|
+
defaultLanguage: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe('Language of the default release notes text, e.g. `en-US` (Google Play and Huawei AppGallery only). Pass `--default-language=` to clear it.'),
|
|
32
68
|
})),
|
|
33
69
|
action: withAuth(async (options, args) => {
|
|
34
|
-
let { appId, destinationId, name, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyId, appleIssuerId, appAppleApiKeyId, androidPackageName, androidBuildArtifactType, androidReleaseStatus, appGoogleServiceAccountKeyId, googlePlayTrack, } = options;
|
|
70
|
+
let { appId, destinationId, name, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyId, appleIssuerId, appAppleApiKeyId, appleBetaGroup, appleSubmitForReview, appleReleaseType, appleRejectIfPossible, androidPackageName, androidBuildArtifactType, androidReleaseStatus, appGoogleServiceAccountKeyId, googlePlayTrack, firebaseAppId, firebaseTesterGroup, huaweiAppId, huaweiClientId, huaweiClientSecret, defaultLanguage, } = options;
|
|
35
71
|
if (!appId) {
|
|
36
72
|
if (!isInteractive()) {
|
|
37
73
|
consola.error('You must provide an app ID when running in non-interactive environment.');
|
|
@@ -58,11 +94,21 @@ export default defineCommand({
|
|
|
58
94
|
appleApiKeyId,
|
|
59
95
|
appleIssuerId,
|
|
60
96
|
appAppleApiKeyId,
|
|
97
|
+
appleBetaGroups: parseListOption(appleBetaGroup),
|
|
98
|
+
appleRejectIfPossible,
|
|
99
|
+
appleReleaseType,
|
|
100
|
+
appleSubmitForReview,
|
|
61
101
|
androidPackageName,
|
|
62
102
|
androidBuildArtifactType,
|
|
63
103
|
androidReleaseStatus,
|
|
64
104
|
appGoogleServiceAccountKeyId,
|
|
65
105
|
googlePlayTrack,
|
|
106
|
+
firebaseAppId,
|
|
107
|
+
firebaseTesterGroups: parseListOption(firebaseTesterGroup),
|
|
108
|
+
huaweiAppId,
|
|
109
|
+
huaweiClientId,
|
|
110
|
+
huaweiClientSecret,
|
|
111
|
+
defaultLanguage: clearableValue(defaultLanguage),
|
|
66
112
|
});
|
|
67
113
|
consola.success('Destination updated successfully.');
|
|
68
114
|
}),
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE_URL } from '../../../config/consts.js';
|
|
2
|
+
import authorizationService from '../../../services/authorization-service.js';
|
|
3
|
+
import userConfig from '../../../utils/user-config.js';
|
|
4
|
+
import consola from 'consola';
|
|
5
|
+
import nock from 'nock';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import updateDestinationCommand from './update.js';
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vi.mock('@/utils/user-config.js');
|
|
10
|
+
vi.mock('@/utils/prompt.js');
|
|
11
|
+
vi.mock('@/services/authorization-service.js');
|
|
12
|
+
vi.mock('consola');
|
|
13
|
+
vi.mock('@/utils/environment.js', () => ({
|
|
14
|
+
isInteractive: () => false,
|
|
15
|
+
}));
|
|
16
|
+
describe('apps-destinations-update', () => {
|
|
17
|
+
const mockUserConfig = vi.mocked(userConfig);
|
|
18
|
+
const mockConsola = vi.mocked(consola);
|
|
19
|
+
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
20
|
+
const appId = 'app-123';
|
|
21
|
+
const destinationId = 'destination-456';
|
|
22
|
+
const testToken = 'test-token';
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
mockUserConfig.read.mockReturnValue({ token: testToken });
|
|
26
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
|
|
27
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
28
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
29
|
+
throw new Error(`Process exited with code ${code}`);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
nock.cleanAll();
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
it('should update the Firebase and Huawei fields', async () => {
|
|
37
|
+
const options = {
|
|
38
|
+
appId,
|
|
39
|
+
destinationId,
|
|
40
|
+
firebaseAppId: '1:123456789012:android:0a1b2c3d4e5f67890',
|
|
41
|
+
firebaseTesterGroup: ['qa-team,beta-testers'],
|
|
42
|
+
huaweiAppId: '112233445',
|
|
43
|
+
huaweiClientId: 'client-id',
|
|
44
|
+
huaweiClientSecret: 'client-secret',
|
|
45
|
+
};
|
|
46
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
47
|
+
.patch(`/v1/apps/${appId}/destinations/${destinationId}`, {
|
|
48
|
+
appId,
|
|
49
|
+
destinationId,
|
|
50
|
+
firebaseAppId: '1:123456789012:android:0a1b2c3d4e5f67890',
|
|
51
|
+
firebaseTesterGroups: ['qa-team', 'beta-testers'],
|
|
52
|
+
huaweiAppId: '112233445',
|
|
53
|
+
huaweiClientId: 'client-id',
|
|
54
|
+
huaweiClientSecret: 'client-secret',
|
|
55
|
+
})
|
|
56
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
57
|
+
.reply(200, { id: destinationId });
|
|
58
|
+
await updateDestinationCommand.action(options, undefined);
|
|
59
|
+
expect(scope.isDone()).toBe(true);
|
|
60
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Destination updated successfully.');
|
|
61
|
+
});
|
|
62
|
+
it('should update the Apple submission settings', async () => {
|
|
63
|
+
const options = {
|
|
64
|
+
appId,
|
|
65
|
+
destinationId,
|
|
66
|
+
appleBetaGroup: ['External Testers,QA'],
|
|
67
|
+
appleSubmitForReview: false,
|
|
68
|
+
appleReleaseType: 'manual',
|
|
69
|
+
appleRejectIfPossible: true,
|
|
70
|
+
};
|
|
71
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
72
|
+
.patch(`/v1/apps/${appId}/destinations/${destinationId}`, {
|
|
73
|
+
appId,
|
|
74
|
+
destinationId,
|
|
75
|
+
appleBetaGroups: ['External Testers', 'QA'],
|
|
76
|
+
appleRejectIfPossible: true,
|
|
77
|
+
appleReleaseType: 'manual',
|
|
78
|
+
appleSubmitForReview: false,
|
|
79
|
+
})
|
|
80
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
81
|
+
.reply(200, { id: destinationId });
|
|
82
|
+
await updateDestinationCommand.action(options, undefined);
|
|
83
|
+
expect(scope.isDone()).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it('should update the default language', async () => {
|
|
86
|
+
const options = { appId, destinationId, defaultLanguage: 'en-US' };
|
|
87
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
88
|
+
.patch(`/v1/apps/${appId}/destinations/${destinationId}`, { appId, destinationId, defaultLanguage: 'en-US' })
|
|
89
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
90
|
+
.reply(200, { id: destinationId });
|
|
91
|
+
await updateDestinationCommand.action(options, undefined);
|
|
92
|
+
expect(scope.isDone()).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
it('should clear the default language when `--default-language=` is passed', async () => {
|
|
95
|
+
const options = { appId, destinationId, defaultLanguage: '' };
|
|
96
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
97
|
+
.patch(`/v1/apps/${appId}/destinations/${destinationId}`, { appId, destinationId, defaultLanguage: null })
|
|
98
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
99
|
+
.reply(200, { id: destinationId });
|
|
100
|
+
await updateDestinationCommand.action(options, undefined);
|
|
101
|
+
expect(scope.isDone()).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
it('should parse the boolean Apple flags from `true` and `false` values', () => {
|
|
104
|
+
const schema = updateDestinationCommand.options?.schema;
|
|
105
|
+
const result = schema?.safeParse({ appleSubmitForReview: 'false', appleRejectIfPossible: true });
|
|
106
|
+
expect(result?.success).toBe(true);
|
|
107
|
+
expect(result?.data?.appleSubmitForReview).toBe(false);
|
|
108
|
+
expect(result?.data?.appleRejectIfPossible).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
it('should reject a boolean Apple flag with an invalid value', () => {
|
|
111
|
+
const schema = updateDestinationCommand.options?.schema;
|
|
112
|
+
const result = schema?.safeParse({ appleSubmitForReview: 'maybe' });
|
|
113
|
+
expect(result?.success).toBe(false);
|
|
114
|
+
expect(result?.error?.issues[0]?.message).toBe('Apple submit for review must be either `true` or `false`.');
|
|
115
|
+
});
|
|
116
|
+
it('should clear the Firebase tester groups when `--firebase-tester-group=` is passed', async () => {
|
|
117
|
+
const options = { appId, destinationId, firebaseTesterGroup: [''] };
|
|
118
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
119
|
+
.patch(`/v1/apps/${appId}/destinations/${destinationId}`, {
|
|
120
|
+
appId,
|
|
121
|
+
destinationId,
|
|
122
|
+
firebaseTesterGroups: [],
|
|
123
|
+
})
|
|
124
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
125
|
+
.reply(200, { id: destinationId });
|
|
126
|
+
await updateDestinationCommand.action(options, undefined);
|
|
127
|
+
expect(scope.isDone()).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
it('should not send the Firebase tester groups when `--firebase-tester-group` is not passed', async () => {
|
|
130
|
+
const options = { appId, destinationId, huaweiAppId: '112233445' };
|
|
131
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
132
|
+
.patch(`/v1/apps/${appId}/destinations/${destinationId}`, (body) => !('firebaseTesterGroups' in body))
|
|
133
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
134
|
+
.reply(200, { id: destinationId });
|
|
135
|
+
await updateDestinationCommand.action(options, undefined);
|
|
136
|
+
expect(scope.isDone()).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -12,6 +12,7 @@ import gitConnectionsService from '../../services/git-connections.js';
|
|
|
12
12
|
import { generateUniqueName, isNameTaken } from '../../utils/app-import.js';
|
|
13
13
|
import { parseAppflowExport } from '../../utils/appflow-export.js';
|
|
14
14
|
import { withAuth } from '../../utils/auth.js';
|
|
15
|
+
import { parseListOption } from '../../utils/cli-options.js';
|
|
15
16
|
import { isInteractive } from '../../utils/environment.js';
|
|
16
17
|
import { getMessageFromUnknownError, UserError } from '../../utils/error.js';
|
|
17
18
|
import { isReadable } from '../../utils/file.js';
|
|
@@ -150,10 +151,7 @@ export default defineCommand({
|
|
|
150
151
|
}),
|
|
151
152
|
});
|
|
152
153
|
const selectApps = async (apps, skippedApps, include) => {
|
|
153
|
-
const filters = include
|
|
154
|
-
?.flatMap((value) => value.split(','))
|
|
155
|
-
.map((value) => value.trim())
|
|
156
|
-
.filter((value) => value.length > 0);
|
|
154
|
+
const filters = parseListOption(include);
|
|
157
155
|
if (filters && filters.length > 0) {
|
|
158
156
|
const matchesFilter = (sourceId, sourceName) => filters.some((filter) => filter === sourceId || filter === sourceName);
|
|
159
157
|
for (const filter of filters) {
|
|
@@ -8,6 +8,11 @@ const SUPPORTED_MANIFEST_MAJOR_VERSION = 'v1';
|
|
|
8
8
|
const WEBHOOKS_DOCS_URL = 'https://capawesome.io/docs/cloud/webhooks/';
|
|
9
9
|
const LIVE_UPDATES_DOCS_URL = 'https://capawesome.io/docs/cloud/live-updates/';
|
|
10
10
|
const SUPPORTED_BUILD_TYPES = ['ad-hoc', 'app-store', 'debug', 'development', 'enterprise', 'release', 'simulator'];
|
|
11
|
+
// Appflow pins tool versions through these variables. Capawesome Cloud reads them under its own names.
|
|
12
|
+
const APPFLOW_VARIABLE_RENAMES = new Map([
|
|
13
|
+
['OVERRIDE_JAVA_VERSION', 'JAVA_VERSION'],
|
|
14
|
+
['OVERRIDE_NODE_VERSION', 'NODE_VERSION'],
|
|
15
|
+
]);
|
|
11
16
|
const manifestSchema = z.object({
|
|
12
17
|
version: z.string().nullish(),
|
|
13
18
|
});
|
|
@@ -190,14 +195,31 @@ const parseApp = (appFolder, detail, type) => {
|
|
|
190
195
|
channels: channels.map((channel) => channel.name),
|
|
191
196
|
configurations: parseConfigurations(nativeConfigs, notes),
|
|
192
197
|
destinations: destinations.map(({ id, ...destination }) => destination),
|
|
193
|
-
environments: environments.map((environment) => (
|
|
194
|
-
name: environment.name,
|
|
195
|
-
variables: Object.entries(environment.vars ?? {}).map(([key, value]) => ({ key, value })),
|
|
196
|
-
secrets: Object.entries(environment.secrets ?? {}).map(([key, value]) => ({ key, value })),
|
|
197
|
-
})),
|
|
198
|
+
environments: environments.map((environment) => parseEnvironment(environment, notes)),
|
|
198
199
|
repository: parseRepository(appFolder, notes),
|
|
199
200
|
};
|
|
200
201
|
};
|
|
202
|
+
const parseEnvironment = (environment, notes) => ({
|
|
203
|
+
name: environment.name,
|
|
204
|
+
variables: renameAppflowVariables(environment.name, toEnvironmentVariables(environment.vars), notes),
|
|
205
|
+
secrets: toEnvironmentVariables(environment.secrets),
|
|
206
|
+
});
|
|
207
|
+
const toEnvironmentVariables = (record) => Object.entries(record ?? {}).map(([key, value]) => ({ key, value }));
|
|
208
|
+
const renameAppflowVariables = (environmentName, variables, notes) => {
|
|
209
|
+
const keys = new Set(variables.map((variable) => variable.key));
|
|
210
|
+
return variables.map((variable) => {
|
|
211
|
+
const renamedKey = APPFLOW_VARIABLE_RENAMES.get(variable.key);
|
|
212
|
+
if (!renamedKey) {
|
|
213
|
+
return variable;
|
|
214
|
+
}
|
|
215
|
+
if (keys.has(renamedKey)) {
|
|
216
|
+
notes.push(`The environment \`${environmentName}\` contains both \`${variable.key}\` and \`${renamedKey}\`. Capawesome Cloud only reads \`${renamedKey}\`.`);
|
|
217
|
+
return variable;
|
|
218
|
+
}
|
|
219
|
+
notes.push(`The variable \`${variable.key}\` of the environment \`${environmentName}\` was renamed to \`${renamedKey}\`, the name Capawesome Cloud reads.`);
|
|
220
|
+
return { ...variable, key: renamedKey };
|
|
221
|
+
});
|
|
222
|
+
};
|
|
201
223
|
const parseRepository = (appFolder, notes) => {
|
|
202
224
|
const repoAssociation = parseJsonFileIfExists(path.join(appFolder, 'repo-association.json'), repoAssociationSchema);
|
|
203
225
|
if (!repoAssociation || Array.isArray(repoAssociation)) {
|
|
@@ -388,6 +388,39 @@ describe('appflow-export', () => {
|
|
|
388
388
|
expect(apps[0].notes).toContainEqual(expect.stringContaining(`\`${sanitizedUrl}\``));
|
|
389
389
|
expect(JSON.stringify(apps[0].notes)).not.toContain('secret-token');
|
|
390
390
|
});
|
|
391
|
+
it('should rename the Appflow tool version variables', async () => {
|
|
392
|
+
writeAppFiles('My App-6668c18c', {
|
|
393
|
+
'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
|
|
394
|
+
'environments.json': [
|
|
395
|
+
{
|
|
396
|
+
id: 1,
|
|
397
|
+
name: 'Production',
|
|
398
|
+
vars: { OVERRIDE_JAVA_VERSION: '21', OVERRIDE_NODE_VERSION: '22' },
|
|
399
|
+
secrets: null,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
});
|
|
403
|
+
const { apps } = await parseAppflowExport(exportDirectory);
|
|
404
|
+
expect(apps[0].environments[0].variables).toEqual([
|
|
405
|
+
{ key: 'JAVA_VERSION', value: '21' },
|
|
406
|
+
{ key: 'NODE_VERSION', value: '22' },
|
|
407
|
+
]);
|
|
408
|
+
expect(apps[0].notes).toContainEqual(expect.stringContaining('`OVERRIDE_NODE_VERSION`'));
|
|
409
|
+
});
|
|
410
|
+
it('should keep an Appflow tool version variable if the Capawesome Cloud name is taken', async () => {
|
|
411
|
+
writeAppFiles('My App-6668c18c', {
|
|
412
|
+
'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
|
|
413
|
+
'environments.json': [
|
|
414
|
+
{ id: 1, name: 'Production', vars: { NODE_VERSION: '20', OVERRIDE_NODE_VERSION: '22' }, secrets: null },
|
|
415
|
+
],
|
|
416
|
+
});
|
|
417
|
+
const { apps } = await parseAppflowExport(exportDirectory);
|
|
418
|
+
expect(apps[0].environments[0].variables).toEqual([
|
|
419
|
+
{ key: 'NODE_VERSION', value: '20' },
|
|
420
|
+
{ key: 'OVERRIDE_NODE_VERSION', value: '22' },
|
|
421
|
+
]);
|
|
422
|
+
expect(apps[0].notes).toContainEqual(expect.stringContaining('contains both'));
|
|
423
|
+
});
|
|
391
424
|
it('should skip an app with an invalid app detail file', async () => {
|
|
392
425
|
writeAppFiles('Invalid-66666666', {
|
|
393
426
|
'app-detail.json': 'not json',
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseListOption } from './cli-options.js';
|
|
3
|
+
describe('parseListOption', () => {
|
|
4
|
+
it('should return undefined if the option is not passed', () => {
|
|
5
|
+
expect(parseListOption(undefined)).toBeUndefined();
|
|
6
|
+
});
|
|
7
|
+
it('should split comma-separated values and trim them', () => {
|
|
8
|
+
expect(parseListOption(['qa-team, beta-testers', 'internal'])).toEqual(['qa-team', 'beta-testers', 'internal']);
|
|
9
|
+
});
|
|
10
|
+
it('should drop empty values', () => {
|
|
11
|
+
expect(parseListOption(['qa-team,, ', ''])).toEqual(['qa-team']);
|
|
12
|
+
});
|
|
13
|
+
it('should return an empty list if only an empty value is passed', () => {
|
|
14
|
+
expect(parseListOption([''])).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import consola from 'consola';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const releaseNotesFileSchema = z.record(z.string(), z.string()).refine((releaseNotes) => 'default' in releaseNotes);
|
|
5
|
+
export const parseReleaseNotes = async (options) => {
|
|
6
|
+
const { releaseNotes, releaseNotesFile, releaseNotesLocale } = options;
|
|
7
|
+
if (releaseNotesFile !== undefined) {
|
|
8
|
+
if (releaseNotes !== undefined || releaseNotesLocale !== undefined) {
|
|
9
|
+
consola.error('The --release-notes-file flag cannot be used together with --release-notes or --release-notes-locale.');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
return readReleaseNotesFile(releaseNotesFile);
|
|
13
|
+
}
|
|
14
|
+
if (releaseNotes === undefined && releaseNotesLocale === undefined) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
if (releaseNotes === undefined) {
|
|
18
|
+
consola.error('Release notes require a default text (--release-notes).');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
return { default: releaseNotes, ...parseLocalizedReleaseNotes(releaseNotesLocale ?? []) };
|
|
22
|
+
};
|
|
23
|
+
const parseLocalizedReleaseNotes = (entries) => {
|
|
24
|
+
const localizedReleaseNotes = {};
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const separatorIndex = entry.indexOf('=');
|
|
27
|
+
if (separatorIndex <= 0) {
|
|
28
|
+
consola.error(`Invalid release notes locale \`${entry}\`. Use the format \`<locale>=<text>\`.`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const locale = entry.slice(0, separatorIndex);
|
|
32
|
+
if (locale === 'default') {
|
|
33
|
+
consola.error('The locale `default` is reserved. Use --release-notes for the default text.');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
localizedReleaseNotes[locale] = entry.slice(separatorIndex + 1);
|
|
37
|
+
}
|
|
38
|
+
return localizedReleaseNotes;
|
|
39
|
+
};
|
|
40
|
+
const readReleaseNotesFile = async (filePath) => {
|
|
41
|
+
let json;
|
|
42
|
+
try {
|
|
43
|
+
json = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
consola.error(`The release notes file is missing or contains invalid JSON: ${filePath}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const result = releaseNotesFileSchema.safeParse(json);
|
|
50
|
+
if (!result.success) {
|
|
51
|
+
consola.error('The release notes file must contain a JSON object of texts by locale with a `default` key.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
return result.data;
|
|
55
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import consola from 'consola';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { parseReleaseNotes } from './release-notes.js';
|
|
7
|
+
vi.mock('consola');
|
|
8
|
+
describe('parseReleaseNotes', () => {
|
|
9
|
+
const mockConsola = vi.mocked(consola);
|
|
10
|
+
let tempDirectory;
|
|
11
|
+
let releaseNotesFile;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
15
|
+
throw new Error(`Process exited with code ${code}`);
|
|
16
|
+
});
|
|
17
|
+
tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'capawesome-cli-release-notes-'));
|
|
18
|
+
releaseNotesFile = path.join(tempDirectory, 'release-notes.json');
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
await fs.rm(tempDirectory, { force: true, recursive: true });
|
|
23
|
+
});
|
|
24
|
+
it('should return undefined if no release notes are passed', async () => {
|
|
25
|
+
await expect(parseReleaseNotes({})).resolves.toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
it('should use the release notes as the default text', async () => {
|
|
28
|
+
await expect(parseReleaseNotes({ releaseNotes: 'Bug fixes' })).resolves.toEqual({ default: 'Bug fixes' });
|
|
29
|
+
});
|
|
30
|
+
it('should add the translated release notes split at the first `=`', async () => {
|
|
31
|
+
const releaseNotes = await parseReleaseNotes({
|
|
32
|
+
releaseNotes: 'Bug fixes',
|
|
33
|
+
releaseNotesLocale: ['de-DE=Fehlerbehebungen', 'fr-FR=Corrections = bugs'],
|
|
34
|
+
});
|
|
35
|
+
expect(releaseNotes).toEqual({ default: 'Bug fixes', 'de-DE': 'Fehlerbehebungen', 'fr-FR': 'Corrections = bugs' });
|
|
36
|
+
});
|
|
37
|
+
it('should reject translated release notes without a default text', async () => {
|
|
38
|
+
await expect(parseReleaseNotes({ releaseNotesLocale: ['de-DE=Fehlerbehebungen'] })).rejects.toThrow('Process exited with code 1');
|
|
39
|
+
expect(mockConsola.error).toHaveBeenCalledWith('Release notes require a default text (--release-notes).');
|
|
40
|
+
});
|
|
41
|
+
it('should reject translated release notes without a locale', async () => {
|
|
42
|
+
await expect(parseReleaseNotes({ releaseNotes: 'Bug fixes', releaseNotesLocale: ['Fehlerbehebungen'] })).rejects.toThrow('Process exited with code 1');
|
|
43
|
+
expect(mockConsola.error).toHaveBeenCalledWith('Invalid release notes locale `Fehlerbehebungen`. Use the format `<locale>=<text>`.');
|
|
44
|
+
});
|
|
45
|
+
it('should reject translated release notes for the reserved `default` locale', async () => {
|
|
46
|
+
await expect(parseReleaseNotes({ releaseNotes: 'Bug fixes', releaseNotesLocale: ['default=Fehlerbehebungen'] })).rejects.toThrow('Process exited with code 1');
|
|
47
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The locale `default` is reserved. Use --release-notes for the default text.');
|
|
48
|
+
});
|
|
49
|
+
it('should read the release notes from a file', async () => {
|
|
50
|
+
await fs.writeFile(releaseNotesFile, JSON.stringify({ default: 'Bug fixes', 'de-DE': 'Fehlerbehebungen' }));
|
|
51
|
+
await expect(parseReleaseNotes({ releaseNotesFile })).resolves.toEqual({
|
|
52
|
+
default: 'Bug fixes',
|
|
53
|
+
'de-DE': 'Fehlerbehebungen',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
it('should reject a file combined with other release notes flags', async () => {
|
|
57
|
+
await expect(parseReleaseNotes({ releaseNotes: 'Bug fixes', releaseNotesFile })).rejects.toThrow('Process exited with code 1');
|
|
58
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The --release-notes-file flag cannot be used together with --release-notes or --release-notes-locale.');
|
|
59
|
+
});
|
|
60
|
+
it('should reject a file without a default text', async () => {
|
|
61
|
+
await fs.writeFile(releaseNotesFile, JSON.stringify({ 'de-DE': 'Fehlerbehebungen' }));
|
|
62
|
+
await expect(parseReleaseNotes({ releaseNotesFile })).rejects.toThrow('Process exited with code 1');
|
|
63
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The release notes file must contain a JSON object of texts by locale with a `default` key.');
|
|
64
|
+
});
|
|
65
|
+
it('should reject a file with invalid JSON', async () => {
|
|
66
|
+
await fs.writeFile(releaseNotesFile, 'Bug fixes');
|
|
67
|
+
await expect(parseReleaseNotes({ releaseNotesFile })).rejects.toThrow('Process exited with code 1');
|
|
68
|
+
expect(mockConsola.error).toHaveBeenCalledWith(`The release notes file is missing or contains invalid JSON: ${releaseNotesFile}`);
|
|
69
|
+
});
|
|
70
|
+
it('should reject an empty file path', async () => {
|
|
71
|
+
await expect(parseReleaseNotes({ releaseNotesFile: '' })).rejects.toThrow('Process exited with code 1');
|
|
72
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The release notes file is missing or contains invalid JSON: ');
|
|
73
|
+
});
|
|
74
|
+
});
|