@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.
- package/dist/commands/app/Init.js +1 -1
- package/dist/commands/app/Init.js.map +1 -1
- package/dist/oo-cli.manifest.json +1 -1
- package/package.json +10 -6
- package/src/commands/app/Init.ts +1 -1
- package/src/test/e2e/__tests__/accounts/accounts.test.ts +120 -0
- package/src/test/e2e/__tests__/availability/availability.test.ts +156 -0
- package/src/test/e2e/__tests__/directory/directory.test.ts +668 -0
- package/src/test/e2e/__tests__/jobs/jobs.test.ts +487 -0
- package/src/test/e2e/__tests__/review/review.test.ts +355 -0
- package/src/test/e2e/config/fixture-loader.ts +130 -0
- package/src/test/e2e/config/setup.ts +29 -0
- package/src/test/e2e/config/test-data-config.ts +27 -0
- package/src/test/e2e/config/test-data-helpers.ts +23 -0
- package/src/test/e2e/fixtures/baselines/accounts/whoami.txt +11 -0
- package/src/test/e2e/fixtures/baselines/accounts/whois.txt +4 -0
- package/src/test/e2e/fixtures/baselines/directory/info.txt +7 -0
- package/src/test/e2e/fixtures/baselines/directory/list.txt +4 -0
- package/src/test/e2e/fixtures/baselines/jobs/list.txt +4 -0
- package/src/test/e2e/fixtures/baselines/review/list.txt +4 -0
- package/src/test/e2e/lib/base-test.ts +150 -0
- package/src/test/e2e/lib/command-discovery.ts +324 -0
- package/src/test/e2e/utils/baseline-normalizer.ts +79 -0
- package/src/test/e2e/utils/cli-executor.ts +349 -0
- package/src/test/e2e/utils/command-registry.ts +99 -0
- package/src/test/e2e/utils/output-validator.ts +661 -0
- package/src/test/setup.ts +3 -1
- package/src/test/tsconfig.json +17 -0
- package/dist/test/setup.d.ts +0 -0
- package/dist/test/setup.js +0 -4
- 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();
|