jetic-cli 0.1.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/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +36 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/dev.d.ts +3 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +478 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +91 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/inspect.d.ts +3 -0
- package/dist/commands/inspect.d.ts.map +1 -0
- package/dist/commands/inspect.js +73 -0
- package/dist/commands/inspect.js.map +1 -0
- package/dist/commands/memory.d.ts +3 -0
- package/dist/commands/memory.d.ts.map +1 -0
- package/dist/commands/memory.js +96 -0
- package/dist/commands/memory.js.map +1 -0
- package/dist/commands/scan.d.ts +3 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +54 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/simulate-workflow.d.ts +46 -0
- package/dist/commands/simulate-workflow.d.ts.map +1 -0
- package/dist/commands/simulate-workflow.js +671 -0
- package/dist/commands/simulate-workflow.js.map +1 -0
- package/dist/commands/simulate.d.ts +3 -0
- package/dist/commands/simulate.d.ts.map +1 -0
- package/dist/commands/simulate.js +482 -0
- package/dist/commands/simulate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -0
- package/src/commands/config.ts +36 -0
- package/src/commands/dev.ts +451 -0
- package/src/commands/init.ts +64 -0
- package/src/commands/inspect.ts +47 -0
- package/src/commands/memory.ts +71 -0
- package/src/commands/scan.ts +22 -0
- package/src/commands/simulate-workflow.ts +794 -0
- package/src/commands/simulate.ts +512 -0
- package/src/index.ts +26 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { loadConfig, readJsonSync } from '@jetic/core';
|
|
3
|
+
import { BehavioralModel, Environment } from '@jetic/model';
|
|
4
|
+
import { EndpointSimulator, SimulationResult } from '@jetic/simulator';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as readline from 'readline';
|
|
7
|
+
import { simulateWorkflowCommand } from './simulate-workflow';
|
|
8
|
+
|
|
9
|
+
// ─── ANSI Helpers ─────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const c = {
|
|
12
|
+
reset: '\x1b[0m',
|
|
13
|
+
bold: '\x1b[1m',
|
|
14
|
+
dim: '\x1b[2m',
|
|
15
|
+
italic: '\x1b[3m',
|
|
16
|
+
cyan: '\x1b[36m',
|
|
17
|
+
green: '\x1b[32m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
yellow: '\x1b[33m',
|
|
20
|
+
magenta: '\x1b[35m',
|
|
21
|
+
white: '\x1b[37m',
|
|
22
|
+
bgRed: '\x1b[41m',
|
|
23
|
+
bgGreen: '\x1b[42m',
|
|
24
|
+
bgCyan: '\x1b[46m',
|
|
25
|
+
black: '\x1b[30m',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const TICK = `${c.green}✓${c.reset}`;
|
|
29
|
+
const CROSS = `${c.red}✗${c.reset}`;
|
|
30
|
+
const SKIP = `${c.yellow}⊘${c.reset}`;
|
|
31
|
+
const SEPARATOR = `${c.dim}──────────────────────────────────────────────────${c.reset}`;
|
|
32
|
+
|
|
33
|
+
// ─── Spinner ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
class Spinner {
|
|
36
|
+
private frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
37
|
+
private idx = 0;
|
|
38
|
+
private interval: NodeJS.Timeout | null = null;
|
|
39
|
+
private message: string = '';
|
|
40
|
+
|
|
41
|
+
start(message: string) {
|
|
42
|
+
this.message = message;
|
|
43
|
+
this.idx = 0;
|
|
44
|
+
this.interval = setInterval(() => {
|
|
45
|
+
const frame = this.frames[this.idx % this.frames.length];
|
|
46
|
+
process.stdout.write(`\r ${c.cyan}${frame}${c.reset} ${this.message}`);
|
|
47
|
+
this.idx++;
|
|
48
|
+
}, 80);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
update(message: string) {
|
|
52
|
+
this.message = message;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
stop(finalMessage?: string) {
|
|
56
|
+
if (this.interval) {
|
|
57
|
+
clearInterval(this.interval);
|
|
58
|
+
this.interval = null;
|
|
59
|
+
}
|
|
60
|
+
if (finalMessage) {
|
|
61
|
+
process.stdout.write(`\r${finalMessage}\x1b[K\n`);
|
|
62
|
+
} else {
|
|
63
|
+
process.stdout.write(`\r\x1b[K`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Interactive Environment Selector ─────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
async function selectEnvironment(environments: Environment[]): Promise<Environment> {
|
|
71
|
+
if (environments.length === 0) {
|
|
72
|
+
console.log(` ${c.yellow}⚠${c.reset} No environments defined in model.json`);
|
|
73
|
+
console.log(` ${c.dim}Using default: http://localhost:3000${c.reset}\n`);
|
|
74
|
+
return { name: 'default', baseUrl: 'http://localhost:3000' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (environments.length === 1) {
|
|
78
|
+
const env = environments[0];
|
|
79
|
+
console.log(` ${c.cyan}🌍${c.reset} Environment: ${c.bold}${env.name}${c.reset} ${c.dim}→ ${env.baseUrl}${c.reset}\n`);
|
|
80
|
+
return env;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return new Promise<Environment>((resolve) => {
|
|
84
|
+
let selectedIndex = 0;
|
|
85
|
+
|
|
86
|
+
const renderOptions = () => {
|
|
87
|
+
// Move cursor up to overwrite previous render
|
|
88
|
+
if (selectedIndex >= 0) {
|
|
89
|
+
process.stdout.write(`\x1b[${environments.length}A`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < environments.length; i++) {
|
|
93
|
+
const env = environments[i];
|
|
94
|
+
const isSelected = i === selectedIndex;
|
|
95
|
+
const radio = isSelected ? `${c.cyan}◉${c.reset}` : `${c.dim}○${c.reset}`;
|
|
96
|
+
const name = isSelected ? `${c.bold}${c.cyan}${env.name}${c.reset}` : `${c.dim}${env.name}${c.reset}`;
|
|
97
|
+
const url = `${c.dim}→ ${env.baseUrl}${c.reset}`;
|
|
98
|
+
process.stdout.write(`\r ${radio} ${name.padEnd(isSelected ? 30 : 20)} ${url}\x1b[K\n`);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
console.log(`\n ${c.cyan}🌍${c.reset} Select environment:\n`);
|
|
103
|
+
|
|
104
|
+
// Initial render
|
|
105
|
+
for (let i = 0; i < environments.length; i++) {
|
|
106
|
+
const env = environments[i];
|
|
107
|
+
const isSelected = i === selectedIndex;
|
|
108
|
+
const radio = isSelected ? `${c.cyan}◉${c.reset}` : `${c.dim}○${c.reset}`;
|
|
109
|
+
const name = isSelected ? `${c.bold}${c.cyan}${env.name}${c.reset}` : `${c.dim}${env.name}${c.reset}`;
|
|
110
|
+
const url = `${c.dim}→ ${env.baseUrl}${c.reset}`;
|
|
111
|
+
console.log(` ${radio} ${name.padEnd(isSelected ? 30 : 20)} ${url}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(`\n ${c.dim}Press ↑/↓ to navigate, Enter to select${c.reset}`);
|
|
115
|
+
|
|
116
|
+
// Enable raw mode for key capture
|
|
117
|
+
if (process.stdin.isTTY) {
|
|
118
|
+
process.stdin.setRawMode(true);
|
|
119
|
+
}
|
|
120
|
+
process.stdin.resume();
|
|
121
|
+
process.stdin.setEncoding('utf8');
|
|
122
|
+
|
|
123
|
+
const onKeyPress = (key: string) => {
|
|
124
|
+
// Ctrl+C
|
|
125
|
+
if (key === '\u0003') {
|
|
126
|
+
process.stdin.setRawMode(false);
|
|
127
|
+
process.stdin.removeListener('data', onKeyPress);
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Arrow keys are escape sequences
|
|
132
|
+
if (key === '\u001b[A') {
|
|
133
|
+
// Up arrow
|
|
134
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
135
|
+
// Move back up past the hint line and options
|
|
136
|
+
process.stdout.write(`\x1b[${environments.length + 2}A`);
|
|
137
|
+
// Re-render
|
|
138
|
+
for (let i = 0; i < environments.length; i++) {
|
|
139
|
+
const env = environments[i];
|
|
140
|
+
const isSelected = i === selectedIndex;
|
|
141
|
+
const radio = isSelected ? `${c.cyan}◉${c.reset}` : `${c.dim}○${c.reset}`;
|
|
142
|
+
const name = isSelected ? `${c.bold}${c.cyan}${env.name}${c.reset}` : `${c.dim}${env.name}${c.reset}`;
|
|
143
|
+
const url = `${c.dim}→ ${env.baseUrl}${c.reset}`;
|
|
144
|
+
process.stdout.write(` ${radio} ${name} ${url}\x1b[K\n`);
|
|
145
|
+
}
|
|
146
|
+
process.stdout.write(`\n ${c.dim}Press ↑/↓ to navigate, Enter to select${c.reset}\n`);
|
|
147
|
+
} else if (key === '\u001b[B') {
|
|
148
|
+
// Down arrow
|
|
149
|
+
selectedIndex = Math.min(environments.length - 1, selectedIndex + 1);
|
|
150
|
+
process.stdout.write(`\x1b[${environments.length + 2}A`);
|
|
151
|
+
for (let i = 0; i < environments.length; i++) {
|
|
152
|
+
const env = environments[i];
|
|
153
|
+
const isSelected = i === selectedIndex;
|
|
154
|
+
const radio = isSelected ? `${c.cyan}◉${c.reset}` : `${c.dim}○${c.reset}`;
|
|
155
|
+
const name = isSelected ? `${c.bold}${c.cyan}${env.name}${c.reset}` : `${c.dim}${env.name}${c.reset}`;
|
|
156
|
+
const url = `${c.dim}→ ${env.baseUrl}${c.reset}`;
|
|
157
|
+
process.stdout.write(` ${radio} ${name} ${url}\x1b[K\n`);
|
|
158
|
+
}
|
|
159
|
+
process.stdout.write(`\n ${c.dim}Press ↑/↓ to navigate, Enter to select${c.reset}\n`);
|
|
160
|
+
} else if (key === '\r' || key === '\n') {
|
|
161
|
+
// Enter
|
|
162
|
+
if (process.stdin.isTTY) {
|
|
163
|
+
process.stdin.setRawMode(false);
|
|
164
|
+
}
|
|
165
|
+
process.stdin.removeListener('data', onKeyPress);
|
|
166
|
+
process.stdin.pause();
|
|
167
|
+
|
|
168
|
+
const selected = environments[selectedIndex];
|
|
169
|
+
// Clear the hint line and show selection
|
|
170
|
+
process.stdout.write(`\r\x1b[K`);
|
|
171
|
+
console.log(`\n ${c.green}✓${c.reset} Selected: ${c.bold}${selected.name}${c.reset} ${c.dim}→ ${selected.baseUrl}${c.reset}\n`);
|
|
172
|
+
resolve(selected);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
process.stdin.on('data', onKeyPress);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Result Rendering ─────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function renderSingleResult(result: SimulationResult, verbose: boolean = true): void {
|
|
183
|
+
const methodColor = getMethodColor(result.method);
|
|
184
|
+
const methodStr = `${methodColor}${result.method.padEnd(6)}${c.reset}`;
|
|
185
|
+
|
|
186
|
+
if (result.skipped) {
|
|
187
|
+
console.log(` ${SKIP} ${methodStr} ${result.path} ${c.dim}(skipped: ${result.skipReason})${c.reset}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (result.error) {
|
|
192
|
+
console.log(` ${CROSS} ${methodStr} ${result.path} ${c.red}ERROR${c.reset}`);
|
|
193
|
+
console.log(` ${c.dim} └─ ${result.error}${c.reset}`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const statusStr = formatStatus(result.responseStatus);
|
|
198
|
+
const timeStr = `${c.dim}${result.responseTimeMs}ms${c.reset}`;
|
|
199
|
+
|
|
200
|
+
if (!verbose) {
|
|
201
|
+
const icon = result.passed ? TICK : CROSS;
|
|
202
|
+
console.log(` ${icon} ${methodStr} ${result.path.padEnd(25)} ${statusStr} ${timeStr}`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Verbose single-endpoint output ──────────────────────────────────────
|
|
207
|
+
console.log(` ${c.cyan}📡${c.reset} ${c.bold}${result.method} ${result.path}${c.reset}`);
|
|
208
|
+
|
|
209
|
+
// Request body
|
|
210
|
+
if (result.requestBody && Object.keys(result.requestBody).length > 0) {
|
|
211
|
+
console.log(` ${c.dim}├─${c.reset} ${c.dim}Generating request data...${c.reset}`);
|
|
212
|
+
const bodyStr = JSON.stringify(result.requestBody, null, 2);
|
|
213
|
+
const lines = bodyStr.split('\n');
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
console.log(` ${c.dim}│${c.reset} ${c.yellow}${line}${c.reset}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Headers
|
|
220
|
+
const headerKeys = Object.keys(result.requestHeaders);
|
|
221
|
+
if (headerKeys.length > 0) {
|
|
222
|
+
const headerStr = headerKeys.map((k) => {
|
|
223
|
+
let val = result.requestHeaders[k];
|
|
224
|
+
// Truncate long values (like auth tokens)
|
|
225
|
+
if (val.length > 40) val = val.substring(0, 37) + '...';
|
|
226
|
+
return `${k}: ${val}`;
|
|
227
|
+
}).join(', ');
|
|
228
|
+
console.log(` ${c.dim}├─${c.reset} Headers: ${c.dim}${headerStr}${c.reset}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Response status
|
|
232
|
+
console.log(` ${c.dim}├─${c.reset} Response: ${statusStr} ${c.dim}(${result.responseTimeMs}ms)${c.reset}`);
|
|
233
|
+
|
|
234
|
+
// ── Response Body ───────────────────────────────────────────────────────
|
|
235
|
+
console.log(` ${c.dim}├─${c.reset} ${c.bold}Response Body:${c.reset}`);
|
|
236
|
+
if (result.responseBody !== null && result.responseBody !== undefined) {
|
|
237
|
+
const bodyStr = typeof result.responseBody === 'string'
|
|
238
|
+
? result.responseBody
|
|
239
|
+
: JSON.stringify(result.responseBody, null, 2);
|
|
240
|
+
const bodyLines = bodyStr.split('\n');
|
|
241
|
+
for (const line of bodyLines) {
|
|
242
|
+
console.log(` ${c.dim}│${c.reset} ${c.cyan}${line}${c.reset}`);
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
console.log(` ${c.dim}│${c.reset} ${c.dim}(empty response)${c.reset}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Schema Validation ──────────────────────────────────────────────────
|
|
249
|
+
if (result.validation.fieldValidations.length > 0) {
|
|
250
|
+
console.log(` ${c.dim}├─${c.reset} ${c.bold}Schema Validation:${c.reset} ${c.dim}(${result.validation.passedFields}/${result.validation.totalFields} fields match)${c.reset}`);
|
|
251
|
+
|
|
252
|
+
for (const field of result.validation.fieldValidations) {
|
|
253
|
+
if (field.passed) {
|
|
254
|
+
// Field matches
|
|
255
|
+
const valuePreview = formatValuePreview(field.actualValue);
|
|
256
|
+
const resolvedHint = field.resolvedPath
|
|
257
|
+
? ` ${c.dim}(resolved via ${c.italic}${field.resolvedPath}${c.reset}${c.dim})${c.reset}`
|
|
258
|
+
: '';
|
|
259
|
+
console.log(` ${c.dim}│${c.reset} ${TICK} ${c.bold}${field.field}${c.reset}${resolvedHint}`);
|
|
260
|
+
console.log(` ${c.dim}│${c.reset} ${c.dim}Type:${c.reset} ${c.green}${field.actualType}${c.reset} ${c.dim}(expected ${field.expectedType})${c.reset}`);
|
|
261
|
+
console.log(` ${c.dim}│${c.reset} ${c.dim}Value:${c.reset} ${c.cyan}${valuePreview}${c.reset}`);
|
|
262
|
+
} else {
|
|
263
|
+
// Field does NOT match
|
|
264
|
+
const valuePreview = formatValuePreview(field.actualValue);
|
|
265
|
+
console.log(` ${c.dim}│${c.reset} ${CROSS} ${c.bold}${c.red}${field.field}${c.reset} ${c.red}← MISMATCH${c.reset}`);
|
|
266
|
+
console.log(` ${c.dim}│${c.reset} ${c.dim}Expected type:${c.reset} ${c.green}${field.expectedType}${c.reset}`);
|
|
267
|
+
console.log(` ${c.dim}│${c.reset} ${c.dim}Actual type:${c.reset} ${c.red}${field.actualType}${c.reset}`);
|
|
268
|
+
console.log(` ${c.dim}│${c.reset} ${c.dim}Actual value:${c.reset} ${c.red}${valuePreview}${c.reset}`);
|
|
269
|
+
if (field.actualType === 'undefined') {
|
|
270
|
+
console.log(` ${c.dim}│${c.reset} ${c.yellow}⚠ Field is missing from the response${c.reset}`);
|
|
271
|
+
} else if (field.actualType === 'null') {
|
|
272
|
+
console.log(` ${c.dim}│${c.reset} ${c.yellow}⚠ Field is null, expected ${field.expectedType}${c.reset}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
console.log(` ${c.dim}├─${c.reset} ${c.dim}Schema Validation: no schema defined in model for this response${c.reset}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Final Verdict ──────────────────────────────────────────────────────
|
|
281
|
+
const verdictIcon = result.passed ? TICK : CROSS;
|
|
282
|
+
const verdictText = result.passed
|
|
283
|
+
? `${c.green}${c.bold}PASSED${c.reset}`
|
|
284
|
+
: `${c.red}${c.bold}FAILED${c.reset}`;
|
|
285
|
+
|
|
286
|
+
const reasons: string[] = [];
|
|
287
|
+
// if (!result.validation.statusPassed) {
|
|
288
|
+
// reasons.push(`status ${result.responseStatus} ≠ ${result.validation.expectedStatus}`);
|
|
289
|
+
//}
|
|
290
|
+
if (result.validation.failedFields > 0) {
|
|
291
|
+
reasons.push(`${result.validation.failedFields} field(s) mismatched`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const reasonStr = reasons.length > 0
|
|
295
|
+
? ` ${c.dim}(${reasons.join(', ')})${c.reset}`
|
|
296
|
+
: '';
|
|
297
|
+
const fieldSummary = result.validation.totalFields > 0
|
|
298
|
+
? ` ${c.dim}• ${result.validation.passedFields}/${result.validation.totalFields} fields${c.reset}`
|
|
299
|
+
: '';
|
|
300
|
+
console.log(` ${c.dim}└─${c.reset} ${verdictIcon} ${verdictText}${fieldSummary}${reasonStr}`);
|
|
301
|
+
console.log('');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Format a value for display preview — truncate long strings/objects.
|
|
306
|
+
*/
|
|
307
|
+
function formatValuePreview(value: any): string {
|
|
308
|
+
if (value === undefined) return 'undefined';
|
|
309
|
+
if (value === null) return 'null';
|
|
310
|
+
if (typeof value === 'string') {
|
|
311
|
+
return value.length > 60 ? `"${value.substring(0, 57)}..."` : `"${value}"`;
|
|
312
|
+
}
|
|
313
|
+
if (typeof value === 'object') {
|
|
314
|
+
const str = JSON.stringify(value);
|
|
315
|
+
return str.length > 80 ? str.substring(0, 77) + '...' : str;
|
|
316
|
+
}
|
|
317
|
+
return String(value);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
function renderSummary(summary: {
|
|
322
|
+
passed: number;
|
|
323
|
+
failed: number;
|
|
324
|
+
skipped: number;
|
|
325
|
+
totalTimeMs: number;
|
|
326
|
+
results: SimulationResult[];
|
|
327
|
+
}): void {
|
|
328
|
+
console.log('');
|
|
329
|
+
// console.log(SEPARATOR);
|
|
330
|
+
|
|
331
|
+
const elapsed = (summary.totalTimeMs / 1000).toFixed(1);
|
|
332
|
+
const allPassed = summary.failed === 0;
|
|
333
|
+
const icon = allPassed ? `${c.green}✅${c.reset}` : `${c.red}❌${c.reset}`;
|
|
334
|
+
const label = allPassed ? `${c.green}Simulation Complete!${c.reset}` : `${c.red}Simulation Complete (with failures)${c.reset}`;
|
|
335
|
+
|
|
336
|
+
console.log(` ${icon} ${c.bold}${label}${c.reset} ${c.dim}(${elapsed}s)${c.reset}`);
|
|
337
|
+
console.log('');
|
|
338
|
+
|
|
339
|
+
// Individual results (compact)
|
|
340
|
+
for (const result of summary.results) {
|
|
341
|
+
renderSingleResult(result, false);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.log('');
|
|
345
|
+
// console.log(SEPARATOR);
|
|
346
|
+
|
|
347
|
+
const parts: string[] = [];
|
|
348
|
+
if (summary.passed > 0) parts.push(`${c.green}${summary.passed} passed${c.reset}`);
|
|
349
|
+
if (summary.failed > 0) parts.push(`${c.red}${summary.failed} failed${c.reset}`);
|
|
350
|
+
if (summary.skipped > 0) parts.push(`${c.yellow}${summary.skipped} skipped${c.reset}`);
|
|
351
|
+
|
|
352
|
+
console.log(` ${parts.join(` ${c.dim}│${c.reset} `)}`);
|
|
353
|
+
// console.log(SEPARATOR);
|
|
354
|
+
console.log('');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function getMethodColor(method: string): string {
|
|
358
|
+
switch (method.toUpperCase()) {
|
|
359
|
+
case 'GET': return c.green;
|
|
360
|
+
case 'POST': return c.yellow;
|
|
361
|
+
case 'PUT': return c.cyan;
|
|
362
|
+
case 'PATCH': return c.magenta;
|
|
363
|
+
case 'DELETE': return c.red;
|
|
364
|
+
default: return c.white;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function formatStatus(status: number): string {
|
|
369
|
+
if (status >= 200 && status < 300) return `${c.green}${status}${c.reset}`;
|
|
370
|
+
if (status >= 300 && status < 400) return `${c.yellow}${status}${c.reset}`;
|
|
371
|
+
if (status >= 400 && status < 500) return `${c.red}${status}${c.reset}`;
|
|
372
|
+
if (status >= 500) return `${c.bgRed}${c.white} ${status} ${c.reset}`;
|
|
373
|
+
return `${c.dim}${status}${c.reset}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ─── Progress Bar ─────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
function buildProgressBar(current: number, total: number): string {
|
|
379
|
+
const width = 24;
|
|
380
|
+
const filled = Math.round((current / total) * width);
|
|
381
|
+
const empty = width - filled;
|
|
382
|
+
return `${c.magenta}${'━'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Load Model ───────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
function loadModel(): BehavioralModel | null {
|
|
388
|
+
const config = loadConfig();
|
|
389
|
+
const modelPath = path.join(config.jeticDir, 'model.json');
|
|
390
|
+
return readJsonSync<BehavioralModel>(modelPath);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
export const simulateCommand = new Command('simulate')
|
|
396
|
+
.description('Simulate API endpoints against a live server');
|
|
397
|
+
|
|
398
|
+
simulateCommand.addCommand(simulateWorkflowCommand);
|
|
399
|
+
|
|
400
|
+
simulateCommand
|
|
401
|
+
.command('endpoint [method] [endpointPath]')
|
|
402
|
+
.description('Simulate a specific endpoint or all endpoints')
|
|
403
|
+
.option('--all', 'Simulate all endpoints')
|
|
404
|
+
.option('--verbose', 'Show detailed request/response info', false)
|
|
405
|
+
.action(async (method: string | undefined, endpointPath: string | undefined, options: { all?: boolean; verbose?: boolean }) => {
|
|
406
|
+
// Banner
|
|
407
|
+
console.log('');
|
|
408
|
+
console.log(` ${c.bgCyan}${c.black}${c.bold} JETIC ${c.reset} ${c.cyan}${c.bold}Endpoint Simulator${c.reset}`);
|
|
409
|
+
// console.log(` ${SEPARATOR}`);
|
|
410
|
+
console.log('');
|
|
411
|
+
|
|
412
|
+
// Load model
|
|
413
|
+
const model = loadModel();
|
|
414
|
+
if (!model) {
|
|
415
|
+
console.log(` ${c.red}✗${c.reset} No behavioral model found.`);
|
|
416
|
+
console.log(` ${c.dim}Run \`jetic scan\` first to generate the model.${c.reset}\n`);
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
console.log(` ${c.dim}Model v${model.version} • ${model.endpoints.length} endpoints${c.reset}\n`);
|
|
421
|
+
|
|
422
|
+
// Environment selection
|
|
423
|
+
const environments = model.environments || [];
|
|
424
|
+
const selectedEnv = await selectEnvironment(environments);
|
|
425
|
+
|
|
426
|
+
// ── Check Backend Health ───────────────────────────────────────────
|
|
427
|
+
const checkSpinner = new Spinner();
|
|
428
|
+
checkSpinner.start(`Checking if backend is active at ${selectedEnv.baseUrl}...`);
|
|
429
|
+
try {
|
|
430
|
+
await fetch(selectedEnv.baseUrl);
|
|
431
|
+
checkSpinner.stop(` ${c.green}✓${c.reset} Backend is active`);
|
|
432
|
+
console.log('');
|
|
433
|
+
} catch (e) {
|
|
434
|
+
checkSpinner.stop();
|
|
435
|
+
console.log(` ${c.red}✗${c.reset} Backend is unreachable at ${c.bold}${selectedEnv.baseUrl}${c.reset}`);
|
|
436
|
+
console.log(` ${c.yellow}⚠${c.reset} Please run/initialize your backend project and try again.\n`);
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Create simulator
|
|
441
|
+
const simulator = new EndpointSimulator(model, selectedEnv);
|
|
442
|
+
const spinner = new Spinner();
|
|
443
|
+
|
|
444
|
+
if (options.all) {
|
|
445
|
+
// ── Simulate ALL endpoints ──────────────────────────────────────
|
|
446
|
+
console.log(` ${c.magenta}🚀${c.reset} Simulating ${c.bold}${model.endpoints.length}${c.reset} endpoints...\n`);
|
|
447
|
+
|
|
448
|
+
const results: SimulationResult[] = [];
|
|
449
|
+
const startTime = Date.now();
|
|
450
|
+
|
|
451
|
+
for (let i = 0; i < model.endpoints.length; i++) {
|
|
452
|
+
const ep = model.endpoints[i];
|
|
453
|
+
const label = `${ep.method} ${ep.path}`;
|
|
454
|
+
const progress = buildProgressBar(i, model.endpoints.length);
|
|
455
|
+
|
|
456
|
+
spinner.start(`${progress} ${c.dim}${i + 1}/${model.endpoints.length}${c.reset} ${c.dim}⏳ ${label}${c.reset}`);
|
|
457
|
+
|
|
458
|
+
const result = await simulator.simulateEndpoint(ep);
|
|
459
|
+
results.push(result);
|
|
460
|
+
|
|
461
|
+
const icon = result.skipped ? SKIP : (result.passed ? TICK : CROSS);
|
|
462
|
+
const statusStr = result.skipped
|
|
463
|
+
? `${c.dim}skipped${c.reset}`
|
|
464
|
+
: formatStatus(result.responseStatus);
|
|
465
|
+
const timeStr = result.skipped ? '' : `${c.dim}${result.responseTimeMs}ms${c.reset}`;
|
|
466
|
+
|
|
467
|
+
spinner.stop(` ${buildProgressBar(i + 1, model.endpoints.length)} ${c.dim}${i + 1}/${model.endpoints.length}${c.reset} ${icon} ${label} ${statusStr} ${timeStr}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const totalTimeMs = Date.now() - startTime;
|
|
471
|
+
|
|
472
|
+
renderSummary({
|
|
473
|
+
passed: results.filter((r) => r.passed).length,
|
|
474
|
+
failed: results.filter((r) => !r.passed && !r.skipped).length,
|
|
475
|
+
skipped: results.filter((r) => r.skipped).length,
|
|
476
|
+
totalTimeMs,
|
|
477
|
+
results,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
} else if (method && endpointPath) {
|
|
481
|
+
// ── Simulate SINGLE endpoint ────────────────────────────────────
|
|
482
|
+
const ep = model.endpoints.find(
|
|
483
|
+
(e) => e.method.toUpperCase() === method.toUpperCase() && e.path === endpointPath
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
if (!ep) {
|
|
487
|
+
console.log(` ${c.red}✗${c.reset} Endpoint ${c.bold}${method.toUpperCase()} ${endpointPath}${c.reset} not found in model.\n`);
|
|
488
|
+
console.log(` ${c.dim}Available endpoints:${c.reset}`);
|
|
489
|
+
for (const e of model.endpoints) {
|
|
490
|
+
console.log(` ${c.dim}•${c.reset} ${getMethodColor(e.method)}${e.method}${c.reset} ${e.path}`);
|
|
491
|
+
}
|
|
492
|
+
console.log('');
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
spinner.start(`Simulating ${c.bold}${ep.method} ${ep.path}${c.reset}...`);
|
|
497
|
+
|
|
498
|
+
const result = await simulator.simulateEndpoint(ep);
|
|
499
|
+
|
|
500
|
+
spinner.stop();
|
|
501
|
+
console.log('');
|
|
502
|
+
renderSingleResult(result, true);
|
|
503
|
+
|
|
504
|
+
} else {
|
|
505
|
+
console.log(` ${c.red}✗${c.reset} Please specify an endpoint or use ${c.bold}--all${c.reset}\n`);
|
|
506
|
+
console.log(` ${c.dim}Usage:${c.reset}`);
|
|
507
|
+
console.log(` ${c.cyan}jetic simulate endpoint POST /register${c.reset}`);
|
|
508
|
+
console.log(` ${c.cyan}jetic simulate endpoint --all${c.reset}`);
|
|
509
|
+
console.log('');
|
|
510
|
+
process.exit(1);
|
|
511
|
+
}
|
|
512
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { initCommand } from './commands/init';
|
|
4
|
+
import { scanCommand } from './commands/scan';
|
|
5
|
+
import { inspectCommand } from './commands/inspect';
|
|
6
|
+
import { configCommand } from './commands/config';
|
|
7
|
+
import { memoryCommand } from './commands/memory';
|
|
8
|
+
import { simulateCommand } from './commands/simulate';
|
|
9
|
+
import { devCommand } from './commands/dev';
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('jetic')
|
|
15
|
+
.description('AI-Native API Behavior Testing')
|
|
16
|
+
.version('0.1.0');
|
|
17
|
+
|
|
18
|
+
program.addCommand(initCommand);
|
|
19
|
+
program.addCommand(scanCommand);
|
|
20
|
+
program.addCommand(inspectCommand);
|
|
21
|
+
program.addCommand(configCommand);
|
|
22
|
+
program.addCommand(memoryCommand);
|
|
23
|
+
program.addCommand(simulateCommand);
|
|
24
|
+
program.addCommand(devCommand);
|
|
25
|
+
|
|
26
|
+
program.parse(process.argv);
|