@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,150 @@
1
+ import { CLIExecutor, CLIExecutionResult, CLIExecutionOptions } from '../utils/cli-executor';
2
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { BaselineNormalizer } from '../utils/baseline-normalizer';
5
+
6
+ export abstract class BaseE2ETest {
7
+ protected cliExecutor: CLIExecutor;
8
+ protected testConfig: any;
9
+
10
+ constructor() {
11
+ this.cliExecutor = new CLIExecutor();
12
+ this.testConfig = (global as any).TEST_CONFIG;
13
+ }
14
+
15
+ /**
16
+ * Cleanup method to be called after tests
17
+ */
18
+ async cleanup(): Promise<void> {
19
+ if (this.cliExecutor) {
20
+ await this.cliExecutor.destroy();
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Execute a CLI command and return the result
26
+ */
27
+ protected async executeCommand(command: string, options: CLIExecutionOptions = {}): Promise<CLIExecutionResult> {
28
+ return this.cliExecutor.executeCommand(command, options);
29
+ }
30
+
31
+ /**
32
+ * Execute CLI command with arguments array
33
+ */
34
+ protected async execute(args: string[], options: CLIExecutionOptions = {}): Promise<CLIExecutionResult> {
35
+ return this.cliExecutor.execute(args, options);
36
+ }
37
+
38
+ /**
39
+ * Load test fixture data
40
+ */
41
+ protected loadFixture(fixturePath: string): string {
42
+ const fullPath = join(this.testConfig.FIXTURES_PATH, fixturePath);
43
+ if (!existsSync(fullPath)) {
44
+ throw new Error(`Fixture not found: ${fullPath}`);
45
+ }
46
+ return readFileSync(fullPath, 'utf-8');
47
+ }
48
+
49
+ /**
50
+ * Load JSON fixture data
51
+ */
52
+ protected loadJsonFixture<T = any>(fixturePath: string): T {
53
+ const content = this.loadFixture(fixturePath);
54
+ return JSON.parse(content);
55
+ }
56
+
57
+ /**
58
+ * Load baseline data for comparison
59
+ */
60
+ protected loadBaseline(baselinePath: string): string {
61
+ const fullPath = join(this.testConfig.BASELINES_PATH, baselinePath);
62
+ if (!existsSync(fullPath)) {
63
+ throw new Error(`Baseline not found: ${fullPath}`);
64
+ }
65
+ return readFileSync(fullPath, 'utf-8');
66
+ }
67
+
68
+ /**
69
+ * Save baseline data (for baseline generation/updates)
70
+ */
71
+ protected saveBaseline(baselinePath: string, content: string): void {
72
+ const fullPath = join(this.testConfig.BASELINES_PATH, baselinePath);
73
+ const dir = dirname(fullPath);
74
+
75
+ if (!existsSync(dir)) {
76
+ mkdirSync(dir, { recursive: true });
77
+ }
78
+
79
+ writeFileSync(fullPath, content, 'utf-8');
80
+ }
81
+
82
+ /**
83
+ * Assert command executed successfully
84
+ */
85
+ protected assertSuccess(result: CLIExecutionResult, message?: string): void {
86
+ expect(result.exitCode).toBe(0);
87
+ if (message) {
88
+ expect(result.stderr).toBe('');
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Assert command failed with expected exit code
94
+ */
95
+ protected assertFailure(result: CLIExecutionResult, expectedExitCode?: number): void {
96
+ if (expectedExitCode !== undefined) {
97
+ expect(result.exitCode).toBe(expectedExitCode);
98
+ } else {
99
+ expect(result.exitCode).not.toBe(0);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Assert output contains expected text
105
+ */
106
+ protected assertOutputContains(result: CLIExecutionResult, expectedText: string): void {
107
+ expect(result.stdout).toContain(expectedText);
108
+ }
109
+
110
+ /**
111
+ * Assert output matches baseline with normalization
112
+ */
113
+ protected assertOutputMatchesBaseline(result: CLIExecutionResult, baselinePath: string, maxLines?: number): void {
114
+ const baseline = this.loadBaseline(baselinePath);
115
+ let actualOutput = result.stdout;
116
+
117
+ // If maxLines is specified, truncate both actual and baseline to first N lines
118
+ if (maxLines) {
119
+ actualOutput = this.truncateToLines(actualOutput, maxLines);
120
+ }
121
+
122
+ const normalizedActual = BaselineNormalizer.normalize(actualOutput);
123
+ const normalizedBaseline = BaselineNormalizer.normalize(baseline);
124
+
125
+ if (normalizedActual !== normalizedBaseline) {
126
+ console.log('Expected (baseline):');
127
+ console.log(normalizedBaseline);
128
+ console.log('\nActual:');
129
+ console.log(normalizedActual);
130
+ }
131
+
132
+ expect(normalizedActual).toBe(normalizedBaseline);
133
+ }
134
+
135
+ /**
136
+ * Truncate output to first N lines for baseline comparison
137
+ */
138
+ private truncateToLines(output: string, maxLines: number): string {
139
+ const lines = output.split('\n');
140
+ return lines.slice(0, maxLines).join('\n');
141
+ }
142
+
143
+ /**
144
+ * Generate or update a baseline file (for development/maintenance)
145
+ */
146
+ protected updateBaseline(result: CLIExecutionResult, baselinePath: string): void {
147
+ const normalizedOutput = BaselineNormalizer.normalize(result.stdout);
148
+ this.saveBaseline(baselinePath, normalizedOutput);
149
+ }
150
+ }
@@ -0,0 +1,324 @@
1
+ import { readdirSync, statSync, readFileSync } from 'fs';
2
+ import { join, extname } from 'path';
3
+
4
+ export interface CommandParameter {
5
+ name: string;
6
+ type: 'param' | 'option' | 'flag';
7
+ required: boolean;
8
+ help?: string;
9
+ shortFlag?: string;
10
+ }
11
+
12
+ export interface CommandMetadata {
13
+ namespace: string;
14
+ commandName: string;
15
+ methodName: string;
16
+ help?: string;
17
+ parameters: CommandParameter[];
18
+ filePath: string;
19
+ className: string;
20
+ isReadOnly: boolean;
21
+ }
22
+
23
+ export interface CommandRegistry {
24
+ commands: CommandMetadata[];
25
+ namespaces: string[];
26
+ readOnlyCommands: CommandMetadata[];
27
+ stateChangingCommands: CommandMetadata[];
28
+ }
29
+
30
+ export class CommandDiscoveryService {
31
+ private readonly commandsPath: string;
32
+ private readonly readOnlyPatterns = [
33
+ // Method name patterns that indicate read-only operations
34
+ /^(list|get|show|info|status|whoami|whois|search|find|view|display)$/i,
35
+ // Help-related patterns
36
+ /help/i,
37
+ // Status/info patterns
38
+ /^(runtime)?status$/i
39
+ ];
40
+
41
+ private readonly stateChangingPatterns = [
42
+ // Method name patterns that indicate state-changing operations
43
+ /^(create|init|add|update|edit|modify|delete|remove|destroy|terminate|trigger|install|uninstall|publish|unpublish|register|abandon|prepare|package|upgrade|set)$/i
44
+ ];
45
+
46
+ constructor(commandsPath: string = 'src/commands') {
47
+ this.commandsPath = commandsPath;
48
+ }
49
+
50
+ /**
51
+ * Discover all commands in the commands directory
52
+ */
53
+ public discoverCommands(): CommandRegistry {
54
+ const commands: CommandMetadata[] = [];
55
+
56
+ this.scanDirectory(this.commandsPath, commands);
57
+
58
+ const namespaces = [...new Set(commands.map(cmd => cmd.namespace))].sort();
59
+ const readOnlyCommands = commands.filter(cmd => cmd.isReadOnly);
60
+ const stateChangingCommands = commands.filter(cmd => !cmd.isReadOnly);
61
+
62
+ return {
63
+ commands,
64
+ namespaces,
65
+ readOnlyCommands,
66
+ stateChangingCommands
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Get commands for a specific namespace
72
+ */
73
+ public getCommandsForNamespace(namespace: string): CommandMetadata[] {
74
+ const registry = this.discoverCommands();
75
+ return registry.commands.filter(cmd => cmd.namespace === namespace);
76
+ }
77
+
78
+ /**
79
+ * Get command by namespace and command name
80
+ */
81
+ public getCommand(namespace: string, commandName: string): CommandMetadata | undefined {
82
+ const registry = this.discoverCommands();
83
+ return registry.commands.find(cmd =>
84
+ cmd.namespace === namespace && cmd.commandName === commandName
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Recursively scan directory for TypeScript command files
90
+ */
91
+ private scanDirectory(dirPath: string, commands: CommandMetadata[]): void {
92
+ try {
93
+ const entries = readdirSync(dirPath);
94
+
95
+ for (const entry of entries) {
96
+ const fullPath = join(dirPath, entry);
97
+ const stat = statSync(fullPath);
98
+
99
+ if (stat.isDirectory()) {
100
+ this.scanDirectory(fullPath, commands);
101
+ } else if (stat.isFile() && extname(entry) === '.ts') {
102
+ const commandMetadata = this.parseCommandFile(fullPath);
103
+ if (commandMetadata) {
104
+ commands.push(commandMetadata);
105
+ }
106
+ }
107
+ }
108
+ } catch (error) {
109
+ console.warn(`Warning: Could not scan directory ${dirPath}:`, error);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Parse a TypeScript file to extract command metadata
115
+ */
116
+ private parseCommandFile(filePath: string): CommandMetadata | null {
117
+ try {
118
+ const content = readFileSync(filePath, 'utf-8');
119
+
120
+ // Extract namespace from @namespace decorator
121
+ const namespaceMatch = content.match(/@namespace\(['"`]([^'"`]+)['"`]\)/);
122
+ if (!namespaceMatch) {
123
+ return null; // Not a command file if no namespace
124
+ }
125
+
126
+ const namespace = namespaceMatch[1];
127
+
128
+ // Extract class name
129
+ const classMatch = content.match(/export\s+class\s+(\w+)/);
130
+ if (!classMatch) {
131
+ return null;
132
+ }
133
+
134
+ const className = classMatch[1];
135
+
136
+ // Find method with @command decorator
137
+ const commandMethodMatch = content.match(/@command[\s\S]*?public\s+async\s+(\w+)\s*\(/);
138
+ if (!commandMethodMatch) {
139
+ return null;
140
+ }
141
+
142
+ const methodName = commandMethodMatch[1];
143
+ const commandName = methodName;
144
+
145
+ // Extract help text for the command method (look for @help before the @command method)
146
+ const commandMethodStart = content.indexOf(`public async ${methodName}`);
147
+ const commandSection = content.substring(0, commandMethodStart);
148
+ const lastHelpMatch = commandSection.match(/@help\(['"`]([^'"`]+)['"`]\)(?![\s\S]*@help)/);
149
+ const help = lastHelpMatch ? lastHelpMatch[1] : undefined;
150
+
151
+ // Extract parameters
152
+ const parameters = this.extractParameters(content);
153
+
154
+ // Determine if command is read-only
155
+ const isReadOnly = this.isReadOnlyCommand(methodName, help, content);
156
+
157
+ return {
158
+ namespace,
159
+ commandName,
160
+ methodName,
161
+ help,
162
+ parameters,
163
+ filePath,
164
+ className,
165
+ isReadOnly
166
+ };
167
+ } catch (error) {
168
+ console.warn(`Warning: Could not parse command file ${filePath}:`, error);
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Extract parameter information from command file content
175
+ */
176
+ private extractParameters(content: string): CommandParameter[] {
177
+ const parameters: CommandParameter[] = [];
178
+
179
+ // Find property declarations with their decorators
180
+ // Look for patterns like:
181
+ // @param
182
+ // @optional
183
+ // @help('...')
184
+ // private propertyName: type
185
+
186
+ const propertyBlocks = content.split(/(?=@(?:param|option|flag)(?:\(['"`][^'"`]*['"`]\))?(?:\s|$))/);
187
+
188
+ for (const block of propertyBlocks) {
189
+ if (!block.trim()) continue;
190
+
191
+ // Extract decorator type - look for the first occurrence at the start of the block
192
+ const decoratorMatch = block.match(/^@(param|option|flag)(?:\(['"`]([^'"`]*)['"`]\))?/);
193
+ if (!decoratorMatch) continue;
194
+
195
+ const [, decoratorType, shortFlag] = decoratorMatch;
196
+
197
+ // Check for @optional
198
+ const isOptional = block.includes('@optional');
199
+
200
+ // Extract help text
201
+ const helpMatch = block.match(/@help\(['"`]([^'"`]+)['"`]\)/);
202
+ const help = helpMatch ? helpMatch[1] : undefined;
203
+
204
+ // Extract property name
205
+ const propMatch = block.match(/(private|public)\s+(\w+)(?:\?)?(?:!)?:\s*/);
206
+ if (!propMatch) continue;
207
+
208
+ const [, , propertyName] = propMatch;
209
+
210
+ parameters.push({
211
+ name: propertyName,
212
+ type: decoratorType as 'param' | 'option' | 'flag',
213
+ required: !isOptional && decoratorType !== 'flag',
214
+ help,
215
+ shortFlag
216
+ });
217
+ }
218
+
219
+ return parameters;
220
+ }
221
+
222
+ /**
223
+ * Determine if a command is read-only based on method name, help text, and content analysis
224
+ */
225
+ private isReadOnlyCommand(methodName: string, help?: string, content?: string): boolean {
226
+ // Check method name against read-only patterns
227
+ if (this.readOnlyPatterns.some(pattern => pattern.test(methodName))) {
228
+ return true;
229
+ }
230
+
231
+ // Check method name against state-changing patterns
232
+ if (this.stateChangingPatterns.some(pattern => pattern.test(methodName))) {
233
+ return false;
234
+ }
235
+
236
+ // Check help text for read-only indicators
237
+ if (help) {
238
+ const readOnlyHelpPatterns = [
239
+ /^(list|show|display|get|view|info|status)/i,
240
+ /information/i,
241
+ /details/i
242
+ ];
243
+
244
+ const stateChangingHelpPatterns = [
245
+ /^(create|add|update|delete|remove|set|install|publish)/i,
246
+ /modify/i,
247
+ /change/i
248
+ ];
249
+
250
+ if (readOnlyHelpPatterns.some(pattern => pattern.test(help))) {
251
+ return true;
252
+ }
253
+
254
+ if (stateChangingHelpPatterns.some(pattern => pattern.test(help))) {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ // Analyze content for API calls that suggest read-only vs state-changing
260
+ if (content) {
261
+ // Look for HTTP methods or API calls that suggest read-only operations
262
+ const readOnlyContentPatterns = [
263
+ /\.get\(/i,
264
+ /\.search\(/i,
265
+ /\.whoami\(/i,
266
+ /\.list\(/i,
267
+ /\.info\(/i,
268
+ /\.status\(/i
269
+ ];
270
+
271
+ const stateChangingContentPatterns = [
272
+ /\.post\(/i,
273
+ /\.put\(/i,
274
+ /\.patch\(/i,
275
+ /\.delete\(/i,
276
+ /\.create\(/i,
277
+ /\.update\(/i,
278
+ /\.remove\(/i,
279
+ /\.install\(/i,
280
+ /\.publish\(/i
281
+ ];
282
+
283
+ if (readOnlyContentPatterns.some(pattern => pattern.test(content))) {
284
+ return true;
285
+ }
286
+
287
+ if (stateChangingContentPatterns.some(pattern => pattern.test(content))) {
288
+ return false;
289
+ }
290
+ }
291
+
292
+ // Default to read-only if uncertain (safer for testing)
293
+ return true;
294
+ }
295
+
296
+ /**
297
+ * Generate a full command signature for CLI execution
298
+ */
299
+ public generateCommandSignature(command: CommandMetadata): string {
300
+ let signature = `${command.namespace} ${command.commandName}`;
301
+
302
+ // Add required parameters
303
+ const requiredParams = command.parameters.filter(p => p.required && p.type === 'param');
304
+ for (const param of requiredParams) {
305
+ signature += ` <${param.name}>`;
306
+ }
307
+
308
+ // Add optional parameters
309
+ const optionalParams = command.parameters.filter(p => !p.required && p.type === 'param');
310
+ for (const param of optionalParams) {
311
+ signature += ` [${param.name}]`;
312
+ }
313
+
314
+ // Add options and flags
315
+ const options = command.parameters.filter(p => p.type === 'option');
316
+ const flags = command.parameters.filter(p => p.type === 'flag');
317
+
318
+ if (options.length > 0 || flags.length > 0) {
319
+ signature += ' [OPTIONS]';
320
+ }
321
+
322
+ return signature;
323
+ }
324
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Simple baseline normalization utility
3
+ * Replaces dynamic values with placeholders for consistent baseline comparison
4
+ */
5
+
6
+ export class BaselineNormalizer {
7
+ /**
8
+ * Normalize CLI output by replacing dynamic values with placeholders
9
+ */
10
+ static normalize(output: string): string {
11
+ let result = output;
12
+
13
+ // Replace deprecation warnings first
14
+ result = result.replace(/\(node:\d+\) \[DEP\d+\] DeprecationWarning: [^\n]+\n/g, '');
15
+ result = result.replace(/\(Use `node --trace-deprecation[^\n]+\n/g, '');
16
+
17
+ // Replace environment
18
+ result = result.replace(/Active environment: \w+/g, 'Active environment: <ENVIRONMENT>');
19
+
20
+ // Replace user-specific data
21
+ result = result.replace(/id: [a-f0-9-]{36}/g, 'id: <USER_ID>');
22
+ result = result.replace(/email: [^\s]+@[^\s]+/g, 'email: <USER_EMAIL>');
23
+ result = result.replace(/role: [^\n]+/g, 'role: <ROLE>');
24
+ result = result.replace(/githubUsername: [^\s]+/g, 'githubUsername: <GITHUB_USERNAME>');
25
+ result = result.replace(/createdAt: (?:'[^']+'|null)/g, "createdAt: <CREATED_DATE>");
26
+ result = result.replace(/vendor: [^\n]+/g, 'vendor: <VENDOR>');
27
+
28
+ // Replace vendor_apps - keep structure but normalize to single entry
29
+ result = result.replace(/vendor_apps:\s*\n(?: - id: [^\n]+\n?)+/g, 'vendor_apps:\n - id: <APP_ID>');
30
+
31
+ // Replace timestamps first (before other patterns)
32
+ result = result.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z/g, '<TIMESTAMP>');
33
+
34
+ // Replace job IDs (UUIDs)
35
+ result = result.replace(/[a-f0-9-]{36}/g, '<JOB_ID>');
36
+
37
+ // Replace version numbers (semantic versions including dev/beta/alpha)
38
+ result = result.replace(/\d+\.\d+\.\d+(?:-(?:dev|beta|alpha)\.\d+)?/g, '<VERSION>');
39
+
40
+ // Replace whois table data (normalize account info in table format)
41
+ // Match lines that start with digits followed by spaces (account ID)
42
+ result = result.replace(/^(\d+)(\s+)([A-Za-z0-9_-]+)(\s+)(.+?)(\s+)(\w+)$/gm,
43
+ '<ACCOUNT_ID>$2<TRACKER_ID>$4<ACCOUNT_NAME>$6<TARGET_PRODUCT>');
44
+
45
+ // Replace directory info fields
46
+ result = result.replace(/name\s+(.+)/g, 'name\t<APP_NAME>');
47
+ result = result.replace(/id\s+([a-zA-Z0-9_-]+)/g, 'id\t\t<APP_ID>');
48
+ result = result.replace(/created\s+<TIMESTAMP>/g, 'created\t<TIMESTAMP>');
49
+
50
+ // Replace directory list table data (preserve table structure)
51
+ // Only replace app IDs at start of lines that look like directory entries
52
+ result = result.replace(/^([a-zA-Z0-9_-]+)(\s+<VERSION>\s+)/gm, '<APP_ID>$2');
53
+
54
+ // Replace states and products
55
+ result = result.replace(/(PUBLISHED|UNPUBLISHED|BUILT|BUILD_FAILED|BUILDING)/g, '<STATE>');
56
+ result = result.replace(/(ODP|OCP|ZAIUS)(?=\s|$)/g, '<TARGET_PRODUCT>');
57
+ result = result.replace(/(NOT_REQUIRED|REQUIRED)/g, '<REQUIREMENT>');
58
+
59
+ // Replace any remaining tracker IDs that look like Base64
60
+ result = result.replace(/[A-Za-z0-9_-]{22}/g, '<TRACKER_ID>');
61
+
62
+ // Replace job statuses and review statuses
63
+ result = result.replace(/(COMPLETE|RUNNING|FAILED|PENDING|SUCCESS|ERROR|IN_REVIEW|APPROVED|REJECTED)/g, '<STATUS>');
64
+
65
+ // Replace function names (words before tracker IDs in job tables)
66
+ result = result.replace(/\w+(?=\s+<TRACKER_ID>)/g, '<FUNCTION>');
67
+
68
+ // Replace durations
69
+ result = result.replace(/\d+ms/g, '<DURATION>');
70
+ result = result.replace(/\d+s/g, '<DURATION>');
71
+ result = result.replace(/Duration: \d+/g, 'Duration: <DURATION>');
72
+
73
+ // Normalize whitespace
74
+ result = result.replace(/\s+$/gm, ''); // Remove trailing whitespace
75
+ result = result.replace(/\n{3,}/g, '\n\n'); // Normalize multiple newlines
76
+
77
+ return result.trim();
78
+ }
79
+ }