@optimizely/ocp-cli 1.2.13 → 1.2.14

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.
Files changed (31) hide show
  1. package/dist/commands/app/Init.js +1 -1
  2. package/dist/commands/app/Init.js.map +1 -1
  3. package/dist/oo-cli.manifest.json +1 -1
  4. package/package.json +10 -6
  5. package/src/commands/app/Init.ts +1 -1
  6. package/src/test/e2e/__tests__/accounts/accounts.test.ts +120 -0
  7. package/src/test/e2e/__tests__/availability/availability.test.ts +156 -0
  8. package/src/test/e2e/__tests__/directory/directory.test.ts +668 -0
  9. package/src/test/e2e/__tests__/jobs/jobs.test.ts +487 -0
  10. package/src/test/e2e/__tests__/review/review.test.ts +355 -0
  11. package/src/test/e2e/config/fixture-loader.ts +130 -0
  12. package/src/test/e2e/config/setup.ts +29 -0
  13. package/src/test/e2e/config/test-data-config.ts +27 -0
  14. package/src/test/e2e/config/test-data-helpers.ts +23 -0
  15. package/src/test/e2e/fixtures/baselines/accounts/whoami.txt +11 -0
  16. package/src/test/e2e/fixtures/baselines/accounts/whois.txt +4 -0
  17. package/src/test/e2e/fixtures/baselines/directory/info.txt +7 -0
  18. package/src/test/e2e/fixtures/baselines/directory/list.txt +4 -0
  19. package/src/test/e2e/fixtures/baselines/jobs/list.txt +4 -0
  20. package/src/test/e2e/fixtures/baselines/review/list.txt +4 -0
  21. package/src/test/e2e/lib/base-test.ts +150 -0
  22. package/src/test/e2e/lib/command-discovery.ts +324 -0
  23. package/src/test/e2e/utils/baseline-normalizer.ts +79 -0
  24. package/src/test/e2e/utils/cli-executor.ts +349 -0
  25. package/src/test/e2e/utils/command-registry.ts +99 -0
  26. package/src/test/e2e/utils/output-validator.ts +661 -0
  27. package/src/test/setup.ts +3 -1
  28. package/src/test/tsconfig.json +17 -0
  29. package/dist/test/setup.d.ts +0 -0
  30. package/dist/test/setup.js +0 -4
  31. package/dist/test/setup.js.map +0 -1
