@curl-runner/cli 1.0.1
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/README.md +518 -0
- package/package.json +43 -0
- package/src/cli.ts +562 -0
- package/src/executor/request-executor.ts +490 -0
- package/src/parser/yaml.ts +99 -0
- package/src/types/config.ts +106 -0
- package/src/utils/curl-builder.ts +152 -0
- package/src/utils/logger.ts +501 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { RequestConfig } from '../types/config';
|
|
2
|
+
|
|
3
|
+
interface CurlMetrics {
|
|
4
|
+
response_code?: number;
|
|
5
|
+
http_code?: number;
|
|
6
|
+
time_total?: number;
|
|
7
|
+
size_download?: number;
|
|
8
|
+
time_namelookup?: number;
|
|
9
|
+
time_connect?: number;
|
|
10
|
+
time_appconnect?: number;
|
|
11
|
+
time_starttransfer?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Using class for organization, but could be refactored to functions
|
|
15
|
+
export class CurlBuilder {
|
|
16
|
+
static buildCommand(config: RequestConfig): string {
|
|
17
|
+
const parts: string[] = ['curl'];
|
|
18
|
+
|
|
19
|
+
parts.push('-X', config.method || 'GET');
|
|
20
|
+
|
|
21
|
+
parts.push('-w', '"\\n__CURL_METRICS_START__%{json}__CURL_METRICS_END__"');
|
|
22
|
+
|
|
23
|
+
if (config.headers) {
|
|
24
|
+
for (const [key, value] of Object.entries(config.headers)) {
|
|
25
|
+
parts.push('-H', `"${key}: ${value}"`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (config.auth) {
|
|
30
|
+
if (config.auth.type === 'basic' && config.auth.username && config.auth.password) {
|
|
31
|
+
parts.push('-u', `"${config.auth.username}:${config.auth.password}"`);
|
|
32
|
+
} else if (config.auth.type === 'bearer' && config.auth.token) {
|
|
33
|
+
parts.push('-H', `"Authorization: Bearer ${config.auth.token}"`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (config.body) {
|
|
38
|
+
const bodyStr = typeof config.body === 'string' ? config.body : JSON.stringify(config.body);
|
|
39
|
+
parts.push('-d', `'${bodyStr.replace(/'/g, "'\\''")}'`);
|
|
40
|
+
|
|
41
|
+
if (!config.headers?.['Content-Type']) {
|
|
42
|
+
parts.push('-H', '"Content-Type: application/json"');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (config.timeout) {
|
|
47
|
+
parts.push('--max-time', config.timeout.toString());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (config.followRedirects !== false) {
|
|
51
|
+
parts.push('-L');
|
|
52
|
+
if (config.maxRedirects) {
|
|
53
|
+
parts.push('--max-redirs', config.maxRedirects.toString());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (config.proxy) {
|
|
58
|
+
parts.push('-x', config.proxy);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (config.insecure) {
|
|
62
|
+
parts.push('-k');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (config.output) {
|
|
66
|
+
parts.push('-o', config.output);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
parts.push('-s', '-S');
|
|
70
|
+
|
|
71
|
+
let url = config.url;
|
|
72
|
+
if (config.params && Object.keys(config.params).length > 0) {
|
|
73
|
+
const queryString = new URLSearchParams(config.params).toString();
|
|
74
|
+
url += (url.includes('?') ? '&' : '?') + queryString;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
parts.push(`"${url}"`);
|
|
78
|
+
|
|
79
|
+
return parts.join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static async executeCurl(command: string): Promise<{
|
|
83
|
+
success: boolean;
|
|
84
|
+
status?: number;
|
|
85
|
+
headers?: Record<string, string>;
|
|
86
|
+
body?: string;
|
|
87
|
+
metrics?: CurlMetrics;
|
|
88
|
+
error?: string;
|
|
89
|
+
}> {
|
|
90
|
+
try {
|
|
91
|
+
const proc = Bun.spawn(['sh', '-c', command], {
|
|
92
|
+
stdout: 'pipe',
|
|
93
|
+
stderr: 'pipe',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const stdout = await new Response(proc.stdout).text();
|
|
97
|
+
const stderr = await new Response(proc.stderr).text();
|
|
98
|
+
|
|
99
|
+
await proc.exited;
|
|
100
|
+
|
|
101
|
+
if (proc.exitCode !== 0 && !stdout) {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
error: stderr || `Command failed with exit code ${proc.exitCode}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let responseBody = stdout;
|
|
109
|
+
let metrics: CurlMetrics = {};
|
|
110
|
+
|
|
111
|
+
const metricsMatch = stdout.match(/__CURL_METRICS_START__(.+?)__CURL_METRICS_END__/);
|
|
112
|
+
if (metricsMatch) {
|
|
113
|
+
responseBody = stdout.replace(/__CURL_METRICS_START__.+?__CURL_METRICS_END__/, '').trim();
|
|
114
|
+
try {
|
|
115
|
+
metrics = JSON.parse(metricsMatch[1]);
|
|
116
|
+
} catch (_e) {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const responseHeaders: Record<string, string> = {};
|
|
120
|
+
if (metrics.response_code) {
|
|
121
|
+
const headerLines = stderr.split('\n').filter((line) => line.includes(':'));
|
|
122
|
+
for (const line of headerLines) {
|
|
123
|
+
const [key, ...valueParts] = line.split(':');
|
|
124
|
+
if (key && valueParts.length > 0) {
|
|
125
|
+
responseHeaders[key.trim()] = valueParts.join(':').trim();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
status: metrics.response_code || metrics.http_code,
|
|
133
|
+
headers: responseHeaders,
|
|
134
|
+
body: responseBody,
|
|
135
|
+
metrics: {
|
|
136
|
+
duration: (metrics.time_total || 0) * 1000,
|
|
137
|
+
size: metrics.size_download,
|
|
138
|
+
dnsLookup: (metrics.time_namelookup || 0) * 1000,
|
|
139
|
+
tcpConnection: (metrics.time_connect || 0) * 1000,
|
|
140
|
+
tlsHandshake: (metrics.time_appconnect || 0) * 1000,
|
|
141
|
+
firstByte: (metrics.time_starttransfer || 0) * 1000,
|
|
142
|
+
download: (metrics.time_total || 0) * 1000,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return {
|
|
147
|
+
success: false,
|
|
148
|
+
error: error instanceof Error ? error.message : String(error),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionResult,
|
|
3
|
+
ExecutionSummary,
|
|
4
|
+
GlobalConfig,
|
|
5
|
+
RequestConfig,
|
|
6
|
+
} from '../types/config';
|
|
7
|
+
|
|
8
|
+
export class Logger {
|
|
9
|
+
private config: GlobalConfig['output'];
|
|
10
|
+
|
|
11
|
+
private readonly colors = {
|
|
12
|
+
reset: '\x1b[0m',
|
|
13
|
+
bright: '\x1b[1m',
|
|
14
|
+
dim: '\x1b[2m',
|
|
15
|
+
underscore: '\x1b[4m',
|
|
16
|
+
|
|
17
|
+
black: '\x1b[30m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
blue: '\x1b[34m',
|
|
22
|
+
magenta: '\x1b[35m',
|
|
23
|
+
cyan: '\x1b[36m',
|
|
24
|
+
white: '\x1b[37m',
|
|
25
|
+
|
|
26
|
+
bgBlack: '\x1b[40m',
|
|
27
|
+
bgRed: '\x1b[41m',
|
|
28
|
+
bgGreen: '\x1b[42m',
|
|
29
|
+
bgYellow: '\x1b[43m',
|
|
30
|
+
bgBlue: '\x1b[44m',
|
|
31
|
+
bgMagenta: '\x1b[45m',
|
|
32
|
+
bgCyan: '\x1b[46m',
|
|
33
|
+
bgWhite: '\x1b[47m',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
constructor(config: GlobalConfig['output'] = {}) {
|
|
37
|
+
this.config = {
|
|
38
|
+
verbose: false,
|
|
39
|
+
showHeaders: false,
|
|
40
|
+
showBody: true,
|
|
41
|
+
showMetrics: false,
|
|
42
|
+
format: 'pretty',
|
|
43
|
+
prettyLevel: 'standard',
|
|
44
|
+
...config,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private color(text: string, color: keyof typeof this.colors): string {
|
|
49
|
+
return `${this.colors[color]}${text}${this.colors.reset}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private getShortFilename(filePath: string): string {
|
|
53
|
+
return filePath.replace(/.*\//, '').replace('.yaml', '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private shouldShowOutput(): boolean {
|
|
57
|
+
if (this.config.format === 'raw') {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (this.config.format === 'pretty') {
|
|
61
|
+
return true; // Pretty format should always show output
|
|
62
|
+
}
|
|
63
|
+
return this.config.verbose !== false; // For other formats, respect verbose flag
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private shouldShowHeaders(): boolean {
|
|
67
|
+
if (this.config.format !== 'pretty') {
|
|
68
|
+
return this.config.showHeaders || false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const level = this.config.prettyLevel || 'standard';
|
|
72
|
+
switch (level) {
|
|
73
|
+
case 'minimal':
|
|
74
|
+
return false;
|
|
75
|
+
case 'standard':
|
|
76
|
+
return this.config.showHeaders || false;
|
|
77
|
+
case 'detailed':
|
|
78
|
+
return true;
|
|
79
|
+
default:
|
|
80
|
+
return this.config.showHeaders || false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private shouldShowBody(): boolean {
|
|
85
|
+
if (this.config.format !== 'pretty') {
|
|
86
|
+
return this.config.showBody !== false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const level = this.config.prettyLevel || 'standard';
|
|
90
|
+
switch (level) {
|
|
91
|
+
case 'minimal':
|
|
92
|
+
return false; // Minimal never shows body
|
|
93
|
+
case 'standard':
|
|
94
|
+
return this.config.showBody !== false;
|
|
95
|
+
case 'detailed':
|
|
96
|
+
return true; // Detailed always shows body
|
|
97
|
+
default:
|
|
98
|
+
return this.config.showBody !== false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private shouldShowMetrics(): boolean {
|
|
103
|
+
if (this.config.format !== 'pretty') {
|
|
104
|
+
return this.config.showMetrics || false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const level = this.config.prettyLevel || 'standard';
|
|
108
|
+
switch (level) {
|
|
109
|
+
case 'minimal':
|
|
110
|
+
return false; // Minimal never shows metrics
|
|
111
|
+
case 'standard':
|
|
112
|
+
return this.config.showMetrics || false;
|
|
113
|
+
case 'detailed':
|
|
114
|
+
return true; // Detailed always shows metrics
|
|
115
|
+
default:
|
|
116
|
+
return this.config.showMetrics || false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private shouldShowRequestDetails(): boolean {
|
|
121
|
+
if (this.config.format !== 'pretty') {
|
|
122
|
+
return this.config.verbose || false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const level = this.config.prettyLevel || 'standard';
|
|
126
|
+
switch (level) {
|
|
127
|
+
case 'minimal':
|
|
128
|
+
return false;
|
|
129
|
+
case 'standard':
|
|
130
|
+
return this.config.verbose || false;
|
|
131
|
+
case 'detailed':
|
|
132
|
+
return true;
|
|
133
|
+
default:
|
|
134
|
+
return this.config.verbose || false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private shouldShowSeparators(): boolean {
|
|
139
|
+
if (this.config.format !== 'pretty') {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const level = this.config.prettyLevel || 'standard';
|
|
144
|
+
switch (level) {
|
|
145
|
+
case 'minimal':
|
|
146
|
+
return false;
|
|
147
|
+
case 'standard':
|
|
148
|
+
return true;
|
|
149
|
+
case 'detailed':
|
|
150
|
+
return true;
|
|
151
|
+
default:
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private colorStatusCode(statusStr: string): string {
|
|
157
|
+
// For expected status codes in validation errors, use yellow to distinguish from red actual values
|
|
158
|
+
return this.color(statusStr, 'yellow');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private logValidationErrors(errorString: string): void {
|
|
162
|
+
// Check if this is a validation error with multiple parts (separated by ';')
|
|
163
|
+
const errors = errorString.split('; ');
|
|
164
|
+
|
|
165
|
+
if (errors.length === 1) {
|
|
166
|
+
// Single error - check if it's a status error for special formatting
|
|
167
|
+
const trimmedError = errors[0].trim();
|
|
168
|
+
const statusMatch = trimmedError.match(/^Expected status (.+?), got (.+)$/);
|
|
169
|
+
if (statusMatch) {
|
|
170
|
+
const [, expected, actual] = statusMatch;
|
|
171
|
+
const expectedStatus = this.colorStatusCode(expected.replace(' or ', '|'));
|
|
172
|
+
const actualStatus = this.color(actual, 'red'); // Always red for incorrect actual values
|
|
173
|
+
console.log(
|
|
174
|
+
` ${this.color('â', 'red')} ${this.color('Error:', 'red')} Expected ${this.color('status', 'yellow')} ${expectedStatus}, got ${actualStatus}`,
|
|
175
|
+
);
|
|
176
|
+
} else {
|
|
177
|
+
console.log(` ${this.color('â', 'red')} ${this.color('Error:', 'red')} ${trimmedError}`);
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// Multiple validation errors - show them nicely formatted
|
|
181
|
+
console.log(` ${this.color('â', 'red')} ${this.color('Validation Errors:', 'red')}`);
|
|
182
|
+
for (const error of errors) {
|
|
183
|
+
const trimmedError = error.trim();
|
|
184
|
+
if (trimmedError) {
|
|
185
|
+
// Parse different error formats for better formatting
|
|
186
|
+
if (trimmedError.startsWith('Expected ')) {
|
|
187
|
+
// Format 1: "Expected status 201, got 200"
|
|
188
|
+
const statusMatch = trimmedError.match(/^Expected status (.+?), got (.+)$/);
|
|
189
|
+
if (statusMatch) {
|
|
190
|
+
const [, expected, actual] = statusMatch;
|
|
191
|
+
const expectedStatus = this.colorStatusCode(expected.replace(' or ', '|'));
|
|
192
|
+
const actualStatus = this.color(actual, 'red'); // Always red for incorrect actual values
|
|
193
|
+
console.log(
|
|
194
|
+
` ${this.color('âĸ', 'red')} ${this.color('status', 'yellow')}: expected ${expectedStatus}, got ${actualStatus}`,
|
|
195
|
+
);
|
|
196
|
+
} else {
|
|
197
|
+
// Format 2: "Expected field to be value, got value"
|
|
198
|
+
const fieldMatch = trimmedError.match(/^Expected (.+?) to be (.+?), got (.+)$/);
|
|
199
|
+
if (fieldMatch) {
|
|
200
|
+
const [, field, expected, actual] = fieldMatch;
|
|
201
|
+
console.log(
|
|
202
|
+
` ${this.color('âĸ', 'red')} ${this.color(field, 'yellow')}: expected ${this.color(expected, 'green')}, got ${this.color(actual, 'red')}`,
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
console.log(` ${this.color('âĸ', 'red')} ${trimmedError}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
console.log(` ${this.color('âĸ', 'red')} ${trimmedError}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private formatJson(data: unknown): string {
|
|
217
|
+
if (this.config.format === 'raw') {
|
|
218
|
+
return typeof data === 'string' ? data : JSON.stringify(data);
|
|
219
|
+
}
|
|
220
|
+
if (this.config.format === 'json') {
|
|
221
|
+
return JSON.stringify(data);
|
|
222
|
+
}
|
|
223
|
+
return JSON.stringify(data, null, 2);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private formatDuration(ms: number): string {
|
|
227
|
+
if (ms < 1000) {
|
|
228
|
+
return `${ms.toFixed(0)}ms`;
|
|
229
|
+
}
|
|
230
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private formatSize(bytes: number | undefined): string {
|
|
234
|
+
if (!bytes) {
|
|
235
|
+
return '0 B';
|
|
236
|
+
}
|
|
237
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
238
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
239
|
+
return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private printSeparator(char: string = 'â', length: number = 60): void {
|
|
243
|
+
console.log(this.color(char.repeat(length), 'dim'));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
logExecutionStart(count: number, mode: string): void {
|
|
247
|
+
if (!this.shouldShowOutput()) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (this.shouldShowSeparators()) {
|
|
252
|
+
this.printSeparator('â');
|
|
253
|
+
console.log(this.color('đ CURL RUNNER', 'bright'));
|
|
254
|
+
console.log(this.color(`Executing ${count} request(s) in ${mode} mode`, 'cyan'));
|
|
255
|
+
this.printSeparator('â');
|
|
256
|
+
console.log();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
logRequestStart(config: RequestConfig, index: number): void {
|
|
261
|
+
if (!this.shouldShowOutput()) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const name = config.name || `Request #${index}`;
|
|
266
|
+
const sourceFile = config.sourceFile
|
|
267
|
+
? ` ${this.color(`[${this.getShortFilename(config.sourceFile)}]`, 'cyan')}`
|
|
268
|
+
: '';
|
|
269
|
+
console.log(this.color(`âļ ${name}`, 'bright') + sourceFile);
|
|
270
|
+
console.log(
|
|
271
|
+
` ${this.color(config.method || 'GET', 'yellow')} ${this.color(config.url, 'blue')}`,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
this.shouldShowRequestDetails() &&
|
|
276
|
+
config.headers &&
|
|
277
|
+
Object.keys(config.headers).length > 0
|
|
278
|
+
) {
|
|
279
|
+
console.log(this.color(' Headers:', 'dim'));
|
|
280
|
+
for (const [key, value] of Object.entries(config.headers)) {
|
|
281
|
+
console.log(` ${key}: ${value}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (this.shouldShowRequestDetails() && config.body) {
|
|
286
|
+
console.log(this.color(' Body:', 'dim'));
|
|
287
|
+
const bodyStr = this.formatJson(config.body);
|
|
288
|
+
for (const line of bodyStr.split('\n')) {
|
|
289
|
+
console.log(` ${line}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
logCommand(command: string): void {
|
|
295
|
+
if (this.shouldShowRequestDetails()) {
|
|
296
|
+
console.log(this.color(' Command:', 'dim'));
|
|
297
|
+
console.log(this.color(` ${command}`, 'dim'));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
logRetry(attempt: number, maxRetries: number): void {
|
|
302
|
+
console.log(this.color(` âģ Retry ${attempt}/${maxRetries}...`, 'yellow'));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
logRequestComplete(result: ExecutionResult): void {
|
|
306
|
+
// Handle raw format output - only show response body
|
|
307
|
+
if (this.config.format === 'raw') {
|
|
308
|
+
if (result.success && this.config.showBody && result.body) {
|
|
309
|
+
const bodyStr = this.formatJson(result.body);
|
|
310
|
+
console.log(bodyStr);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Handle JSON format output - structured JSON only
|
|
316
|
+
if (this.config.format === 'json') {
|
|
317
|
+
const jsonResult = {
|
|
318
|
+
request: {
|
|
319
|
+
name: result.request.name,
|
|
320
|
+
url: result.request.url,
|
|
321
|
+
method: result.request.method || 'GET',
|
|
322
|
+
},
|
|
323
|
+
success: result.success,
|
|
324
|
+
status: result.status,
|
|
325
|
+
...(this.shouldShowHeaders() && result.headers ? { headers: result.headers } : {}),
|
|
326
|
+
...(this.shouldShowBody() && result.body ? { body: result.body } : {}),
|
|
327
|
+
...(result.error ? { error: result.error } : {}),
|
|
328
|
+
...(this.shouldShowMetrics() && result.metrics ? { metrics: result.metrics } : {}),
|
|
329
|
+
};
|
|
330
|
+
console.log(JSON.stringify(jsonResult, null, 2));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Pretty format output (default behavior)
|
|
335
|
+
if (!this.shouldShowOutput()) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const statusColor = result.success ? 'green' : 'red';
|
|
340
|
+
const statusIcon = result.success ? 'â' : 'â';
|
|
341
|
+
|
|
342
|
+
console.log(
|
|
343
|
+
` ${this.color(statusIcon, statusColor)} ` +
|
|
344
|
+
`Status: ${this.color(String(result.status || 'ERROR'), statusColor)}`,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
if (result.error) {
|
|
348
|
+
this.logValidationErrors(result.error);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (this.shouldShowMetrics() && result.metrics) {
|
|
352
|
+
const metrics = result.metrics;
|
|
353
|
+
const parts = [`Duration: ${this.color(this.formatDuration(metrics.duration), 'cyan')}`];
|
|
354
|
+
|
|
355
|
+
if (metrics.size !== undefined) {
|
|
356
|
+
parts.push(`Size: ${this.color(this.formatSize(metrics.size), 'cyan')}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (this.shouldShowRequestDetails()) {
|
|
360
|
+
if (metrics.dnsLookup) {
|
|
361
|
+
parts.push(`DNS: ${this.formatDuration(metrics.dnsLookup)}`);
|
|
362
|
+
}
|
|
363
|
+
if (metrics.tcpConnection) {
|
|
364
|
+
parts.push(`TCP: ${this.formatDuration(metrics.tcpConnection)}`);
|
|
365
|
+
}
|
|
366
|
+
if (metrics.tlsHandshake) {
|
|
367
|
+
parts.push(`TLS: ${this.formatDuration(metrics.tlsHandshake)}`);
|
|
368
|
+
}
|
|
369
|
+
if (metrics.firstByte) {
|
|
370
|
+
parts.push(`TTFB: ${this.formatDuration(metrics.firstByte)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
console.log(` ${parts.join(' | ')}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (this.shouldShowHeaders() && result.headers && Object.keys(result.headers).length > 0) {
|
|
378
|
+
console.log(this.color(' Response Headers:', 'dim'));
|
|
379
|
+
for (const [key, value] of Object.entries(result.headers)) {
|
|
380
|
+
console.log(` ${key}: ${value}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (this.shouldShowBody() && result.body) {
|
|
385
|
+
console.log(this.color(' Response Body:', 'dim'));
|
|
386
|
+
const bodyStr = this.formatJson(result.body);
|
|
387
|
+
const lines = bodyStr.split('\n');
|
|
388
|
+
const maxLines = this.shouldShowRequestDetails() ? Infinity : 10;
|
|
389
|
+
for (const line of lines.slice(0, maxLines)) {
|
|
390
|
+
console.log(` ${line}`);
|
|
391
|
+
}
|
|
392
|
+
if (lines.length > maxLines) {
|
|
393
|
+
console.log(this.color(` ... (${lines.length - maxLines} more lines)`, 'dim'));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
console.log();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
logSummary(summary: ExecutionSummary, isGlobal: boolean = false): void {
|
|
401
|
+
// For raw format, don't show summary
|
|
402
|
+
if (this.config.format === 'raw') {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// For JSON format, output structured summary
|
|
407
|
+
if (this.config.format === 'json') {
|
|
408
|
+
const jsonSummary = {
|
|
409
|
+
summary: {
|
|
410
|
+
total: summary.total,
|
|
411
|
+
successful: summary.successful,
|
|
412
|
+
failed: summary.failed,
|
|
413
|
+
duration: summary.duration,
|
|
414
|
+
},
|
|
415
|
+
results: summary.results.map((result) => ({
|
|
416
|
+
request: {
|
|
417
|
+
name: result.request.name,
|
|
418
|
+
url: result.request.url,
|
|
419
|
+
method: result.request.method || 'GET',
|
|
420
|
+
},
|
|
421
|
+
success: result.success,
|
|
422
|
+
status: result.status,
|
|
423
|
+
...(this.shouldShowHeaders() && result.headers ? { headers: result.headers } : {}),
|
|
424
|
+
...(this.shouldShowBody() && result.body ? { body: result.body } : {}),
|
|
425
|
+
...(result.error ? { error: result.error } : {}),
|
|
426
|
+
...(this.shouldShowMetrics() && result.metrics ? { metrics: result.metrics } : {}),
|
|
427
|
+
})),
|
|
428
|
+
};
|
|
429
|
+
console.log(JSON.stringify(jsonSummary, null, 2));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Pretty format summary (default behavior)
|
|
434
|
+
if (!this.shouldShowOutput()) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (this.shouldShowSeparators()) {
|
|
439
|
+
this.printSeparator('â');
|
|
440
|
+
const title = isGlobal ? 'đ¯ OVERALL SUMMARY' : 'đ EXECUTION SUMMARY';
|
|
441
|
+
console.log(this.color(title, 'bright'));
|
|
442
|
+
this.printSeparator();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const successRate = ((summary.successful / summary.total) * 100).toFixed(1);
|
|
446
|
+
const statusColor =
|
|
447
|
+
summary.failed === 0 ? 'green' : summary.successful === 0 ? 'red' : 'yellow';
|
|
448
|
+
|
|
449
|
+
console.log(` Total Requests: ${this.color(String(summary.total), 'cyan')}`);
|
|
450
|
+
console.log(` Successful: ${this.color(String(summary.successful), 'green')}`);
|
|
451
|
+
console.log(` Failed: ${this.color(String(summary.failed), 'red')}`);
|
|
452
|
+
console.log(` Success Rate: ${this.color(`${successRate}%`, statusColor)}`);
|
|
453
|
+
console.log(` Total Duration: ${this.color(this.formatDuration(summary.duration), 'cyan')}`);
|
|
454
|
+
|
|
455
|
+
if (summary.failed > 0 && this.shouldShowRequestDetails()) {
|
|
456
|
+
console.log();
|
|
457
|
+
console.log(this.color(' Failed Requests:', 'red'));
|
|
458
|
+
summary.results
|
|
459
|
+
.filter((r) => !r.success)
|
|
460
|
+
.forEach((r) => {
|
|
461
|
+
const name = r.request.name || r.request.url;
|
|
462
|
+
console.log(` âĸ ${name}: ${r.error}`);
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (this.shouldShowSeparators()) {
|
|
467
|
+
this.printSeparator('â');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
logError(message: string): void {
|
|
472
|
+
console.error(this.color(`â ${message}`, 'red'));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
logWarning(message: string): void {
|
|
476
|
+
console.warn(this.color(`â ī¸ ${message}`, 'yellow'));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
logInfo(message: string): void {
|
|
480
|
+
console.log(this.color(`âšī¸ ${message}`, 'blue'));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
logSuccess(message: string): void {
|
|
484
|
+
console.log(this.color(`â
${message}`, 'green'));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
logFileHeader(fileName: string, requestCount: number): void {
|
|
488
|
+
if (!this.shouldShowOutput() || this.config.format !== 'pretty') {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const shortName = fileName.replace(/.*\//, '').replace('.yaml', '');
|
|
493
|
+
console.log();
|
|
494
|
+
this.printSeparator('â');
|
|
495
|
+
console.log(
|
|
496
|
+
this.color(`đ ${shortName}.yaml`, 'bright') +
|
|
497
|
+
this.color(` (${requestCount} request${requestCount === 1 ? '' : 's'})`, 'dim'),
|
|
498
|
+
);
|
|
499
|
+
this.printSeparator('â');
|
|
500
|
+
}
|
|
501
|
+
}
|