mpx-api 1.0.0
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/CONTRIBUTING.md +71 -0
- package/LICENSE +21 -0
- package/README.md +345 -0
- package/bin/mpx-api.js +52 -0
- package/examples/github-api.yaml +35 -0
- package/examples/jsonplaceholder.yaml +52 -0
- package/examples/openapi-petstore.yaml +88 -0
- package/package.json +51 -0
- package/src/commands/collection.js +175 -0
- package/src/commands/docs.js +140 -0
- package/src/commands/env.js +152 -0
- package/src/commands/history.js +46 -0
- package/src/commands/load.js +161 -0
- package/src/commands/mock.js +178 -0
- package/src/commands/request.js +91 -0
- package/src/commands/test.js +55 -0
- package/src/lib/assertion.js +170 -0
- package/src/lib/collection-runner.js +66 -0
- package/src/lib/config.js +71 -0
- package/src/lib/history.js +51 -0
- package/src/lib/http-client.js +179 -0
- package/src/lib/license.js +35 -0
- package/src/lib/output.js +172 -0
- package/src/lib/template.js +63 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { request } from 'undici';
|
|
2
|
+
import { formatError, formatSuccess, formatInfo } from '../lib/output.js';
|
|
3
|
+
import { requireProLicense } from '../lib/license.js';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
export function registerLoadCommand(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('load <url>')
|
|
9
|
+
.description('Run load test against URL (Pro)')
|
|
10
|
+
.option('--rps <number>', 'Requests per second', '10')
|
|
11
|
+
.option('-d, --duration <seconds>', 'Test duration in seconds', '10')
|
|
12
|
+
.option('-H, --header <header...>', 'Add request headers')
|
|
13
|
+
.option('--method <method>', 'HTTP method', 'GET')
|
|
14
|
+
.action(async (url, options) => {
|
|
15
|
+
try {
|
|
16
|
+
requireProLicense('Load testing');
|
|
17
|
+
|
|
18
|
+
const rps = parseInt(options.rps);
|
|
19
|
+
const duration = parseInt(options.duration);
|
|
20
|
+
const method = options.method.toUpperCase();
|
|
21
|
+
|
|
22
|
+
formatInfo(`Starting load test: ${rps} req/s for ${duration}s`);
|
|
23
|
+
formatInfo(`Target: ${method} ${url}`);
|
|
24
|
+
console.log('');
|
|
25
|
+
|
|
26
|
+
const headers = {};
|
|
27
|
+
if (options.header) {
|
|
28
|
+
for (const header of options.header) {
|
|
29
|
+
const [key, ...valueParts] = header.split(':');
|
|
30
|
+
headers[key.trim()] = valueParts.join(':').trim();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const results = await runLoadTest(url, {
|
|
35
|
+
rps,
|
|
36
|
+
duration,
|
|
37
|
+
method,
|
|
38
|
+
headers,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
displayLoadTestResults(results);
|
|
42
|
+
|
|
43
|
+
} catch (err) {
|
|
44
|
+
formatError(err);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function runLoadTest(url, options) {
|
|
51
|
+
const { rps, duration, method, headers } = options;
|
|
52
|
+
const interval = 1000 / rps; // ms between requests
|
|
53
|
+
const totalRequests = rps * duration;
|
|
54
|
+
|
|
55
|
+
const results = {
|
|
56
|
+
total: totalRequests,
|
|
57
|
+
success: 0,
|
|
58
|
+
failed: 0,
|
|
59
|
+
responseTimes: [],
|
|
60
|
+
errors: {},
|
|
61
|
+
statusCodes: {},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const startTime = Date.now();
|
|
65
|
+
const promises = [];
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < totalRequests; i++) {
|
|
68
|
+
const delay = i * interval;
|
|
69
|
+
|
|
70
|
+
promises.push(
|
|
71
|
+
new Promise((resolve) => {
|
|
72
|
+
setTimeout(async () => {
|
|
73
|
+
const reqStart = Date.now();
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const response = await request(url, { method, headers });
|
|
77
|
+
const reqTime = Date.now() - reqStart;
|
|
78
|
+
|
|
79
|
+
await response.body.arrayBuffer(); // Consume body
|
|
80
|
+
|
|
81
|
+
results.success++;
|
|
82
|
+
results.responseTimes.push(reqTime);
|
|
83
|
+
results.statusCodes[response.statusCode] = (results.statusCodes[response.statusCode] || 0) + 1;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
results.failed++;
|
|
86
|
+
const errorType = err.code || err.message;
|
|
87
|
+
results.errors[errorType] = (results.errors[errorType] || 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resolve();
|
|
91
|
+
}, delay);
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await Promise.all(promises);
|
|
97
|
+
|
|
98
|
+
results.totalTime = Date.now() - startTime;
|
|
99
|
+
|
|
100
|
+
// Calculate percentiles
|
|
101
|
+
if (results.responseTimes.length > 0) {
|
|
102
|
+
results.responseTimes.sort((a, b) => a - b);
|
|
103
|
+
results.p50 = percentile(results.responseTimes, 50);
|
|
104
|
+
results.p95 = percentile(results.responseTimes, 95);
|
|
105
|
+
results.p99 = percentile(results.responseTimes, 99);
|
|
106
|
+
results.min = results.responseTimes[0];
|
|
107
|
+
results.max = results.responseTimes[results.responseTimes.length - 1];
|
|
108
|
+
results.avg = results.responseTimes.reduce((a, b) => a + b, 0) / results.responseTimes.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function percentile(arr, p) {
|
|
115
|
+
const index = Math.ceil((arr.length * p) / 100) - 1;
|
|
116
|
+
return arr[index];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function displayLoadTestResults(results) {
|
|
120
|
+
console.log('');
|
|
121
|
+
console.log(chalk.bold.cyan('Load Test Results:'));
|
|
122
|
+
console.log('');
|
|
123
|
+
|
|
124
|
+
console.log(chalk.bold('Summary:'));
|
|
125
|
+
console.log(` Total requests: ${results.total}`);
|
|
126
|
+
console.log(` ${chalk.green('Successful:')} ${results.success}`);
|
|
127
|
+
if (results.failed > 0) {
|
|
128
|
+
console.log(` ${chalk.red('Failed:')} ${results.failed}`);
|
|
129
|
+
}
|
|
130
|
+
console.log(` Duration: ${(results.totalTime / 1000).toFixed(2)}s`);
|
|
131
|
+
console.log(` Actual RPS: ${(results.total / (results.totalTime / 1000)).toFixed(2)}`);
|
|
132
|
+
console.log('');
|
|
133
|
+
|
|
134
|
+
if (results.responseTimes.length > 0) {
|
|
135
|
+
console.log(chalk.bold('Response Times:'));
|
|
136
|
+
console.log(` Min: ${results.min}ms`);
|
|
137
|
+
console.log(` Max: ${results.max}ms`);
|
|
138
|
+
console.log(` Avg: ${results.avg.toFixed(2)}ms`);
|
|
139
|
+
console.log(` P50: ${results.p50}ms`);
|
|
140
|
+
console.log(` P95: ${results.p95}ms`);
|
|
141
|
+
console.log(` P99: ${results.p99}ms`);
|
|
142
|
+
console.log('');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (Object.keys(results.statusCodes).length > 0) {
|
|
146
|
+
console.log(chalk.bold('Status Codes:'));
|
|
147
|
+
for (const [code, count] of Object.entries(results.statusCodes)) {
|
|
148
|
+
const color = code.startsWith('2') ? 'green' : code.startsWith('4') || code.startsWith('5') ? 'red' : 'white';
|
|
149
|
+
console.log(` ${chalk[color](code)}: ${count}`);
|
|
150
|
+
}
|
|
151
|
+
console.log('');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (Object.keys(results.errors).length > 0) {
|
|
155
|
+
console.log(chalk.bold.red('Errors:'));
|
|
156
|
+
for (const [error, count] of Object.entries(results.errors)) {
|
|
157
|
+
console.log(` ${error}: ${count}`);
|
|
158
|
+
}
|
|
159
|
+
console.log('');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { formatError, formatInfo, formatSuccess } from '../lib/output.js';
|
|
5
|
+
import { requireProLicense } from '../lib/license.js';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
export function registerMockCommands(program) {
|
|
9
|
+
const mock = program
|
|
10
|
+
.command('mock')
|
|
11
|
+
.description('Mock server commands');
|
|
12
|
+
|
|
13
|
+
mock
|
|
14
|
+
.command('start [spec]')
|
|
15
|
+
.description('Start mock server from OpenAPI spec (Pro)')
|
|
16
|
+
.option('-p, --port <port>', 'Server port', '3000')
|
|
17
|
+
.option('-d, --delay <ms>', 'Response delay in milliseconds', '0')
|
|
18
|
+
.option('--cors', 'Enable CORS headers')
|
|
19
|
+
.action((spec, options) => {
|
|
20
|
+
try {
|
|
21
|
+
requireProLicense('Mock server');
|
|
22
|
+
|
|
23
|
+
if (!spec) {
|
|
24
|
+
formatError(new Error('OpenAPI spec file required'));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!existsSync(spec)) {
|
|
29
|
+
formatError(new Error(`Spec file not found: ${spec}`));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const content = readFileSync(spec, 'utf8');
|
|
34
|
+
let openApiSpec;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (spec.endsWith('.yaml') || spec.endsWith('.yml')) {
|
|
38
|
+
openApiSpec = parseYaml(content);
|
|
39
|
+
} else {
|
|
40
|
+
openApiSpec = JSON.parse(content);
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
formatError(new Error(`Failed to parse spec: ${err.message}`));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const port = parseInt(options.port);
|
|
48
|
+
const delay = parseInt(options.delay);
|
|
49
|
+
|
|
50
|
+
const server = createMockServer(openApiSpec, {
|
|
51
|
+
delay,
|
|
52
|
+
cors: options.cors,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
server.listen(port, () => {
|
|
56
|
+
formatSuccess(`Mock server started on http://localhost:${port}`);
|
|
57
|
+
formatInfo(`Serving from: ${spec}`);
|
|
58
|
+
if (delay > 0) {
|
|
59
|
+
formatInfo(`Response delay: ${delay}ms`);
|
|
60
|
+
}
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(chalk.gray('Press Ctrl+C to stop'));
|
|
63
|
+
console.log('');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
} catch (err) {
|
|
67
|
+
formatError(err);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createMockServer(spec, options = {}) {
|
|
74
|
+
const { delay = 0, cors = false } = options;
|
|
75
|
+
const paths = spec.paths || {};
|
|
76
|
+
|
|
77
|
+
return createServer((req, res) => {
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
|
|
80
|
+
// CORS headers
|
|
81
|
+
if (cors) {
|
|
82
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
83
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
84
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
85
|
+
|
|
86
|
+
if (req.method === 'OPTIONS') {
|
|
87
|
+
res.writeHead(204);
|
|
88
|
+
res.end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Find matching path
|
|
94
|
+
let matchedPath = null;
|
|
95
|
+
let matchedOperation = null;
|
|
96
|
+
|
|
97
|
+
for (const [path, operations] of Object.entries(paths)) {
|
|
98
|
+
// Simple path matching (no parameters for now)
|
|
99
|
+
const pattern = path.replace(/\{[^}]+\}/g, '[^/]+');
|
|
100
|
+
const regex = new RegExp(`^${pattern}$`);
|
|
101
|
+
|
|
102
|
+
if (regex.test(req.url)) {
|
|
103
|
+
const method = req.method.toLowerCase();
|
|
104
|
+
if (operations[method]) {
|
|
105
|
+
matchedPath = path;
|
|
106
|
+
matchedOperation = operations[method];
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Apply delay
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
if (!matchedOperation) {
|
|
115
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
116
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
117
|
+
logRequest(req, 404, Date.now() - start);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Get first success response
|
|
122
|
+
const responses = matchedOperation.responses || {};
|
|
123
|
+
const successCode = Object.keys(responses).find(code => code.startsWith('2')) || '200';
|
|
124
|
+
const response = responses[successCode];
|
|
125
|
+
|
|
126
|
+
// Generate mock response
|
|
127
|
+
const mockData = generateMockFromSchema(response.content?.['application/json']?.schema || {});
|
|
128
|
+
|
|
129
|
+
res.writeHead(parseInt(successCode), { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify(mockData, null, 2));
|
|
131
|
+
|
|
132
|
+
logRequest(req, successCode, Date.now() - start);
|
|
133
|
+
}, delay);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function generateMockFromSchema(schema) {
|
|
138
|
+
if (!schema) return {};
|
|
139
|
+
|
|
140
|
+
if (schema.type === 'object') {
|
|
141
|
+
const obj = {};
|
|
142
|
+
for (const [key, propSchema] of Object.entries(schema.properties || {})) {
|
|
143
|
+
obj[key] = generateMockFromSchema(propSchema);
|
|
144
|
+
}
|
|
145
|
+
return obj;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (schema.type === 'array') {
|
|
149
|
+
return [generateMockFromSchema(schema.items || {})];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (schema.example !== undefined) {
|
|
153
|
+
return schema.example;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Default values by type
|
|
157
|
+
const defaults = {
|
|
158
|
+
string: 'example',
|
|
159
|
+
number: 42,
|
|
160
|
+
integer: 42,
|
|
161
|
+
boolean: true,
|
|
162
|
+
null: null,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return defaults[schema.type] || null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function logRequest(req, status, duration) {
|
|
169
|
+
const statusColor = status >= 200 && status < 300 ? 'green' :
|
|
170
|
+
status >= 400 ? 'yellow' : 'white';
|
|
171
|
+
|
|
172
|
+
console.log(
|
|
173
|
+
chalk[statusColor](status.toString().padEnd(4)) +
|
|
174
|
+
chalk.bold(req.method.padEnd(7)) +
|
|
175
|
+
chalk.gray(`${duration}ms`.padEnd(8)) +
|
|
176
|
+
req.url
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { HttpClient } from '../lib/http-client.js';
|
|
2
|
+
import { formatResponse, formatError } from '../lib/output.js';
|
|
3
|
+
import { saveToHistory } from '../lib/history.js';
|
|
4
|
+
|
|
5
|
+
export function registerRequestCommands(program) {
|
|
6
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
7
|
+
|
|
8
|
+
for (const method of methods) {
|
|
9
|
+
program
|
|
10
|
+
.command(`${method} <url>`)
|
|
11
|
+
.description(`Send a ${method.toUpperCase()} request`)
|
|
12
|
+
.option('-H, --header <header...>', 'Add request headers (key:value or "key: value")')
|
|
13
|
+
.option('-j, --json <data>', 'Send JSON data (automatically sets Content-Type)')
|
|
14
|
+
.option('-d, --data <data>', 'Send raw request body')
|
|
15
|
+
.option('-v, --verbose', 'Show response headers')
|
|
16
|
+
.option('-q, --quiet', 'Only output response body')
|
|
17
|
+
.option('--no-follow', 'Do not follow redirects')
|
|
18
|
+
.option('--no-verify', 'Skip SSL certificate verification')
|
|
19
|
+
.option('--timeout <ms>', 'Request timeout in milliseconds', '30000')
|
|
20
|
+
.action(async (url, options) => {
|
|
21
|
+
try {
|
|
22
|
+
const client = new HttpClient({
|
|
23
|
+
followRedirects: options.follow,
|
|
24
|
+
verifySsl: options.verify,
|
|
25
|
+
timeout: parseInt(options.timeout),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const requestOptions = {
|
|
29
|
+
headers: parseHeaders(options.header || []),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Handle JSON data
|
|
33
|
+
if (options.json) {
|
|
34
|
+
try {
|
|
35
|
+
requestOptions.json = JSON.parse(options.json);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
formatError(new Error(`Invalid JSON: ${err.message}`));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
} else if (options.data) {
|
|
41
|
+
requestOptions.body = options.data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await client.request(method, url, requestOptions);
|
|
45
|
+
|
|
46
|
+
// Save to history
|
|
47
|
+
saveToHistory(
|
|
48
|
+
{
|
|
49
|
+
method: method.toUpperCase(),
|
|
50
|
+
url,
|
|
51
|
+
headers: requestOptions.headers,
|
|
52
|
+
body: requestOptions.json || requestOptions.body,
|
|
53
|
+
},
|
|
54
|
+
response
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Format and display response
|
|
58
|
+
formatResponse(response, {
|
|
59
|
+
verbose: options.verbose,
|
|
60
|
+
quiet: options.quiet,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Exit with non-zero code for 4xx/5xx errors
|
|
64
|
+
if (response.status >= 400) {
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
formatError(err);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseHeaders(headerArray) {
|
|
76
|
+
const headers = {};
|
|
77
|
+
|
|
78
|
+
for (const header of headerArray) {
|
|
79
|
+
// Support both "key:value" and "key: value" formats
|
|
80
|
+
const colonIndex = header.indexOf(':');
|
|
81
|
+
if (colonIndex === -1) {
|
|
82
|
+
throw new Error(`Invalid header format: ${header}. Expected "key:value" or "key: value"`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const key = header.slice(0, colonIndex).trim();
|
|
86
|
+
const value = header.slice(colonIndex + 1).trim();
|
|
87
|
+
headers[key] = value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return headers;
|
|
91
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import { formatError } from '../lib/output.js';
|
|
4
|
+
import { runCollection } from '../lib/collection-runner.js';
|
|
5
|
+
import { formatTestResults } from '../lib/output.js';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
export function registerTestCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('test [file]')
|
|
11
|
+
.description('Run tests from a collection file')
|
|
12
|
+
.option('-e, --env <name>', 'Environment to use')
|
|
13
|
+
.option('--base-url <url>', 'Override base URL')
|
|
14
|
+
.option('--json', 'Output results as JSON')
|
|
15
|
+
.action(async (file, options) => {
|
|
16
|
+
try {
|
|
17
|
+
const collectionPath = file || join('.mpx-api', 'collection.yaml');
|
|
18
|
+
|
|
19
|
+
if (!existsSync(collectionPath)) {
|
|
20
|
+
formatError(new Error(`Collection not found: ${collectionPath}`));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const content = readFileSync(collectionPath, 'utf8');
|
|
25
|
+
const collection = parse(content);
|
|
26
|
+
|
|
27
|
+
// Load environment if specified
|
|
28
|
+
let env = {};
|
|
29
|
+
if (options.env) {
|
|
30
|
+
const envPath = join('.mpx-api', 'environments', `${options.env}.yaml`);
|
|
31
|
+
if (existsSync(envPath)) {
|
|
32
|
+
const envContent = readFileSync(envPath, 'utf8');
|
|
33
|
+
env = parse(envContent);
|
|
34
|
+
env = env.variables || {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const baseUrl = options.baseUrl || collection.baseUrl || '';
|
|
39
|
+
|
|
40
|
+
const results = await runCollection(collection, { env, baseUrl });
|
|
41
|
+
|
|
42
|
+
if (options.json) {
|
|
43
|
+
console.log(JSON.stringify(results, null, 2));
|
|
44
|
+
const allPassed = results.every(r => r.passed);
|
|
45
|
+
process.exit(allPassed ? 0 : 1);
|
|
46
|
+
} else {
|
|
47
|
+
const allPassed = formatTestResults(results);
|
|
48
|
+
process.exit(allPassed ? 0 : 1);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
formatError(err);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Assertion engine for testing
|
|
2
|
+
|
|
3
|
+
export function runAssertions(response, assertions) {
|
|
4
|
+
const results = [];
|
|
5
|
+
|
|
6
|
+
for (const [path, expected] of Object.entries(assertions)) {
|
|
7
|
+
const result = {
|
|
8
|
+
path,
|
|
9
|
+
expected,
|
|
10
|
+
actual: undefined,
|
|
11
|
+
passed: false,
|
|
12
|
+
description: '',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Special case: status code
|
|
16
|
+
if (path === 'status') {
|
|
17
|
+
result.actual = response.status;
|
|
18
|
+
result.passed = response.status === expected;
|
|
19
|
+
result.description = `Status code is ${expected}`;
|
|
20
|
+
results.push(result);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Special case: responseTime
|
|
25
|
+
if (path === 'responseTime') {
|
|
26
|
+
result.actual = response.responseTime;
|
|
27
|
+
if (typeof expected === 'object') {
|
|
28
|
+
// Handle operators: { lt: 500, gt: 100 }
|
|
29
|
+
result.passed = evaluateOperators(response.responseTime, expected);
|
|
30
|
+
result.description = `Response time ${formatOperators(expected)}`;
|
|
31
|
+
} else {
|
|
32
|
+
result.passed = response.responseTime === expected;
|
|
33
|
+
result.description = `Response time is ${expected}ms`;
|
|
34
|
+
}
|
|
35
|
+
results.push(result);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle headers.* paths
|
|
40
|
+
if (path.startsWith('headers.')) {
|
|
41
|
+
const headerName = path.slice(8).toLowerCase();
|
|
42
|
+
const actualValue = response.headers[headerName];
|
|
43
|
+
result.actual = actualValue;
|
|
44
|
+
|
|
45
|
+
if (typeof expected === 'string') {
|
|
46
|
+
result.passed = actualValue === expected || (actualValue && actualValue.includes(expected));
|
|
47
|
+
result.description = `Header ${headerName} contains "${expected}"`;
|
|
48
|
+
} else if (typeof expected === 'object') {
|
|
49
|
+
result.passed = evaluateOperators(actualValue, expected);
|
|
50
|
+
result.description = `Header ${headerName} ${formatOperators(expected)}`;
|
|
51
|
+
}
|
|
52
|
+
results.push(result);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle body.* paths
|
|
57
|
+
if (path.startsWith('body.')) {
|
|
58
|
+
const bodyPath = path.slice(5);
|
|
59
|
+
const actualValue = getNestedValue(response.body, bodyPath);
|
|
60
|
+
result.actual = actualValue;
|
|
61
|
+
|
|
62
|
+
if (typeof expected === 'object' && !Array.isArray(expected)) {
|
|
63
|
+
// Handle operators
|
|
64
|
+
result.passed = evaluateOperators(actualValue, expected);
|
|
65
|
+
result.description = `${path} ${formatOperators(expected)}`;
|
|
66
|
+
} else {
|
|
67
|
+
result.passed = deepEqual(actualValue, expected);
|
|
68
|
+
result.description = `${path} equals ${JSON.stringify(expected)}`;
|
|
69
|
+
}
|
|
70
|
+
results.push(result);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getNestedValue(obj, path) {
|
|
79
|
+
const parts = path.split('.');
|
|
80
|
+
let current = obj;
|
|
81
|
+
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
if (current === null || current === undefined) return undefined;
|
|
84
|
+
|
|
85
|
+
// Handle array indexing
|
|
86
|
+
const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
|
|
87
|
+
if (arrayMatch) {
|
|
88
|
+
const [, key, index] = arrayMatch;
|
|
89
|
+
current = current[key];
|
|
90
|
+
if (!Array.isArray(current)) return undefined;
|
|
91
|
+
current = current[parseInt(index)];
|
|
92
|
+
} else {
|
|
93
|
+
current = current[part];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return current;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function evaluateOperators(actual, expected) {
|
|
101
|
+
for (const [op, value] of Object.entries(expected)) {
|
|
102
|
+
switch (op) {
|
|
103
|
+
case 'eq':
|
|
104
|
+
if (actual !== value) return false;
|
|
105
|
+
break;
|
|
106
|
+
case 'ne':
|
|
107
|
+
if (actual === value) return false;
|
|
108
|
+
break;
|
|
109
|
+
case 'gt':
|
|
110
|
+
if (!(actual > value)) return false;
|
|
111
|
+
break;
|
|
112
|
+
case 'gte':
|
|
113
|
+
if (!(actual >= value)) return false;
|
|
114
|
+
break;
|
|
115
|
+
case 'lt':
|
|
116
|
+
if (!(actual < value)) return false;
|
|
117
|
+
break;
|
|
118
|
+
case 'lte':
|
|
119
|
+
if (!(actual <= value)) return false;
|
|
120
|
+
break;
|
|
121
|
+
case 'contains':
|
|
122
|
+
if (!actual || !actual.includes(value)) return false;
|
|
123
|
+
break;
|
|
124
|
+
case 'exists':
|
|
125
|
+
if (value && actual === undefined) return false;
|
|
126
|
+
if (!value && actual !== undefined) return false;
|
|
127
|
+
break;
|
|
128
|
+
default:
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatOperators(expected) {
|
|
136
|
+
const parts = [];
|
|
137
|
+
for (const [op, value] of Object.entries(expected)) {
|
|
138
|
+
switch (op) {
|
|
139
|
+
case 'eq': parts.push(`equals ${value}`); break;
|
|
140
|
+
case 'ne': parts.push(`does not equal ${value}`); break;
|
|
141
|
+
case 'gt': parts.push(`greater than ${value}`); break;
|
|
142
|
+
case 'gte': parts.push(`greater than or equal to ${value}`); break;
|
|
143
|
+
case 'lt': parts.push(`less than ${value}`); break;
|
|
144
|
+
case 'lte': parts.push(`less than or equal to ${value}`); break;
|
|
145
|
+
case 'contains': parts.push(`contains "${value}"`); break;
|
|
146
|
+
case 'exists': parts.push(value ? 'exists' : 'does not exist'); break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return parts.join(' and ');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function deepEqual(a, b) {
|
|
153
|
+
if (a === b) return true;
|
|
154
|
+
if (a == null || b == null) return false;
|
|
155
|
+
if (typeof a !== typeof b) return false;
|
|
156
|
+
|
|
157
|
+
if (Array.isArray(a)) {
|
|
158
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
159
|
+
return a.every((item, i) => deepEqual(item, b[i]));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof a === 'object') {
|
|
163
|
+
const keysA = Object.keys(a);
|
|
164
|
+
const keysB = Object.keys(b);
|
|
165
|
+
if (keysA.length !== keysB.length) return false;
|
|
166
|
+
return keysA.every(key => deepEqual(a[key], b[key]));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return false;
|
|
170
|
+
}
|