@@ -0,0 +1,355 @@
1
+ import { BaseE2ETest } from '../../lib/base-test';
2
+
3
+ class ReviewE2ETest extends BaseE2ETest {
4
+ // Review commands
5
+ async runListCommand(appId?: string) {
6
+ const args = ['review', 'list'];
7
+ if (appId) {
8
+ args.push(appId);
9
+ }
10
+ return this.execute(args);
11
+ }
12
+
13
+ async runOpenCommand(appVersion: string) {
14
+ return this.execute(['review', 'open', appVersion]);
15
+ }
16
+
17
+ async runOpenCommandWithoutVersion() {
18
+ return this.execute(['review', 'open']);
19
+ }
20
+
21
+ // Public wrapper methods to access protected assertions
22
+ public checkSuccess(result: any) {
23
+ this.assertSuccess(result);
24
+ }
25
+
26
+ public checkFailure(result: any) {
27
+ this.assertFailure(result);
28
+ }
29
+
30
+ public checkOutputContains(result: any, text: string) {
31
+ this.assertOutputContains(result, text);
32
+ }
33
+
34
+ public checkOutputMatchesBaseline(result: any, baselinePath: string, maxLines?: number) {
35
+ this.assertOutputMatchesBaseline(result, baselinePath, maxLines);
36
+ }
37
+
38
+ public clearHistory() {
39
+ this.cliExecutor.clearProcessHistory();
40
+ }
41
+
42
+ public executePublic(args: string[], options?: any) {
43
+ return this.execute(args, options);
44
+ }
45
+ }
46
+
47
+ describe('Review Commands E2E Tests', () => {
48
+ let testInstance: ReviewE2ETest;
49
+
50
+ beforeAll(() => {
51
+ testInstance = new ReviewE2ETest();
52
+ });
53
+
54
+ afterAll(async () => {
55
+ if (testInstance) {
56
+ await testInstance.cleanup();
57
+ }
58
+ });
59
+
60
+ afterEach(() => {
61
+ // Clean up process history after each test
62
+ if (testInstance) {
63
+ testInstance.clearHistory();
64
+ }
65
+ });
66
+
67
+ describe('review list', () => {
68
+ it('should list all reviews when no app ID is provided', async () => {
69
+ const result = await testInstance.runListCommand();
70
+
71
+ expect(result.exitCode).toBe(0);
72
+ expect(result.stdout).toBeTruthy();
73
+
74
+ // Check baseline format (first 5 lines - header + 1 entry)
75
+ testInstance.checkOutputMatchesBaseline(result, 'review/list.txt', 4);
76
+ }, 15000);
77
+
78
+ it('should filter reviews by app ID when provided', async () => {
79
+ const result = await testInstance.runListCommand('ocp_shakedown');
80
+
81
+ expect(result.exitCode).toBe(0);
82
+ expect(result.stdout).toBeTruthy();
83
+
84
+ // Check for table headers
85
+ expect(result.stdout).toMatch(/App ID/);
86
+ expect(result.stdout).toMatch(/Version/);
87
+ expect(result.stdout).toMatch(/Review Status/);
88
+ expect(result.stdout).toMatch(/Created At/);
89
+ expect(result.stdout).toMatch(/Updated At/);
90
+
91
+ // If there are results, they should only be for the specified app
92
+ const lines = result.stdout.split('\n');
93
+ const dataLines = lines.filter(line =>
94
+ line.trim() &&
95
+ !line.includes('App ID') &&
96
+ !line.includes('---') &&
97
+ line.includes('ocp_shakedown')
98
+ );
99
+
100
+ // Each data line should contain the app ID we filtered for
101
+ dataLines.forEach(line => {
102
+ if (line.trim()) {
103
+ expect(line).toMatch(/ocp_shakedown/);
104
+ }
105
+ });
106
+ }, 15000);
107
+
108
+ it('should handle non-existent app ID gracefully', async () => {
109
+ const result = await testInstance.runListCommand('nonexistent-app-id');
110
+
111
+ expect(result.exitCode).toBe(0);
112
+ expect(result.stdout).toBeTruthy();
113
+
114
+ // Should still show headers even with no results
115
+ expect(result.stdout).toMatch(/App ID/);
116
+ expect(result.stdout).toMatch(/Version/);
117
+ expect(result.stdout).toMatch(/Review Status/);
118
+ }, 15000);
119
+
120
+ it('should display review statuses correctly', async () => {
121
+ const result = await testInstance.runListCommand();
122
+
123
+ expect(result.exitCode).toBe(0);
124
+ expect(result.stdout).toBeTruthy();
125
+
126
+ // Should contain valid review statuses
127
+ const validStatuses = ['IN_REVIEW', 'APPROVED', 'REJECTED'];
128
+ const containsValidStatus = validStatuses.some(status =>
129
+ result.stdout.includes(status)
130
+ );
131
+
132
+ // If there are any reviews, they should have valid statuses
133
+ const hasDataLines = result.stdout.split('\n').some(line =>
134
+ line.trim() &&
135
+ !line.includes('App ID') &&
136
+ !line.includes('---') &&
137
+ line.includes('.') // Likely contains version numbers
138
+ );
139
+
140
+ if (hasDataLines) {
141
+ expect(containsValidStatus).toBe(true);
142
+ }
143
+ }, 15000);
144
+
145
+ it('should complete within reasonable time', async () => {
146
+ const result = await testInstance.runListCommand();
147
+
148
+ expect(result.executionTime).toBeLessThan(15000); // 15 seconds
149
+ expect(result.timedOut).toBe(false);
150
+ }, 20000);
151
+ });
152
+
153
+ describe('review open', () => {
154
+ it('should fail when app version parameter is missing', async () => {
155
+ const result = await testInstance.runOpenCommandWithoutVersion();
156
+
157
+ expect(result.exitCode).not.toBe(0);
158
+ expect(result.stderr).toBeTruthy();
159
+ expect(result.stderr).toMatch(/Missing required parameter.*appVersion/);
160
+ });
161
+
162
+ it('should handle non-existent app version gracefully', async () => {
163
+ const result = await testInstance.runOpenCommand('nonexistent-app@1.0.0');
164
+
165
+ expect(result.exitCode).not.toBe(0);
166
+ expect(result.stderr).toBeTruthy();
167
+ }, 15000);
168
+
169
+ it('should handle malformed app version parameter', async () => {
170
+ const result = await testInstance.runOpenCommand('invalid-format');
171
+
172
+ expect(result.exitCode).not.toBe(0);
173
+ expect(result.stderr).toBeTruthy();
174
+ }, 15000);
175
+
176
+ it('should complete within reasonable time', async () => {
177
+ // Use a dummy app version for timing test
178
+ const result = await testInstance.runOpenCommand('dummy-app@1.0.0');
179
+
180
+ expect(result.executionTime).toBeLessThan(10000); // 10 seconds
181
+ expect(result.timedOut).toBe(false);
182
+ }, 15000);
183
+
184
+ it('should attempt to open valid review URL', async () => {
185
+ // Get a real app version from the review list first
186
+ const listResult = await testInstance.runListCommand();
187
+
188
+ if (listResult.exitCode === 0 && listResult.stdout) {
189
+ const lines = listResult.stdout.split('\n');
190
+ const dataLine = lines.find(line =>
191
+ line.trim() &&
192
+ !line.includes('App ID') &&
193
+ !line.includes('---') &&
194
+ line.includes('.') // Likely contains version numbers
195
+ );
196
+
197
+ if (dataLine) {
198
+ const parts = dataLine.trim().split(/\s+/);
199
+ if (parts.length >= 2) {
200
+ const appId = parts[0];
201
+ const version = parts[1];
202
+ const appVersion = `${appId}@${version}`;
203
+
204
+ const result = await testInstance.runOpenCommand(appVersion);
205
+
206
+ // Command might succeed (if review exists) or fail (if not found)
207
+ expect(result.exitCode).toBeDefined();
208
+
209
+ if (result.exitCode === 0) {
210
+ // If successful, should mention opening or URL
211
+ expect(result.stdout).toMatch(/Review URL|Opened review|url/i);
212
+ } else {
213
+ // If failed, should have error message
214
+ expect(result.stderr).toBeTruthy();
215
+ }
216
+ } else {
217
+ // Skip test if no valid app version found
218
+ expect(true).toBe(true);
219
+ }
220
+ } else {
221
+ // Skip test if no data found
222
+ expect(true).toBe(true);
223
+ }
224
+ } else {
225
+ // Skip test if list command failed
226
+ expect(true).toBe(true);
227
+ }
228
+ }, 25000);
229
+ });
230
+
231
+ describe('Error Scenarios', () => {
232
+ it('should handle malformed command gracefully', async () => {
233
+ const result = await testInstance.executePublic(['review', 'invalid-command']);
234
+
235
+ expect(result.exitCode).not.toBe(0);
236
+ expect(result.stderr).toBeTruthy();
237
+ });
238
+
239
+ it('should handle unexpected parameters for list command', async () => {
240
+ const result = await testInstance.executePublic(['review', 'list', 'param1', 'param2']);
241
+ expect(result.exitCode).not.toBe(0);
242
+ });
243
+ });
244
+
245
+ describe('Performance Tests', () => {
246
+ it('should complete review list within reasonable time', async () => {
247
+ const result = await testInstance.runListCommand();
248
+
249
+ expect(result.executionTime).toBeLessThan(15000); // 15 seconds
250
+ expect(result.executionTime).toBeGreaterThan(0);
251
+ }, 20000);
252
+
253
+ it('should complete review open within reasonable time', async () => {
254
+ const result = await testInstance.runOpenCommand('dummy-app@1.0.0');
255
+
256
+ expect(result.executionTime).toBeLessThan(10000); // 10 seconds
257
+ expect(result.executionTime).toBeGreaterThan(0);
258
+ }, 15000);
259
+
260
+ it('should handle multiple concurrent list requests', async () => {
261
+ const promises = Array(3).fill(null).map(() => testInstance.runListCommand());
262
+ const results = await Promise.all(promises);
263
+
264
+ results.forEach(result => {
265
+ expect(result.exitCode).toBe(0);
266
+ expect(result.stdout).toBeTruthy();
267
+ expect(result.executionTime).toBeLessThan(20000);
268
+ });
269
+ }, 30000);
270
+ });
271
+
272
+ describe('Output Validation', () => {
273
+ it('should format output properly', async () => {
274
+ const result = await testInstance.runListCommand();
275
+
276
+ expect(result.exitCode).toBe(0);
277
+ expect(result.stdout).toBeTruthy();
278
+
279
+ // Output should be tabular format
280
+ const lines = result.stdout.trim().split('\n').filter(line => line.trim());
281
+ expect(lines.length).toBeGreaterThanOrEqual(1); // At least header
282
+
283
+ // Header line should contain all expected columns
284
+ const headerLine = lines.find(line =>
285
+ line.includes('App ID') &&
286
+ line.includes('Version') &&
287
+ line.includes('Review Status')
288
+ );
289
+ expect(headerLine).toBeTruthy();
290
+ }, 15000);
291
+
292
+ it('should not contain sensitive information in output', async () => {
293
+ const result = await testInstance.runListCommand();
294
+
295
+ expect(result.exitCode).toBe(0);
296
+ expect(result.stdout).toBeTruthy();
297
+
298
+ // Output should not contain sensitive data patterns
299
+ expect(result.stdout).not.toMatch(/password/i);
300
+ expect(result.stdout).not.toMatch(/token/i);
301
+ expect(result.stdout).not.toMatch(/secret/i);
302
+ expect(result.stdout).not.toMatch(/api.*key/i);
303
+ }, 10000);
304
+
305
+ it('should handle empty results gracefully', async () => {
306
+ const result = await testInstance.runListCommand('definitely-nonexistent-app-12345');
307
+
308
+ expect(result.exitCode).toBe(0);
309
+ expect(result.stdout).toBeTruthy();
310
+
311
+ // Should still show headers
312
+ expect(result.stdout).toMatch(/App ID/);
313
+ expect(result.stdout).toMatch(/Version/);
314
+ expect(result.stdout).toMatch(/Review Status/);
315
+ }, 15000);
316
+ });
317
+
318
+ describe('Data Consistency', () => {
319
+ it('should return consistent results across multiple calls', async () => {
320
+ const result1 = await testInstance.runListCommand();
321
+ const result2 = await testInstance.runListCommand();
322
+
323
+ expect(result1.exitCode).toBe(result2.exitCode);
324
+ expect(result1.exitCode).toBe(0);
325
+
326
+ // Results should be consistent (allowing for minor timing differences)
327
+ const lines1 = result1.stdout.split('\n').filter(line => line.trim());
328
+ const lines2 = result2.stdout.split('\n').filter(line => line.trim());
329
+
330
+ // Should have same number of header lines at minimum
331
+ expect(lines1.length).toBeGreaterThan(0);
332
+ expect(lines2.length).toBeGreaterThan(0);
333
+ }, 25000);
334
+
335
+ it('should filter results correctly when app ID is provided', async () => {
336
+ const allResult = await testInstance.runListCommand();
337
+ const filteredResult = await testInstance.runListCommand('ocp_shakedown');
338
+
339
+ expect(allResult.exitCode).toBe(0);
340
+ expect(filteredResult.exitCode).toBe(0);
341
+
342
+ // Filtered results should be subset of all results (or empty)
343
+ const filteredLines = filteredResult.stdout.split('\n').filter(line =>
344
+ line.trim() && !line.includes('App ID') && !line.includes('---') && line.includes('ocp_shakedown')
345
+ );
346
+
347
+ // Each filtered line should be for the specified app
348
+ filteredLines.forEach(line => {
349
+ if (line.trim()) {
350
+ expect(line).toMatch(/ocp_shakedown/);
351
+ }
352
+ });
353
+ }, 25000);
354
+ });
355
+ });
@@ -0,0 +1,130 @@
1
+ import { join } from 'path';
2
+ import { readFileSync, existsSync } from 'fs';
3
+
4
+ export interface TestDataFixture {
5
+ name: string;
6
+ description?: string;
7
+ data: any;
8
+ type: 'json' | 'yaml' | 'text' | 'binary';
9
+ }
10
+
11
+ export interface FixtureLoader {
12
+ loadFixture(name: string): TestDataFixture;
13
+ loadFixtures(pattern: string): TestDataFixture[];
14
+ getFixturePath(name: string): string;
15
+ fixtureExists(name: string): boolean;
16
+ }
17
+
18
+ export class FileSystemFixtureLoader implements FixtureLoader {
19
+ private readonly fixturesPath: string;
20
+ private readonly cache: Map<string, TestDataFixture> = new Map();
21
+
22
+ constructor(fixturesPath?: string) {
23
+ this.fixturesPath = fixturesPath || join(__dirname, '../fixtures');
24
+ }
25
+
26
+ loadFixture(name: string): TestDataFixture {
27
+ // Check cache first
28
+ if (this.cache.has(name)) {
29
+ return this.cache.get(name)!;
30
+ }
31
+
32
+ const fixturePath = this.getFixturePath(name);
33
+
34
+ if (!this.fixtureExists(name)) {
35
+ throw new Error(`Fixture not found: ${name} at path ${fixturePath}`);
36
+ }
37
+
38
+ try {
39
+ const content = readFileSync(fixturePath, 'utf-8');
40
+ const fixture = this.parseFixtureContent(name, content);
41
+
42
+ // Cache the fixture
43
+ this.cache.set(name, fixture);
44
+
45
+ return fixture;
46
+ } catch (error) {
47
+ throw new Error(`Failed to load fixture ${name}: ${error}`);
48
+ }
49
+ }
50
+
51
+ loadFixtures(_pattern: string): TestDataFixture[] {
52
+ // For now, implement basic pattern matching
53
+ // In a real implementation, you might use glob patterns
54
+ const fixtures: TestDataFixture[] = [];
55
+
56
+ // This is a simplified implementation
57
+ // You could extend this to use glob patterns or other matching logic
58
+ return fixtures;
59
+ }
60
+
61
+ getFixturePath(name: string): string {
62
+ // Support different file extensions
63
+ const extensions = ['.json', '.yaml', '.yml', '.txt', '.xml'];
64
+
65
+ for (const ext of extensions) {
66
+ const fullPath = join(this.fixturesPath, `${name}${ext}`);
67
+ if (existsSync(fullPath)) {
68
+ return fullPath;
69
+ }
70
+ }
71
+
72
+ // Default to .json if no extension provided
73
+ return join(this.fixturesPath, name.includes('.') ? name : `${name}.json`);
74
+ }
75
+
76
+ fixtureExists(name: string): boolean {
77
+ return existsSync(this.getFixturePath(name));
78
+ }
79
+
80
+ private parseFixtureContent(name: string, content: string): TestDataFixture {
81
+ const fixturePath = this.getFixturePath(name);
82
+ const extension = fixturePath.split('.').pop()?.toLowerCase();
83
+
84
+ let data: any;
85
+ let type: TestDataFixture['type'];
86
+
87
+ switch (extension) {
88
+ case 'json':
89
+ try {
90
+ data = JSON.parse(content);
91
+ type = 'json';
92
+ } catch (error) {
93
+ throw new Error(`Invalid JSON in fixture ${name}: ${error}`);
94
+ }
95
+ break;
96
+
97
+ case 'yaml':
98
+ case 'yml':
99
+ // For YAML parsing, you might want to add a YAML library like 'js-yaml'
100
+ // For now, treat as text
101
+ data = content;
102
+ type = 'yaml';
103
+ break;
104
+
105
+ case 'txt':
106
+ case 'md':
107
+ data = content;
108
+ type = 'text';
109
+ break;
110
+
111
+ default:
112
+ data = content;
113
+ type = 'text';
114
+ }
115
+
116
+ return {
117
+ name,
118
+ data,
119
+ type,
120
+ description: `Test fixture: ${name}`
121
+ };
122
+ }
123
+
124
+ clearCache(): void {
125
+ this.cache.clear();
126
+ }
127
+ }
128
+
129
+ // Global fixture loader instance
130
+ export const fixtureLoader = new FileSystemFixtureLoader();
@@ -0,0 +1,29 @@
1
+ import { join } from 'path';
2
+ import { afterEach, vi } from 'vitest';
3
+ import { CLIExecutor } from '../utils/cli-executor';
4
+
5
+ // Global test configuration - just the essentials
6
+ const TEST_CONFIG = {
7
+ CLI_PATH: join(__dirname, '../../../../bin/ocp.js'),
8
+ FIXTURES_PATH: join(__dirname, '../fixtures'),
9
+ BASELINES_PATH: join(__dirname, '../fixtures/baselines'),
10
+ DEFAULT_TIMEOUT: 20000,
11
+ LONG_TIMEOUT: 30000
12
+ };
13
+
14
+ // Make test config globally available
15
+ (global as any).TEST_CONFIG = TEST_CONFIG;
16
+
17
+ beforeAll(() => {
18
+ new CLIExecutor().execute(['env', 'set', 'staging']);
19
+ })
20
+
21
+ afterEach(() => {
22
+ // Cleanup after each test
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ // Global error handler for unhandled rejections
27
+ process.on('unhandledRejection', (reason, promise) => {
28
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
29
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Test Data Configuration
3
+ *
4
+ * Centralized configuration for test data parameters used across E2E tests.
5
+ */
6
+
7
+ export interface TestDataConfig {
8
+ appId: string;
9
+ appVersion: string;
10
+ trackerId: string;
11
+ }
12
+
13
+ /**
14
+ * Default test data configuration
15
+ */
16
+ export const TEST_DATA: TestDataConfig = {
17
+ appId: process.env.OCP_TEST_APP_ID || "hub_shakedown",
18
+ appVersion: process.env.OCP_TEST_APP_VERSION || "2.2.0",
19
+ trackerId: process.env.OCP_TEST_TRACKER_ID || "Z6jCndly_E2WzpvDkcKBKA",
20
+ };
21
+
22
+ /**
23
+ * Get the current test data configuration
24
+ */
25
+ export function getTestData(): TestDataConfig {
26
+ return TEST_DATA;
27
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Test Data Helper Utilities
3
+ */
4
+
5
+ import { TEST_DATA } from './test-data-config';
6
+
7
+ export const testData = {
8
+ get appId(): string {
9
+ return TEST_DATA.appId;
10
+ },
11
+
12
+ get appVersion(): string {
13
+ return TEST_DATA.appVersion;
14
+ },
15
+
16
+ get trackerId(): string {
17
+ return TEST_DATA.trackerId;
18
+ },
19
+
20
+ get appVersionString(): string {
21
+ return `${TEST_DATA.appId}@${TEST_DATA.appVersion}`;
22
+ }
23
+ };
@@ -0,0 +1,11 @@
1
+ Active environment: <ENVIRONMENT>
2
+ personal_apps: []
3
+ id: <USER_ID>
4
+ email: <USER_EMAIL>
5
+ role: <ROLE>
6
+ githubUsername: <GITHUB_USERNAME>
7
+ accounts: []
8
+ createdAt: <CREATED_DATE>
9
+ vendor: <VENDOR>
10
+ vendor_apps:
11
+ - id: <APP_ID>
@@ -0,0 +1,4 @@
1
+ Active environment: <ENVIRONMENT>
2
+
3
+ id tracker Id name target product
4
+ <ACCOUNT_ID> <TRACKER_ID> <ACCOUNT_NAME> <TARGET_PRODUCT>
@@ -0,0 +1,7 @@
1
+ Active environment: <ENVIRONMENT>
2
+ General
3
+ id <APP_ID>
4
+ name <APP_NAME>
5
+ created <TIMESTAMP>
6
+ Versions
7
+ <VERSION> <STATE> <REQUIREMENT>
@@ -0,0 +1,4 @@
1
+ Active environment: <ENVIRONMENT>
2
+
3
+ App ID Version Target Product State Created At Updated At
4
+ <APP_ID> <VERSION> <TARGET_PRODUCT> <STATE> <TIMESTAMP> <TIMESTAMP>
@@ -0,0 +1,4 @@
1
+ Active environment: <ENVIRONMENT>
2
+
3
+ Job ID Version Job Function Tracker ID Status Created At Updated At Duration
4
+ <JOB_ID> <VERSION> <FUNCTION> <TRACKER_ID> <STATUS> <TIMESTAMP> <TIMESTAMP> <DURATION>
@@ -0,0 +1,4 @@
1
+ Active environment: <ENVIRONMENT>
2
+
3
+ App ID Version Review Status Created At Updated At
4
+ <APP_ID> <VERSION> <STATUS> <TIMESTAMP> <TIMESTAMP>