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