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,671 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.simulateWorkflowCommand = void 0;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const commander_1 = require("commander");
|
|
40
|
+
const core_1 = require("@jetic/core");
|
|
41
|
+
const memory_1 = require("@jetic/memory");
|
|
42
|
+
const zod_1 = require("zod");
|
|
43
|
+
const faker_1 = require("@faker-js/faker");
|
|
44
|
+
// ─── ANSI Helpers ──────────────────────────────────────────────────────────────
|
|
45
|
+
const c = {
|
|
46
|
+
reset: '\x1b[0m',
|
|
47
|
+
bold: '\x1b[1m',
|
|
48
|
+
dim: '\x1b[2m',
|
|
49
|
+
italic: '\x1b[3m',
|
|
50
|
+
cyan: '\x1b[36m',
|
|
51
|
+
green: '\x1b[32m',
|
|
52
|
+
red: '\x1b[31m',
|
|
53
|
+
yellow: '\x1b[33m',
|
|
54
|
+
magenta: '\x1b[35m',
|
|
55
|
+
blue: '\x1b[34m',
|
|
56
|
+
white: '\x1b[37m',
|
|
57
|
+
bgCyan: '\x1b[46m',
|
|
58
|
+
black: '\x1b[30m',
|
|
59
|
+
};
|
|
60
|
+
const TICK = `${c.green}✓${c.reset}`;
|
|
61
|
+
const CROSS = `${c.red}✗${c.reset}`;
|
|
62
|
+
const SKIP = `${c.yellow}⊘${c.reset}`;
|
|
63
|
+
const ARROW = `${c.cyan}→${c.reset}`;
|
|
64
|
+
const CHAIN = `${c.dim}│${c.reset}`;
|
|
65
|
+
const SEP = `${c.dim}──────────────────────────────────────────────────${c.reset}`;
|
|
66
|
+
// ─── Spinner ──────────────────────────────────────────────────────────────────
|
|
67
|
+
class Spinner {
|
|
68
|
+
frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
69
|
+
idx = 0;
|
|
70
|
+
interval = null;
|
|
71
|
+
start(message) {
|
|
72
|
+
this.idx = 0;
|
|
73
|
+
this.interval = setInterval(() => {
|
|
74
|
+
const frame = this.frames[this.idx % this.frames.length];
|
|
75
|
+
process.stdout.write(`\r ${c.cyan}${frame}${c.reset} ${message}`);
|
|
76
|
+
this.idx++;
|
|
77
|
+
}, 80);
|
|
78
|
+
}
|
|
79
|
+
stop(finalMessage) {
|
|
80
|
+
if (this.interval) {
|
|
81
|
+
clearInterval(this.interval);
|
|
82
|
+
this.interval = null;
|
|
83
|
+
}
|
|
84
|
+
if (finalMessage)
|
|
85
|
+
process.stdout.write(`\r${finalMessage}\x1b[K\n`);
|
|
86
|
+
else
|
|
87
|
+
process.stdout.write(`\r\x1b[K`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ─── AI workflow generator ────────────────────────────────────────────────────
|
|
91
|
+
async function generateWorkflow(model, config, workflowName) {
|
|
92
|
+
if (!config.ai) {
|
|
93
|
+
throw new Error('AI is not configured. Run `jetic config ai` first.');
|
|
94
|
+
}
|
|
95
|
+
const { provider, model: aiModel, apiKeyEnvVar } = config.ai;
|
|
96
|
+
const apiKey = process.env[apiKeyEnvVar];
|
|
97
|
+
if (!apiKey)
|
|
98
|
+
throw new Error(`Missing API key in env var: ${apiKeyEnvVar}`);
|
|
99
|
+
if (provider !== 'openai' && provider !== 'openrouter') {
|
|
100
|
+
throw new Error(`Unsupported AI provider: ${provider}`);
|
|
101
|
+
}
|
|
102
|
+
// Build a concise endpoint summary for the prompt
|
|
103
|
+
const endpointSummary = model.endpoints
|
|
104
|
+
.map((ep) => {
|
|
105
|
+
const mw = ep.middleware.map((m) => m.name).join(', ');
|
|
106
|
+
const auth = mw ? ` [auth: ${mw}]` : '';
|
|
107
|
+
const resp = ep.responses?.['200']?.schema
|
|
108
|
+
? ' → ' + Object.keys(ep.responses['200'].schema).slice(0, 5).join(', ')
|
|
109
|
+
: '';
|
|
110
|
+
const body = ep.requestBody?.fields
|
|
111
|
+
? ' body:' + Object.keys(ep.requestBody.fields).slice(0, 5).join(',')
|
|
112
|
+
: '';
|
|
113
|
+
return `${ep.method} ${ep.path}${auth}${body}${resp}`;
|
|
114
|
+
})
|
|
115
|
+
.join('\n');
|
|
116
|
+
const prompt = `You are an expert API test workflow designer.
|
|
117
|
+
Given this API's endpoints, design a realistic end-to-end workflow that tests the most important user journey.
|
|
118
|
+
|
|
119
|
+
Project: ${model.project.name} (${model.project.framework})
|
|
120
|
+
Workflow goal: "${workflowName}"
|
|
121
|
+
|
|
122
|
+
Endpoints:
|
|
123
|
+
${endpointSummary}
|
|
124
|
+
|
|
125
|
+
Rules:
|
|
126
|
+
1. Order steps logically (auth first, then CRUD operations, then cleanup/logout).
|
|
127
|
+
2. For each step, specify which response fields to "capture" into memory (e.g., accessToken, refreshToken, userId, workspaceId, examId etc.)
|
|
128
|
+
3. For each step, specify which memory keys to "inject" — headers or body fields needed by this request.
|
|
129
|
+
- Use prefix "header:" for HTTP headers (e.g., "header:Authorization" → "Bearer {token}")
|
|
130
|
+
- Use prefix "body:" for body fields injection
|
|
131
|
+
- Memory keys are in format "scope:key" e.g. "workflow:accessToken"
|
|
132
|
+
4. The memory key format for capture is "scope:key" → "response.field.path" (dot-notation).
|
|
133
|
+
Bearer tokens should be stored with the full "Bearer " prefix if needed.
|
|
134
|
+
5. For body fields that must be generated (email, password, name), use realistic placeholder values.
|
|
135
|
+
6. Mark "expectStatus": 201 for creation routes, 200 for others, 204 for deletes.
|
|
136
|
+
7. Include between 5-12 steps covering the full lifecycle.
|
|
137
|
+
|
|
138
|
+
Return a JSON object matching this schema exactly.`;
|
|
139
|
+
const importDynamic = new Function('modulePath', 'return import(modulePath)');
|
|
140
|
+
const { generateObject } = await importDynamic('ai');
|
|
141
|
+
let aiModelObj;
|
|
142
|
+
if (provider === 'openai') {
|
|
143
|
+
const { createOpenAI } = await importDynamic('@ai-sdk/openai');
|
|
144
|
+
aiModelObj = createOpenAI({ apiKey })(aiModel);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const { createOpenRouter } = await importDynamic('@openrouter/ai-sdk-provider');
|
|
148
|
+
aiModelObj = createOpenRouter({ apiKey })(aiModel);
|
|
149
|
+
}
|
|
150
|
+
const StepSchema = zod_1.z.object({
|
|
151
|
+
name: zod_1.z.string(),
|
|
152
|
+
method: zod_1.z.string(),
|
|
153
|
+
path: zod_1.z.string(),
|
|
154
|
+
description: zod_1.z.string().optional(),
|
|
155
|
+
inject: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
|
|
156
|
+
capture: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
|
|
157
|
+
expectStatus: zod_1.z.number().optional(),
|
|
158
|
+
body: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional(),
|
|
159
|
+
});
|
|
160
|
+
const WorkflowSchema = zod_1.z.object({
|
|
161
|
+
name: zod_1.z.string(),
|
|
162
|
+
description: zod_1.z.string().optional(),
|
|
163
|
+
steps: zod_1.z.array(StepSchema),
|
|
164
|
+
});
|
|
165
|
+
const { object } = await generateObject({
|
|
166
|
+
model: aiModelObj,
|
|
167
|
+
mode: 'json',
|
|
168
|
+
maxTokens: 2000,
|
|
169
|
+
schema: WorkflowSchema,
|
|
170
|
+
prompt,
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
name: object.name,
|
|
174
|
+
description: object.description,
|
|
175
|
+
generatedAt: new Date().toISOString(),
|
|
176
|
+
steps: object.steps,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// ─── Deep-get value from object using dot-notation ────────────────────────────
|
|
180
|
+
function deepGet(obj, dotPath) {
|
|
181
|
+
const parts = dotPath.replace(/\[(\d+)\]/g, '.$1').split('.');
|
|
182
|
+
let current = obj;
|
|
183
|
+
for (const part of parts) {
|
|
184
|
+
if (current == null)
|
|
185
|
+
return undefined;
|
|
186
|
+
current = current[part];
|
|
187
|
+
}
|
|
188
|
+
return current;
|
|
189
|
+
}
|
|
190
|
+
// ─── Shared template string resolver ────────────────────────────────────────
|
|
191
|
+
// Resolves all {{faker.*}} and {{scope:key}} placeholders in a single string.
|
|
192
|
+
async function resolveTemplateString(value) {
|
|
193
|
+
const templateRe = /\{\{([^}]+)\}\}/g;
|
|
194
|
+
let match;
|
|
195
|
+
const replacements = [];
|
|
196
|
+
while ((match = templateRe.exec(value)) !== null) {
|
|
197
|
+
const expr = match[1].trim();
|
|
198
|
+
// ── scope:key → read from JeticMemory ───────────────────────────
|
|
199
|
+
if (expr.includes(':') && !expr.startsWith('faker.')) {
|
|
200
|
+
const colonIdx = expr.indexOf(':');
|
|
201
|
+
const scope = expr.slice(0, colonIdx);
|
|
202
|
+
const key = expr.slice(colonIdx + 1);
|
|
203
|
+
const mem = new memory_1.JeticMemory({ scope });
|
|
204
|
+
const memVal = await mem.get(key);
|
|
205
|
+
replacements.push({ placeholder: match[0], resolved: memVal != null ? String(memVal) : '' });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// ── faker.x.y → call faker dynamically ──────────────────────────
|
|
209
|
+
if (expr.startsWith('faker.')) {
|
|
210
|
+
const parts = expr.split('.');
|
|
211
|
+
try {
|
|
212
|
+
let fn = faker_1.faker;
|
|
213
|
+
for (const part of parts.slice(1))
|
|
214
|
+
fn = fn[part];
|
|
215
|
+
const generated = typeof fn === 'function' ? fn() : fn;
|
|
216
|
+
replacements.push({ placeholder: match[0], resolved: String(generated) });
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
replacements.push({ placeholder: match[0], resolved: expr });
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Unknown — leave as-is
|
|
224
|
+
replacements.push({ placeholder: match[0], resolved: match[0] });
|
|
225
|
+
}
|
|
226
|
+
let result = value;
|
|
227
|
+
for (const { placeholder, resolved } of replacements) {
|
|
228
|
+
result = result.replace(placeholder, resolved);
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
// ─── Resolve {{faker.*}} and {{scope:*}} templates in body values ─────────────
|
|
233
|
+
async function resolveBodyTemplates(body) {
|
|
234
|
+
const resolved = {};
|
|
235
|
+
for (const [key, value] of Object.entries(body)) {
|
|
236
|
+
resolved[key] = typeof value === 'string'
|
|
237
|
+
? await resolveTemplateString(value)
|
|
238
|
+
: value;
|
|
239
|
+
}
|
|
240
|
+
return resolved;
|
|
241
|
+
}
|
|
242
|
+
// ─── Resolve memory injections into request headers/body ─────────────────────
|
|
243
|
+
// inject values support two forms:
|
|
244
|
+
// 1. Plain memory key: "workflow:accessToken" (legacy, direct lookup)
|
|
245
|
+
// 2. Template string: "Bearer {{workflow:accessToken}}" (resolved via resolveTemplateString)
|
|
246
|
+
async function resolveInjections(inject, memory, allMemory) {
|
|
247
|
+
const headers = {};
|
|
248
|
+
const body = {};
|
|
249
|
+
if (!inject)
|
|
250
|
+
return { headers, body };
|
|
251
|
+
for (const [target, memKeyOrTemplate] of Object.entries(inject)) {
|
|
252
|
+
let strValue;
|
|
253
|
+
if (memKeyOrTemplate.includes('{{')) {
|
|
254
|
+
// Template mode: resolve {{...}} placeholders (supports Bearer prefix etc.)
|
|
255
|
+
strValue = await resolveTemplateString(memKeyOrTemplate);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
// Legacy mode: treat as a direct "scope:key" memory reference
|
|
259
|
+
const [scope, key] = memKeyOrTemplate.includes(':')
|
|
260
|
+
? memKeyOrTemplate.split(':', 2)
|
|
261
|
+
: ['workflow', memKeyOrTemplate];
|
|
262
|
+
const scopedMemory = new memory_1.JeticMemory({ scope });
|
|
263
|
+
const value = await scopedMemory.get(key);
|
|
264
|
+
if (value === null)
|
|
265
|
+
continue;
|
|
266
|
+
strValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
267
|
+
}
|
|
268
|
+
if (target.startsWith('body:')) {
|
|
269
|
+
body[target.slice(5)] = strValue;
|
|
270
|
+
}
|
|
271
|
+
else if (target.startsWith('header:')) {
|
|
272
|
+
headers[target.slice(7)] = strValue;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
// Default: treat as header
|
|
276
|
+
headers[target] = strValue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { headers, body };
|
|
280
|
+
}
|
|
281
|
+
// ─── Capture response values into Jetic memory ───────────────────────────────
|
|
282
|
+
async function captureToMemory(capture, responseBody) {
|
|
283
|
+
const captured = [];
|
|
284
|
+
if (!capture || !responseBody)
|
|
285
|
+
return captured;
|
|
286
|
+
for (const [memKey, responsePath] of Object.entries(capture)) {
|
|
287
|
+
const value = deepGet(responseBody, responsePath);
|
|
288
|
+
if (value === undefined || value === null)
|
|
289
|
+
continue;
|
|
290
|
+
const [scope, key] = memKey.includes(':') ? memKey.split(':', 2) : ['workflow', memKey];
|
|
291
|
+
const memory = new memory_1.JeticMemory({ scope });
|
|
292
|
+
await memory.set(key, value);
|
|
293
|
+
captured.push(`${scope}:${key} ← ${responsePath}`);
|
|
294
|
+
}
|
|
295
|
+
return captured;
|
|
296
|
+
}
|
|
297
|
+
// ─── Capture resolved REQUEST BODY fields into Jetic memory (pre-call) ────────
|
|
298
|
+
async function captureInputToMemory(captureInput, resolvedBody) {
|
|
299
|
+
const captured = [];
|
|
300
|
+
if (!captureInput)
|
|
301
|
+
return captured;
|
|
302
|
+
for (const [memKey, bodyField] of Object.entries(captureInput)) {
|
|
303
|
+
const value = resolvedBody[bodyField];
|
|
304
|
+
if (value === undefined || value === null)
|
|
305
|
+
continue;
|
|
306
|
+
const [scope, key] = memKey.includes(':') ? memKey.split(':', 2) : ['workflow', memKey];
|
|
307
|
+
const memory = new memory_1.JeticMemory({ scope });
|
|
308
|
+
await memory.set(key, value);
|
|
309
|
+
captured.push(`${scope}:${key} ← request.${bodyField}`);
|
|
310
|
+
}
|
|
311
|
+
return captured;
|
|
312
|
+
}
|
|
313
|
+
// ─── Replace path params with values from memory or body ─────────────────────
|
|
314
|
+
async function resolvePathParams(urlPath, body) {
|
|
315
|
+
// e.g. /api/workspaces/:id → try to find :id in memory or body
|
|
316
|
+
return urlPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, param) => {
|
|
317
|
+
// Check body first
|
|
318
|
+
if (body[param] !== undefined)
|
|
319
|
+
return String(body[param]);
|
|
320
|
+
// Fallback to common memory keys
|
|
321
|
+
return `:${param}`; // leave as-is if not found
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
async function executeStep(step, baseUrl, defaultBody) {
|
|
325
|
+
const startTime = Date.now();
|
|
326
|
+
// Resolve memory injections
|
|
327
|
+
const memory = new memory_1.JeticMemory({ scope: 'workflow' });
|
|
328
|
+
const { headers, body: injectedBody } = await resolveInjections(step.inject, memory, memory);
|
|
329
|
+
// Resolve {{faker.*}} / {{workflow:*}} templates in the step body
|
|
330
|
+
const resolvedStepBody = await resolveBodyTemplates(step.body || {});
|
|
331
|
+
// Merge body: step.body overrides defaults, injectedBody adds to body
|
|
332
|
+
const requestBody = { ...defaultBody, ...injectedBody, ...resolvedStepBody };
|
|
333
|
+
// Capture resolved request body fields into memory BEFORE the HTTP call
|
|
334
|
+
// so subsequent steps can reference faker-generated values via {{workflow:key}}
|
|
335
|
+
const inputCaptured = await captureInputToMemory(step.captureInput, requestBody);
|
|
336
|
+
// Resolve path params
|
|
337
|
+
const resolvedPath = await resolvePathParams(step.path, requestBody);
|
|
338
|
+
const url = `${baseUrl.replace(/\/$/, '')}${resolvedPath}`;
|
|
339
|
+
const expectedStatus = step.expectStatus ?? 200;
|
|
340
|
+
try {
|
|
341
|
+
const fetchOptions = {
|
|
342
|
+
method: step.method.toUpperCase(),
|
|
343
|
+
headers: {
|
|
344
|
+
'Content-Type': 'application/json',
|
|
345
|
+
...headers,
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
// Only add body for non-GET/HEAD requests
|
|
349
|
+
if (!['GET', 'HEAD'].includes(step.method.toUpperCase()) && Object.keys(requestBody).length > 0) {
|
|
350
|
+
fetchOptions.body = JSON.stringify(requestBody);
|
|
351
|
+
}
|
|
352
|
+
// Add query params for GET requests
|
|
353
|
+
let finalUrl = url;
|
|
354
|
+
if (['GET', 'HEAD'].includes(step.method.toUpperCase()) && Object.keys(requestBody).length > 0) {
|
|
355
|
+
const params = new URLSearchParams();
|
|
356
|
+
for (const [k, v] of Object.entries(requestBody)) {
|
|
357
|
+
if (v !== undefined && v !== null)
|
|
358
|
+
params.set(k, String(v));
|
|
359
|
+
}
|
|
360
|
+
finalUrl = `${url}?${params.toString()}`;
|
|
361
|
+
}
|
|
362
|
+
const response = await fetch(finalUrl, fetchOptions);
|
|
363
|
+
const durationMs = Date.now() - startTime;
|
|
364
|
+
let responseBody = null;
|
|
365
|
+
const contentType = response.headers.get('content-type') || '';
|
|
366
|
+
if (contentType.includes('application/json')) {
|
|
367
|
+
try {
|
|
368
|
+
responseBody = await response.json();
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
responseBody = null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
responseBody = await response.text();
|
|
376
|
+
}
|
|
377
|
+
const passed = response.status === expectedStatus || (response.status >= 200 && response.status < 300 && expectedStatus >= 200 && expectedStatus < 300);
|
|
378
|
+
// Capture response fields into memory
|
|
379
|
+
const captured = passed
|
|
380
|
+
? await captureToMemory(step.capture, responseBody)
|
|
381
|
+
: [];
|
|
382
|
+
return {
|
|
383
|
+
step,
|
|
384
|
+
status: response.status,
|
|
385
|
+
responseBody,
|
|
386
|
+
durationMs,
|
|
387
|
+
passed,
|
|
388
|
+
captured: [...inputCaptured, ...captured],
|
|
389
|
+
injected: headers,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
return {
|
|
394
|
+
step,
|
|
395
|
+
status: 0,
|
|
396
|
+
responseBody: null,
|
|
397
|
+
durationMs: Date.now() - startTime,
|
|
398
|
+
passed: false,
|
|
399
|
+
captured: inputCaptured,
|
|
400
|
+
error: err.message || String(err),
|
|
401
|
+
injected: headers,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// ─── Rendering helpers ────────────────────────────────────────────────────────
|
|
406
|
+
function formatStatus(status) {
|
|
407
|
+
if (status === 0)
|
|
408
|
+
return `${c.dim}NO RESPONSE${c.reset}`;
|
|
409
|
+
if (status >= 200 && status < 300)
|
|
410
|
+
return `${c.green}${status}${c.reset}`;
|
|
411
|
+
if (status >= 300 && status < 400)
|
|
412
|
+
return `${c.yellow}${status}${c.reset}`;
|
|
413
|
+
if (status >= 400 && status < 500)
|
|
414
|
+
return `${c.red}${status}${c.reset}`;
|
|
415
|
+
return `${c.red}${c.bold}${status}${c.reset}`;
|
|
416
|
+
}
|
|
417
|
+
function getMethodColor(method) {
|
|
418
|
+
switch (method.toUpperCase()) {
|
|
419
|
+
case 'GET': return c.green;
|
|
420
|
+
case 'POST': return c.yellow;
|
|
421
|
+
case 'PUT': return c.cyan;
|
|
422
|
+
case 'PATCH': return c.magenta;
|
|
423
|
+
case 'DELETE': return c.red;
|
|
424
|
+
default: return c.white;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
function renderStepResult(result, index, total) {
|
|
428
|
+
const icon = result.error ? CROSS : (result.passed ? TICK : CROSS);
|
|
429
|
+
const mc = getMethodColor(result.step.method);
|
|
430
|
+
const method = `${mc}${result.step.method.padEnd(6)}${c.reset}`;
|
|
431
|
+
const statusStr = formatStatus(result.status);
|
|
432
|
+
const timeStr = `${c.dim}${result.durationMs}ms${c.reset}`;
|
|
433
|
+
console.log(` ${icon} ${c.bold}Step ${index + 1}/${total}${c.reset} ${method} ${c.bold}${result.step.path}${c.reset} ${statusStr} ${timeStr}`);
|
|
434
|
+
if (result.step.name) {
|
|
435
|
+
console.log(` ${CHAIN} ${c.italic}${c.dim}${result.step.name}${c.reset}`);
|
|
436
|
+
}
|
|
437
|
+
// Show injected headers
|
|
438
|
+
if (Object.keys(result.injected).length > 0) {
|
|
439
|
+
for (const [k, v] of Object.entries(result.injected)) {
|
|
440
|
+
const preview = v.length > 50 ? v.substring(0, 47) + '...' : v;
|
|
441
|
+
console.log(` ${CHAIN} ${c.dim}📥 inject ${k}: ${preview}${c.reset}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Show captured memory
|
|
445
|
+
if (result.captured.length > 0) {
|
|
446
|
+
for (const cap of result.captured) {
|
|
447
|
+
console.log(` ${CHAIN} ${c.green}💾 captured ${cap}${c.reset}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Show error
|
|
451
|
+
if (result.error) {
|
|
452
|
+
console.log(` ${CHAIN} ${c.red}Error: ${result.error}${c.reset}`);
|
|
453
|
+
}
|
|
454
|
+
// Show response body preview on failure
|
|
455
|
+
if (!result.passed && result.responseBody && !result.error) {
|
|
456
|
+
const preview = typeof result.responseBody === 'string'
|
|
457
|
+
? result.responseBody
|
|
458
|
+
: JSON.stringify(result.responseBody, null, 2);
|
|
459
|
+
console.log(` ${CHAIN} ${c.red}Response: ${preview}${c.reset}`);
|
|
460
|
+
}
|
|
461
|
+
console.log('');
|
|
462
|
+
}
|
|
463
|
+
function renderWorkflowHeader(workflow) {
|
|
464
|
+
console.log('');
|
|
465
|
+
console.log(` ${c.bold}${workflow.name}${c.reset}`);
|
|
466
|
+
if (workflow.description) {
|
|
467
|
+
console.log(` ${c.dim}${workflow.description}${c.reset}`);
|
|
468
|
+
}
|
|
469
|
+
console.log('');
|
|
470
|
+
console.log(` ${c.dim}${workflow.steps.length} steps${c.reset}`);
|
|
471
|
+
// Print the workflow graph
|
|
472
|
+
for (let i = 0; i < workflow.steps.length; i++) {
|
|
473
|
+
const step = workflow.steps[i];
|
|
474
|
+
const mc = getMethodColor(step.method);
|
|
475
|
+
const label = `${mc}${step.method}${c.reset} ${step.path}`;
|
|
476
|
+
if (i === 0) {
|
|
477
|
+
console.log(` ${c.cyan}┌─${c.reset} ${c.bold}${label}${c.reset} ${c.dim}${step.name}${c.reset}`);
|
|
478
|
+
}
|
|
479
|
+
else if (i === workflow.steps.length - 1) {
|
|
480
|
+
console.log(` ${c.cyan}└─${c.reset} ${label} ${c.dim}${step.name}${c.reset}`);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
console.log(` ${c.cyan}├─${c.reset} ${label} ${c.dim}${step.name}${c.reset}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
console.log('');
|
|
487
|
+
}
|
|
488
|
+
function renderWorkflowSummary(results, totalMs) {
|
|
489
|
+
const passed = results.filter((r) => r.passed).length;
|
|
490
|
+
const failed = results.filter((r) => !r.passed).length;
|
|
491
|
+
const elapsed = (totalMs / 1000).toFixed(2);
|
|
492
|
+
//console.log(SEP);
|
|
493
|
+
const allPassed = failed === 0;
|
|
494
|
+
const icon = allPassed ? `${c.green}✅${c.reset}` : `${c.red}❌${c.reset}`;
|
|
495
|
+
const label = allPassed
|
|
496
|
+
? `${c.green}${c.bold}Workflow Complete!${c.reset}`
|
|
497
|
+
: `${c.red}${c.bold}Workflow Failed${c.reset}`;
|
|
498
|
+
console.log(` ${icon} ${label} ${c.dim}${elapsed}s${c.reset}`);
|
|
499
|
+
console.log('');
|
|
500
|
+
const parts = [];
|
|
501
|
+
if (passed > 0)
|
|
502
|
+
parts.push(`${c.green}${passed} passed${c.reset}`);
|
|
503
|
+
if (failed > 0)
|
|
504
|
+
parts.push(`${c.red}${failed} failed${c.reset}`);
|
|
505
|
+
console.log(` ${parts.join(` ${c.dim}│${c.reset} `)}`);
|
|
506
|
+
// console.log(SEP);
|
|
507
|
+
console.log('');
|
|
508
|
+
}
|
|
509
|
+
// ─── Environment selector (simple for workflow) ───────────────────────────────
|
|
510
|
+
async function pickEnvironment(model) {
|
|
511
|
+
const envs = model.environments || [];
|
|
512
|
+
if (envs.length === 0) {
|
|
513
|
+
console.log(` ${c.yellow}⚠${c.reset} No environments in model.json. Using ${c.bold}http://localhost:3000${c.reset}\n`);
|
|
514
|
+
return 'http://localhost:3000';
|
|
515
|
+
}
|
|
516
|
+
if (envs.length === 1) {
|
|
517
|
+
const env = envs[0];
|
|
518
|
+
console.log(` ${c.cyan}🌍${c.reset} Environment: ${c.bold}${env.name}${c.reset} ${c.dim}${env.baseUrl}${c.reset}\n`);
|
|
519
|
+
return env.baseUrl;
|
|
520
|
+
}
|
|
521
|
+
// Default to first (non-interactive for now; extend with selector if desired)
|
|
522
|
+
const env = envs[0];
|
|
523
|
+
console.log(` ${c.cyan}🌍${c.reset} Using environment: ${c.bold}${env.name}${c.reset} ${c.dim}${env.baseUrl}${c.reset}`);
|
|
524
|
+
console.log(` ${c.dim}(Pass --env <name> to choose a different environment)${c.reset}\n`);
|
|
525
|
+
return env.baseUrl;
|
|
526
|
+
}
|
|
527
|
+
// ─── Command: jetic simulate workflow ────────────────────────────────────────
|
|
528
|
+
exports.simulateWorkflowCommand = new commander_1.Command('workflow')
|
|
529
|
+
.description('Generate and execute a full end-to-end workflow test from model.json')
|
|
530
|
+
.option('--goal <text>', 'Describe the workflow goal', 'Full user journey')
|
|
531
|
+
.option('--env <name>', 'Environment name to use from model.json')
|
|
532
|
+
.option('--workflow <file>', 'Use an existing workflow.json instead of generating')
|
|
533
|
+
.option('--generate-only', 'Only generate workflow.json without executing')
|
|
534
|
+
.option('--clear-memory', 'Clear Jetic memory before running', false)
|
|
535
|
+
.action(async (options) => {
|
|
536
|
+
console.log('');
|
|
537
|
+
console.log(` ${c.bgCyan}${c.black}${c.bold} JETIC ${c.reset} ${c.cyan}${c.bold}Workflow Runner${c.reset}`);
|
|
538
|
+
// console.log(` ${SEP}`);
|
|
539
|
+
console.log('');
|
|
540
|
+
// ── Load config & model ────────────────────────────────────────────
|
|
541
|
+
const config = (0, core_1.loadConfig)();
|
|
542
|
+
const modelPath = path.join(config.jeticDir, 'model.json');
|
|
543
|
+
const model = (0, core_1.readJsonSync)(modelPath);
|
|
544
|
+
if (!model) {
|
|
545
|
+
console.error(` ${c.red}✗${c.reset} No model.json found. Run ${c.bold}jetic scan${c.reset} first.\n`);
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
console.log(` ${c.dim}Model: ${model.project.name} • ${model.endpoints.length} endpoints${c.reset}\n`);
|
|
549
|
+
// ── Optionally clear memory ────────────────────────────────────────
|
|
550
|
+
if (options.clearMemory) {
|
|
551
|
+
memory_1.JeticMemory.clearAllMemory();
|
|
552
|
+
console.log(` ${c.yellow}🧹${c.reset} Jetic memory cleared\n`);
|
|
553
|
+
}
|
|
554
|
+
// ── Load or generate workflow ──────────────────────────────────────
|
|
555
|
+
let workflow;
|
|
556
|
+
const workflowPath = options.workflow
|
|
557
|
+
? path.resolve(options.workflow)
|
|
558
|
+
: path.join(config.jeticDir, 'workflow.json');
|
|
559
|
+
if (options.workflow && fs.existsSync(workflowPath)) {
|
|
560
|
+
// Use provided workflow file
|
|
561
|
+
try {
|
|
562
|
+
workflow = JSON.parse(fs.readFileSync(workflowPath, 'utf-8'));
|
|
563
|
+
console.log(` ${TICK} Loaded workflow: ${c.bold}${workflow.name}${c.reset} ${c.dim}(${workflowPath})${c.reset}\n`);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
console.error(` ${c.red}✗${c.reset} Failed to parse workflow file: ${workflowPath}\n`);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
// Generate with AI
|
|
572
|
+
const spinner = new Spinner();
|
|
573
|
+
spinner.start(`${c.magenta}🤖 AI generating workflow for: "${options.goal}"...${c.reset}`);
|
|
574
|
+
try {
|
|
575
|
+
workflow = await generateWorkflow(model, config, options.goal);
|
|
576
|
+
spinner.stop(` ${TICK} Workflow generated: ${c.bold}${workflow.name}${c.reset} ${c.dim}(${workflow.steps.length} steps)${c.reset}`);
|
|
577
|
+
// Save workflow.json
|
|
578
|
+
fs.mkdirSync(path.dirname(workflowPath), { recursive: true });
|
|
579
|
+
fs.writeFileSync(workflowPath, JSON.stringify(workflow, null, 2), 'utf-8');
|
|
580
|
+
console.log(` ${c.dim} Saved to ${workflowPath}${c.reset}\n`);
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
spinner.stop(` ${c.red}✗${c.reset} Failed to generate workflow`);
|
|
584
|
+
console.error(`\n ${c.red}${err.message}${c.reset}\n`);
|
|
585
|
+
console.error(` ${c.dim}Make sure AI is configured: jetic config ai${c.reset}\n`);
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// ── Generate-only mode ─────────────────────────────────────────────
|
|
590
|
+
if (options.generateOnly) {
|
|
591
|
+
renderWorkflowHeader(workflow);
|
|
592
|
+
console.log(` ${c.green}✓${c.reset} Workflow saved. Run without ${c.bold}--generate-only${c.reset} to execute.\n`);
|
|
593
|
+
process.exit(0);
|
|
594
|
+
}
|
|
595
|
+
// ── Pick environment ───────────────────────────────────────────────
|
|
596
|
+
let baseUrl;
|
|
597
|
+
const envs = model.environments || [];
|
|
598
|
+
if (options.env) {
|
|
599
|
+
const found = envs.find((e) => e.name === options.env);
|
|
600
|
+
if (!found) {
|
|
601
|
+
console.error(` ${c.red}✗${c.reset} Environment "${options.env}" not found in model.json\n`);
|
|
602
|
+
console.error(` ${c.dim}Available: ${envs.map((e) => e.name).join(', ')}${c.reset}\n`);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
}
|
|
605
|
+
baseUrl = found.baseUrl;
|
|
606
|
+
console.log(` ${c.cyan}🌍${c.reset} Environment: ${c.bold}${found.name}${c.reset} ${c.dim}${found.baseUrl}${c.reset}\n`);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
baseUrl = await pickEnvironment(model);
|
|
610
|
+
}
|
|
611
|
+
// ── Check Backend Health ───────────────────────────────────────────
|
|
612
|
+
const checkSpinner = new Spinner();
|
|
613
|
+
checkSpinner.start(`Checking if backend is active at ${baseUrl}...`);
|
|
614
|
+
try {
|
|
615
|
+
await fetch(baseUrl);
|
|
616
|
+
checkSpinner.stop(` ${c.green}✓${c.reset} Backend is active`);
|
|
617
|
+
console.log('');
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
checkSpinner.stop();
|
|
621
|
+
console.log(` ${c.red}✗${c.reset} Backend is unreachable at ${c.bold}${baseUrl}${c.reset}`);
|
|
622
|
+
console.log(` ${c.yellow}⚠${c.reset} Please run/initialize your backend project and try again.\n`);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
// ── Render workflow graph ──────────────────────────────────────────
|
|
626
|
+
renderWorkflowHeader(workflow);
|
|
627
|
+
console.log(` ${c.magenta}🚀${c.reset} Executing workflow steps...\n`);
|
|
628
|
+
// ── Execute steps ──────────────────────────────────────────────────
|
|
629
|
+
const results = [];
|
|
630
|
+
const totalStart = Date.now();
|
|
631
|
+
let stopOnFailure = false;
|
|
632
|
+
for (let i = 0; i < workflow.steps.length; i++) {
|
|
633
|
+
const step = workflow.steps[i];
|
|
634
|
+
const stepLabel = `${c.dim}${i + 1}/${workflow.steps.length}${c.reset} ${getMethodColor(step.method)}${step.method}${c.reset} ${step.path}`;
|
|
635
|
+
const spinner = new Spinner();
|
|
636
|
+
spinner.start(`${stepLabel} ${c.dim}${step.name}${c.reset}...`);
|
|
637
|
+
const result = await executeStep(step, baseUrl, {});
|
|
638
|
+
results.push(result);
|
|
639
|
+
const icon = result.passed ? TICK : CROSS;
|
|
640
|
+
spinner.stop(` ${icon} ${stepLabel} ${formatStatus(result.status)} ${c.dim}${result.durationMs}ms${c.reset}`);
|
|
641
|
+
renderStepResult(result, i, workflow.steps.length);
|
|
642
|
+
// If a critical step fails (auth steps), stop early
|
|
643
|
+
if (!result.passed && (step.path.includes('/login') ||
|
|
644
|
+
step.path.includes('/register') ||
|
|
645
|
+
step.path.includes('/auth'))) {
|
|
646
|
+
console.log(` ${c.red}${c.bold}⚠ Auth step failed — stopping workflow to prevent cascading failures.${c.reset}\n`);
|
|
647
|
+
stopOnFailure = true;
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// ── Summary ────────────────────────────────────────────────────────
|
|
652
|
+
renderWorkflowSummary(results, Date.now() - totalStart);
|
|
653
|
+
// Show memory state
|
|
654
|
+
const memoryState = memory_1.JeticMemory.getAllMemory();
|
|
655
|
+
const memKeys = Object.entries(memoryState).flatMap(([scope, keys]) => Object.keys(keys).map((k) => `${scope}:${k}`));
|
|
656
|
+
if (memKeys.length > 0) {
|
|
657
|
+
console.log(` ${c.cyan}💾${c.reset} ${c.bold}Jetic Memory${c.reset} ${c.dim}(captured during run)${c.reset}`);
|
|
658
|
+
for (const key of memKeys) {
|
|
659
|
+
const [scope, k] = key.split(':', 2);
|
|
660
|
+
const val = memoryState[scope][k];
|
|
661
|
+
const preview = typeof val === 'string' && val.length > 60
|
|
662
|
+
? val.substring(0, 57) + '...'
|
|
663
|
+
: String(val);
|
|
664
|
+
console.log(` ${c.dim} ${key}${c.reset} = ${c.cyan}${preview}${c.reset}`);
|
|
665
|
+
}
|
|
666
|
+
console.log('');
|
|
667
|
+
}
|
|
668
|
+
const failed = results.filter((r) => !r.passed).length;
|
|
669
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
670
|
+
});
|
|
671
|
+
//# sourceMappingURL=simulate-workflow.js.map
|