@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,349 @@
1
+ import { execa } from 'execa';
2
+ import { join } from 'path';
3
+ import { EventEmitter } from 'events';
4
+
5
+ export interface CLIExecutionResult {
6
+ stdout: string;
7
+ stderr: string;
8
+ exitCode: number;
9
+ executionTime: number;
10
+ timedOut: boolean;
11
+ isTerminated: boolean;
12
+ signal?: string;
13
+ }
14
+
15
+ export interface CLIExecutionOptions {
16
+ timeout?: number;
17
+ env?: Record<string, string>;
18
+ cwd?: string;
19
+ input?: string;
20
+ killSignal?: NodeJS.Signals;
21
+ maxBuffer?: number;
22
+ encoding?: BufferEncoding;
23
+ }
24
+
25
+ export interface ProcessInfo {
26
+ pid?: number;
27
+ command: string;
28
+ args: string[];
29
+ startTime: number;
30
+ status: 'running' | 'completed' | 'killed' | 'timeout' | 'error';
31
+ endTime?: number;
32
+ duration?: number;
33
+ }
34
+
35
+ export class CLIExecutionError extends Error {
36
+ constructor(
37
+ message: string,
38
+ public readonly result: CLIExecutionResult,
39
+ public readonly processInfo: ProcessInfo
40
+ ) {
41
+ super(message);
42
+ this.name = 'CLIExecutionError';
43
+ }
44
+ }
45
+
46
+ export class CLIExecutor extends EventEmitter {
47
+ private readonly cliPath: string;
48
+ private readonly activeProcesses: Map<number, any> = new Map();
49
+ private readonly processInfoMap: Map<number, ProcessInfo> = new Map();
50
+ private processCounter = 0;
51
+ private readonly maxConcurrentProcesses: number;
52
+
53
+ constructor(cliPath?: string, maxConcurrentProcesses: number = 10) {
54
+ super();
55
+ this.cliPath = cliPath || (global as any).TEST_CONFIG?.CLI_PATH || join(__dirname, '../../../../bin/ocp.js');
56
+ this.maxConcurrentProcesses = maxConcurrentProcesses;
57
+ }
58
+
59
+ async execute(args: string[], options: CLIExecutionOptions = {}): Promise<CLIExecutionResult> {
60
+ // Check process limit
61
+ if (this.activeProcesses.size >= this.maxConcurrentProcesses) {
62
+ throw new Error(`Maximum concurrent processes limit reached (${this.maxConcurrentProcesses})`);
63
+ }
64
+
65
+ const processId = ++this.processCounter;
66
+ const startTime = Date.now();
67
+ const timeout = options.timeout || (global as any).TEST_CONFIG?.DEFAULT_TIMEOUT || 10000;
68
+
69
+ const processInfo: ProcessInfo = {
70
+ command: 'node',
71
+ args: [this.cliPath, ...args],
72
+ startTime,
73
+ status: 'running'
74
+ };
75
+
76
+ try {
77
+ // Prepare environment variables
78
+ const processEnv = this.prepareEnvironment(options.env);
79
+
80
+ const execaOptions = {
81
+ env: processEnv,
82
+ cwd: options.cwd || process.cwd(),
83
+ timeout,
84
+ input: options.input,
85
+ maxBuffer: options.maxBuffer || 1024 * 1024, // 1MB default
86
+ encoding: 'utf8' as const,
87
+ killSignal: options.killSignal || 'SIGTERM',
88
+ reject: false, // Don't throw on non-zero exit codes
89
+ };
90
+
91
+ const subprocess = execa('node', [this.cliPath, ...args], execaOptions);
92
+ processInfo.pid = subprocess.pid;
93
+
94
+ if (subprocess.pid) {
95
+ this.activeProcesses.set(processId, subprocess);
96
+ this.processInfoMap.set(processId, processInfo);
97
+ }
98
+
99
+ this.emit('processStarted', { ...processInfo });
100
+
101
+ const result = await subprocess;
102
+
103
+ // Clean up
104
+ this.activeProcesses.delete(processId);
105
+
106
+ const cliResult: CLIExecutionResult = {
107
+ stdout: result.stdout || '',
108
+ stderr: result.stderr || '',
109
+ exitCode: result.exitCode != null ? result.exitCode : 0,
110
+ executionTime: Math.round(result.durationMs || 0),
111
+ timedOut: result.timedOut || false,
112
+ isTerminated: result.isTerminated || false,
113
+ signal: result.signal
114
+ };
115
+
116
+ // Update process info
117
+ processInfo.status = 'completed';
118
+ processInfo.endTime = Date.now();
119
+ processInfo.duration = Math.round(result.durationMs || 0);
120
+
121
+ this.emit('processCompleted', { processInfo: { ...processInfo }, result: cliResult });
122
+
123
+ return cliResult;
124
+
125
+ } catch (error: any) {
126
+ // Clean up
127
+ this.activeProcesses.delete(processId);
128
+
129
+ const executionTime = error.durationMs ? Math.round(error.durationMs) : Date.now() - startTime;
130
+ processInfo.status = 'error';
131
+
132
+ let cliResult: CLIExecutionResult;
133
+
134
+ if (error.stdout !== undefined) {
135
+ // This is an ExecaError with process results
136
+ cliResult = {
137
+ stdout: error.stdout || '',
138
+ stderr: error.stderr || '',
139
+ exitCode: error.exitCode != null ? error.exitCode : -1,
140
+ executionTime,
141
+ timedOut: error.timedOut || false,
142
+ isTerminated: error.isTerminated || false,
143
+ signal: error.signal
144
+ };
145
+ } else {
146
+ // This is a different kind of error
147
+ cliResult = {
148
+ stdout: '',
149
+ stderr: error.message || '',
150
+ exitCode: -1,
151
+ executionTime,
152
+ timedOut: false,
153
+ isTerminated: false
154
+ };
155
+ }
156
+
157
+ // Update process info
158
+ processInfo.endTime = Date.now();
159
+ processInfo.duration = executionTime;
160
+
161
+ const cliError = new CLIExecutionError(
162
+ `Process execution failed: ${error.message}`,
163
+ cliResult,
164
+ processInfo
165
+ );
166
+
167
+ this.emit('processError', { processInfo: { ...processInfo }, error: cliError });
168
+
169
+ // For compatibility, return the result instead of throwing
170
+ return cliResult;
171
+ }
172
+ }
173
+
174
+ async executeCommand(command: string, options: CLIExecutionOptions = {}): Promise<CLIExecutionResult> {
175
+ const args = this.parseCommand(command);
176
+ return this.execute(args, options);
177
+ }
178
+
179
+ private prepareEnvironment(customEnv?: Record<string, string>): Record<string, string> {
180
+ // Start with current process environment, filtering out undefined values
181
+ const baseEnv = Object.fromEntries(
182
+ Object.entries(process.env).filter(([_, value]) => value !== undefined)
183
+ ) as Record<string, string>;
184
+
185
+ // Apply test-specific environment variables
186
+ const testEnv = {
187
+ NODE_ENV: 'test',
188
+ OCP_ENV: 'test',
189
+ // Disable colors for consistent output in tests
190
+ NO_COLOR: '1',
191
+ FORCE_COLOR: '0',
192
+ // Disable interactive prompts
193
+ CI: '1',
194
+ // Set consistent locale for predictable output
195
+ LC_ALL: 'C',
196
+ LANG: 'C'
197
+ };
198
+
199
+ // Merge environments with custom taking precedence
200
+ const mergedEnv = {
201
+ ...baseEnv,
202
+ ...testEnv,
203
+ ...customEnv
204
+ };
205
+
206
+ // Filter out any undefined or null values
207
+ return Object.fromEntries(
208
+ Object.entries(mergedEnv).filter(([_, value]) =>
209
+ value !== undefined && value !== null
210
+ )
211
+ );
212
+ }
213
+
214
+ private parseCommand(command: string): string[] {
215
+ // Simple command parsing - handles quoted arguments
216
+ const args: string[] = [];
217
+ let current = '';
218
+ let inQuotes = false;
219
+ let quoteChar = '';
220
+
221
+ for (let i = 0; i < command.length; i++) {
222
+ const char = command[i];
223
+
224
+ if ((char === '"' || char === "'") && !inQuotes) {
225
+ inQuotes = true;
226
+ quoteChar = char;
227
+ } else if (char === quoteChar && inQuotes) {
228
+ inQuotes = false;
229
+ quoteChar = '';
230
+ } else if (char === ' ' && !inQuotes) {
231
+ if (current.length > 0) {
232
+ args.push(current);
233
+ current = '';
234
+ }
235
+ } else {
236
+ current += char;
237
+ }
238
+ }
239
+
240
+ if (current.length > 0) {
241
+ args.push(current);
242
+ }
243
+
244
+ return args;
245
+ }
246
+
247
+ async killAllProcesses(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
248
+ if (this.activeProcesses.size === 0) {
249
+ return;
250
+ }
251
+
252
+ const killPromises = Array.from(this.activeProcesses.values()).map((subprocess: any) =>
253
+ subprocess.kill(signal).catch((error: any) => {
254
+ this.emit('warning', `Failed to kill process: ${error}`);
255
+ })
256
+ );
257
+
258
+ await Promise.all(killPromises);
259
+ this.activeProcesses.clear();
260
+
261
+ // Update all remaining process info to killed status
262
+ for (const processInfo of this.processInfoMap.values()) {
263
+ if (processInfo.status === 'running') {
264
+ processInfo.status = 'killed';
265
+ processInfo.endTime = Date.now();
266
+ processInfo.duration = processInfo.endTime - processInfo.startTime;
267
+ }
268
+ }
269
+ }
270
+
271
+ getActiveProcessCount(): number {
272
+ return this.activeProcesses.size;
273
+ }
274
+
275
+ getProcessInfo(): ProcessInfo[] {
276
+ return Array.from(this.processInfoMap.values()).filter(info =>
277
+ info.status === 'running'
278
+ );
279
+ }
280
+
281
+ getAllProcessInfo(): ProcessInfo[] {
282
+ return Array.from(this.processInfoMap.values());
283
+ }
284
+
285
+ getCompletedProcessInfo(): ProcessInfo[] {
286
+ return Array.from(this.processInfoMap.values()).filter(info =>
287
+ info.status !== 'running'
288
+ );
289
+ }
290
+
291
+ isProcessRunning(processId: number): boolean {
292
+ const processInfo = this.processInfoMap.get(processId);
293
+ return processInfo?.status === 'running' && this.activeProcesses.has(processId);
294
+ }
295
+
296
+ async waitForProcessCompletion(processId: number, timeoutMs: number = 30000): Promise<ProcessInfo | null> {
297
+ return new Promise((resolve) => {
298
+ const processInfo = this.processInfoMap.get(processId);
299
+ if (!processInfo || processInfo.status !== 'running') {
300
+ resolve(processInfo || null);
301
+ return;
302
+ }
303
+
304
+ const timeout = setTimeout(() => {
305
+ resolve(null);
306
+ }, timeoutMs);
307
+
308
+ const checkCompletion = () => {
309
+ const currentInfo = this.processInfoMap.get(processId);
310
+ if (currentInfo && currentInfo.status !== 'running') {
311
+ clearTimeout(timeout);
312
+ resolve(currentInfo);
313
+ }
314
+ };
315
+
316
+ // Check periodically
317
+ const interval = setInterval(checkCompletion, 100);
318
+
319
+ setTimeout(() => {
320
+ clearInterval(interval);
321
+ }, timeoutMs);
322
+ });
323
+ }
324
+
325
+ clearProcessHistory(): void {
326
+ // Remove completed process info to free memory
327
+ const completedIds = Array.from(this.processInfoMap.entries())
328
+ .filter(([_, info]) => info.status !== 'running')
329
+ .map(([id]) => id);
330
+
331
+ completedIds.forEach(id => this.processInfoMap.delete(id));
332
+ }
333
+
334
+ async destroy(): Promise<void> {
335
+ // Kill all active processes using the simplified method
336
+ await this.killAllProcesses('SIGTERM');
337
+
338
+ // Clear all maps
339
+ this.activeProcesses.clear();
340
+ this.processInfoMap.clear();
341
+
342
+ // Remove all listeners
343
+ this.removeAllListeners();
344
+ }
345
+
346
+ getMaxConcurrentProcesses(): number {
347
+ return this.maxConcurrentProcesses;
348
+ }
349
+ }
@@ -0,0 +1,99 @@
1
+ import { CommandDiscoveryService, CommandRegistry } from '../lib/command-discovery';
2
+
3
+ /**
4
+ * Utility class for working with the command registry
5
+ */
6
+ export class CommandRegistryUtil {
7
+ private static instance: CommandRegistryUtil;
8
+ private registry: CommandRegistry | null = null;
9
+ private discoveryService: CommandDiscoveryService;
10
+
11
+ private constructor() {
12
+ this.discoveryService = new CommandDiscoveryService();
13
+ }
14
+
15
+ public static getInstance(): CommandRegistryUtil {
16
+ if (!CommandRegistryUtil.instance) {
17
+ CommandRegistryUtil.instance = new CommandRegistryUtil();
18
+ }
19
+ return CommandRegistryUtil.instance;
20
+ }
21
+
22
+ /**
23
+ * Get the command registry (cached after first call)
24
+ */
25
+ public getRegistry(): CommandRegistry {
26
+ if (!this.registry) {
27
+ this.registry = this.discoveryService.discoverCommands();
28
+ }
29
+ return this.registry;
30
+ }
31
+
32
+ /**
33
+ * Get all read-only commands for testing
34
+ */
35
+ public getReadOnlyCommands() {
36
+ return this.getRegistry().readOnlyCommands;
37
+ }
38
+
39
+ /**
40
+ * Get all state-changing commands
41
+ */
42
+ public getStateChangingCommands() {
43
+ return this.getRegistry().stateChangingCommands;
44
+ }
45
+
46
+ /**
47
+ * Get commands for a specific namespace
48
+ */
49
+ public getCommandsForNamespace(namespace: string) {
50
+ return this.discoveryService.getCommandsForNamespace(namespace);
51
+ }
52
+
53
+ /**
54
+ * Get a specific command
55
+ */
56
+ public getCommand(namespace: string, commandName: string) {
57
+ return this.discoveryService.getCommand(namespace, commandName);
58
+ }
59
+
60
+ /**
61
+ * Generate command signature for CLI execution
62
+ */
63
+ public generateCommandSignature(namespace: string, commandName: string): string | null {
64
+ const command = this.getCommand(namespace, commandName);
65
+ if (!command) {
66
+ return null;
67
+ }
68
+ return this.discoveryService.generateCommandSignature(command);
69
+ }
70
+
71
+ /**
72
+ * Get all available namespaces
73
+ */
74
+ public getNamespaces(): string[] {
75
+ return this.getRegistry().namespaces;
76
+ }
77
+
78
+ /**
79
+ * Print registry statistics
80
+ */
81
+ public printStats(): void {
82
+ const registry = this.getRegistry();
83
+ console.log('Command Registry Statistics:');
84
+ console.log(` Total commands: ${registry.commands.length}`);
85
+ console.log(` Namespaces: ${registry.namespaces.length} (${registry.namespaces.join(', ')})`);
86
+ console.log(` Read-only commands: ${registry.readOnlyCommands.length}`);
87
+ console.log(` State-changing commands: ${registry.stateChangingCommands.length}`);
88
+ }
89
+
90
+ /**
91
+ * Refresh the registry (re-discover commands)
92
+ */
93
+ public refresh(): void {
94
+ this.registry = null;
95
+ }
96
+ }
97
+
98
+ // Export a singleton instance for convenience
99
+ export const commandRegistry = CommandRegistryUtil.getInstance();