infernoflow 0.29.0 → 0.31.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/bin/infernoflow.mjs +27 -0
- package/dist/lib/commands/ai.mjs +370 -0
- package/dist/lib/commands/changelog.mjs +5 -1
- package/dist/lib/commands/demo.mjs +569 -0
- package/dist/lib/commands/explain.mjs +2 -2
- package/dist/lib/commands/review.mjs +4 -4
- package/dist/lib/commands/test.mjs +363 -0
- package/package.json +1 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow test
|
|
3
|
+
*
|
|
4
|
+
* Run registered scenarios for a capability and report pass/fail.
|
|
5
|
+
* When no test runner is configured, generates a minimal ad-hoc harness
|
|
6
|
+
* from the scenario + source file and runs it with Node.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* infernoflow test Run all caps with scenarios
|
|
10
|
+
* infernoflow test user-auth Run scenarios for one cap
|
|
11
|
+
* infernoflow test user-auth payment-process Run multiple caps
|
|
12
|
+
* infernoflow test --all Run every cap (including no-scenario)
|
|
13
|
+
* infernoflow test --generate user-auth Print generated test file, don't run
|
|
14
|
+
* infernoflow test --json Machine-readable output
|
|
15
|
+
* infernoflow test --bail Stop on first failure
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
22
|
+
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
23
|
+
|
|
24
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function loadJson(p) {
|
|
27
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
28
|
+
catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stability(cap) { return cap?.stability || "experimental"; }
|
|
32
|
+
|
|
33
|
+
const PASS_ICON = green("✓");
|
|
34
|
+
const FAIL_ICON = red("✗");
|
|
35
|
+
const SKIP_ICON = gray("○");
|
|
36
|
+
|
|
37
|
+
// ── scenario loader ───────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function loadScenarios(capId, infernoDir) {
|
|
40
|
+
const dir = path.join(infernoDir, "scenarios");
|
|
41
|
+
if (!fs.existsSync(dir)) return [];
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const f of fs.readdirSync(dir)) {
|
|
44
|
+
if (!f.endsWith(".json")) continue;
|
|
45
|
+
try {
|
|
46
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
47
|
+
const covered = s.capabilitiesCovered || s.capabilities || [];
|
|
48
|
+
if (covered.some(c => c.toLowerCase() === capId.toLowerCase())) {
|
|
49
|
+
found.push({ ...s, _file: f });
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── test runner detection ─────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function detectTestRunner(cwd) {
|
|
59
|
+
const pkg = loadJson(path.join(cwd, "package.json"));
|
|
60
|
+
if (!pkg) return null;
|
|
61
|
+
|
|
62
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
63
|
+
if (deps?.vitest) return "vitest";
|
|
64
|
+
if (deps?.jest) return "jest";
|
|
65
|
+
if (deps?.mocha) return "mocha";
|
|
66
|
+
if (pkg.scripts?.test && !pkg.scripts.test.includes("no test")) {
|
|
67
|
+
return { custom: pkg.scripts.test };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── ad-hoc test generator ─────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function generateAdHocTest(capId, cap, scenario, scanEntry) {
|
|
75
|
+
const name = cap.name || cap.title || capId;
|
|
76
|
+
const desc = cap.description || "(no description)";
|
|
77
|
+
const files = scanEntry?.codeAnalysis?.sourceFiles || [];
|
|
78
|
+
const fns = scanEntry?.codeAnalysis?.functions || [];
|
|
79
|
+
const steps = scenario?.steps || scenario?.actions || [];
|
|
80
|
+
const expects = scenario?.expects || scenario?.assertions || [];
|
|
81
|
+
|
|
82
|
+
const lines = [
|
|
83
|
+
`// Auto-generated smoke test for: ${capId}`,
|
|
84
|
+
`// Generated by infernoflow test — edit as needed`,
|
|
85
|
+
``,
|
|
86
|
+
`import { strict as assert } from "node:assert";`,
|
|
87
|
+
``,
|
|
88
|
+
`// Capability: ${name}`,
|
|
89
|
+
`// ${desc}`,
|
|
90
|
+
``,
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// Import source if we know the file
|
|
94
|
+
if (files.length) {
|
|
95
|
+
lines.push(`// Source: ${files[0]}`);
|
|
96
|
+
lines.push(`// import { ${fns[0] || capId} } from "./${path.basename(files[0])}";`);
|
|
97
|
+
lines.push(``);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lines.push(`async function run() {`);
|
|
101
|
+
lines.push(` const results = [];`);
|
|
102
|
+
lines.push(``);
|
|
103
|
+
|
|
104
|
+
// Generate test cases from scenario steps
|
|
105
|
+
if (steps.length) {
|
|
106
|
+
steps.forEach((step, i) => {
|
|
107
|
+
const desc = typeof step === "string" ? step : (step.action || step.description || `step ${i + 1}`);
|
|
108
|
+
lines.push(` // Step ${i + 1}: ${desc}`);
|
|
109
|
+
lines.push(` results.push({ step: ${JSON.stringify(desc)}, status: "manual" });`);
|
|
110
|
+
lines.push(``);
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
lines.push(` // No explicit steps — running basic smoke test`);
|
|
114
|
+
lines.push(` results.push({ step: "capability exists", status: "pass" });`);
|
|
115
|
+
lines.push(``);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Add assertion checks
|
|
119
|
+
if (expects.length) {
|
|
120
|
+
expects.forEach((exp, i) => {
|
|
121
|
+
const desc = typeof exp === "string" ? exp : (exp.condition || exp.description || `assertion ${i + 1}`);
|
|
122
|
+
lines.push(` // Assert: ${desc}`);
|
|
123
|
+
lines.push(` // assert(condition, ${JSON.stringify(desc)});`);
|
|
124
|
+
});
|
|
125
|
+
lines.push(``);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
lines.push(` return results;`);
|
|
129
|
+
lines.push(`}`);
|
|
130
|
+
lines.push(``);
|
|
131
|
+
lines.push(`run().then(results => {`);
|
|
132
|
+
lines.push(` const failed = results.filter(r => r.status === "fail");`);
|
|
133
|
+
lines.push(` results.forEach(r => {`);
|
|
134
|
+
lines.push(` const icon = r.status === "pass" ? "✓" : r.status === "fail" ? "✗" : "○";`);
|
|
135
|
+
lines.push(` console.log(\` \${icon} \${r.step}\`);`);
|
|
136
|
+
lines.push(` });`);
|
|
137
|
+
lines.push(` if (failed.length) { console.error(\`\\n \${failed.length} failed\`); process.exit(1); }`);
|
|
138
|
+
lines.push(` else console.log(\`\\n All steps passed\`);`);
|
|
139
|
+
lines.push(`}).catch(err => { console.error(err); process.exit(1); });`);
|
|
140
|
+
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── run a single scenario ─────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function runScenario(capId, cap, scenario, scanEntry, cwd) {
|
|
147
|
+
const scenarioId = scenario?.scenarioId || scenario?.id || scenario?._file?.replace(".json", "") || "unnamed";
|
|
148
|
+
const runner = detectTestRunner(cwd);
|
|
149
|
+
|
|
150
|
+
// If there are testFiles registered in the scenario, run those
|
|
151
|
+
const testFiles = scenario?.testFiles || scenario?.testFile ? [scenario.testFile].flat().filter(Boolean) : [];
|
|
152
|
+
if (testFiles.length) {
|
|
153
|
+
for (const tf of testFiles) {
|
|
154
|
+
const absPath = path.resolve(cwd, tf);
|
|
155
|
+
if (!fs.existsSync(absPath)) {
|
|
156
|
+
return { scenarioId, status: "skip", reason: `test file not found: ${tf}` };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Run with detected test runner
|
|
161
|
+
if (runner && typeof runner === "string") {
|
|
162
|
+
const cmd = runner === "vitest"
|
|
163
|
+
? `npx vitest run ${testFiles.join(" ")} --reporter verbose`
|
|
164
|
+
: runner === "jest"
|
|
165
|
+
? `npx jest ${testFiles.join(" ")} --no-coverage`
|
|
166
|
+
: runner === "mocha"
|
|
167
|
+
? `npx mocha ${testFiles.join(" ")}`
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
if (cmd) {
|
|
171
|
+
const result = spawnSync(cmd, { shell: true, cwd, encoding: "utf8", timeout: 60_000 });
|
|
172
|
+
const passed = result.status === 0;
|
|
173
|
+
return {
|
|
174
|
+
scenarioId,
|
|
175
|
+
status: passed ? "pass" : "fail",
|
|
176
|
+
output: (result.stdout || "") + (result.stderr || ""),
|
|
177
|
+
runner,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Custom script runner
|
|
183
|
+
if (runner?.custom) {
|
|
184
|
+
const result = spawnSync(runner.custom, { shell: true, cwd, encoding: "utf8", timeout: 60_000 });
|
|
185
|
+
return {
|
|
186
|
+
scenarioId,
|
|
187
|
+
status: result.status === 0 ? "pass" : "fail",
|
|
188
|
+
output: (result.stdout || "") + (result.stderr || ""),
|
|
189
|
+
runner: "npm test",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// No test files — generate and run ad-hoc test
|
|
195
|
+
const testSrc = generateAdHocTest(capId, cap, scenario, scanEntry);
|
|
196
|
+
const tmpFile = path.join(os.tmpdir(), `infernoflow-test-${capId}-${Date.now()}.mjs`);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
fs.writeFileSync(tmpFile, testSrc);
|
|
200
|
+
const result = spawnSync(process.execPath, [tmpFile], {
|
|
201
|
+
cwd,
|
|
202
|
+
encoding: "utf8",
|
|
203
|
+
timeout: 30_000,
|
|
204
|
+
});
|
|
205
|
+
const passed = result.status === 0;
|
|
206
|
+
return {
|
|
207
|
+
scenarioId,
|
|
208
|
+
status: passed ? "pass" : "fail",
|
|
209
|
+
output: (result.stdout || "") + (result.stderr || ""),
|
|
210
|
+
runner: "ad-hoc",
|
|
211
|
+
generated: true,
|
|
212
|
+
};
|
|
213
|
+
} finally {
|
|
214
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── print result ──────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
function printCapResult(capId, cap, results, verbose) {
|
|
221
|
+
const level = stability(cap);
|
|
222
|
+
const badge = level === "frozen" ? red("frozen") : level === "stable" ? yellow("stable") : gray("experimental");
|
|
223
|
+
const total = results.length;
|
|
224
|
+
const passed = results.filter(r => r.status === "pass").length;
|
|
225
|
+
const failed = results.filter(r => r.status === "fail").length;
|
|
226
|
+
const skipped = results.filter(r => r.status === "skip").length;
|
|
227
|
+
|
|
228
|
+
const statusIcon = failed > 0 ? red("✗") : passed > 0 ? green("✓") : gray("○");
|
|
229
|
+
console.log(` ${statusIcon} ${bold(capId)} ${gray(`[${badge}]`)}`);
|
|
230
|
+
|
|
231
|
+
for (const r of results) {
|
|
232
|
+
const icon = r.status === "pass" ? PASS_ICON : r.status === "fail" ? FAIL_ICON : SKIP_ICON;
|
|
233
|
+
const note = r.generated ? gray(" (generated)") : r.runner ? gray(` (${r.runner})`) : "";
|
|
234
|
+
console.log(` ${icon} ${gray(r.scenarioId)}${note}`);
|
|
235
|
+
if (r.reason) console.log(` ${gray(r.reason)}`);
|
|
236
|
+
if (verbose && r.output) {
|
|
237
|
+
const trimmed = r.output.trim().split("\n").slice(0, 10).join("\n");
|
|
238
|
+
console.log(trimmed.split("\n").map(l => ` ${gray(l)}`).join("\n"));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { total, passed, failed, skipped };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── entry point ───────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export async function testCommand(rawArgs) {
|
|
248
|
+
const args = (rawArgs || []).slice(1);
|
|
249
|
+
const jsonMode = args.includes("--json");
|
|
250
|
+
const bail = args.includes("--bail");
|
|
251
|
+
const generateMode = args.includes("--generate");
|
|
252
|
+
const runAll = args.includes("--all");
|
|
253
|
+
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
254
|
+
|
|
255
|
+
const capArgs = args.filter(a => !a.startsWith("--") && a !== "-v");
|
|
256
|
+
|
|
257
|
+
const cwd = process.cwd();
|
|
258
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
259
|
+
|
|
260
|
+
if (!fs.existsSync(infernoDir)) {
|
|
261
|
+
if (!jsonMode) console.error(red("✗ inferno/ not found. Run: infernoflow init"));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Load capabilities
|
|
266
|
+
let allCaps = [];
|
|
267
|
+
const rawCaps = loadJson(path.join(infernoDir, "capabilities.json"));
|
|
268
|
+
if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
|
|
269
|
+
|
|
270
|
+
const scanData = loadJson(path.join(infernoDir, "scan.json"));
|
|
271
|
+
|
|
272
|
+
// Determine which caps to test
|
|
273
|
+
let targetCaps;
|
|
274
|
+
if (capArgs.length) {
|
|
275
|
+
targetCaps = capArgs.map(id => {
|
|
276
|
+
const cap = allCaps.find(c => c.id === id);
|
|
277
|
+
if (!cap) {
|
|
278
|
+
if (!jsonMode) console.error(red(`✗ Capability "${id}" not found in capabilities.json`));
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
return cap;
|
|
282
|
+
});
|
|
283
|
+
} else if (runAll) {
|
|
284
|
+
targetCaps = allCaps;
|
|
285
|
+
} else {
|
|
286
|
+
// Default: caps that have scenarios registered
|
|
287
|
+
targetCaps = allCaps.filter(cap => loadScenarios(cap.id, infernoDir).length > 0);
|
|
288
|
+
if (!targetCaps.length) {
|
|
289
|
+
if (!jsonMode) {
|
|
290
|
+
console.log();
|
|
291
|
+
console.log(` ${gray("No scenarios registered. Use --all to test all capabilities, or")} `);
|
|
292
|
+
console.log(` ${gray("add scenarios to inferno/scenarios/ first.")}`);
|
|
293
|
+
console.log();
|
|
294
|
+
}
|
|
295
|
+
process.exit(0);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!jsonMode) {
|
|
300
|
+
console.log();
|
|
301
|
+
console.log(` ${bold("🧪 infernoflow test")}`);
|
|
302
|
+
console.log();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --generate mode: print generated test for first cap
|
|
306
|
+
if (generateMode) {
|
|
307
|
+
const cap = targetCaps[0];
|
|
308
|
+
const scenarios = loadScenarios(cap.id, infernoDir);
|
|
309
|
+
const scenario = scenarios[0] || {};
|
|
310
|
+
const scanEntry = scanData?.capabilities?.find(c => c.id === cap.id);
|
|
311
|
+
const src = generateAdHocTest(cap.id, cap, scenario, scanEntry);
|
|
312
|
+
console.log(src);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Run tests
|
|
317
|
+
const summary = { total: 0, passed: 0, failed: 0, skipped: 0, caps: [] };
|
|
318
|
+
let bailed = false;
|
|
319
|
+
|
|
320
|
+
for (const cap of targetCaps) {
|
|
321
|
+
const scenarios = loadScenarios(cap.id, infernoDir);
|
|
322
|
+
const scanEntry = scanData?.capabilities?.find(c => c.id === cap.id);
|
|
323
|
+
|
|
324
|
+
let capResults = [];
|
|
325
|
+
|
|
326
|
+
if (!scenarios.length) {
|
|
327
|
+
capResults = [{ scenarioId: "(no scenarios)", status: "skip", reason: "register scenarios in inferno/scenarios/" }];
|
|
328
|
+
} else {
|
|
329
|
+
for (const scenario of scenarios) {
|
|
330
|
+
const r = runScenario(cap.id, cap, scenario, scanEntry, cwd);
|
|
331
|
+
capResults.push(r);
|
|
332
|
+
if (bail && r.status === "fail") { bailed = true; break; }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const counts = printCapResult(cap.id, cap, capResults, verbose);
|
|
337
|
+
summary.total += counts.total;
|
|
338
|
+
summary.passed += counts.passed;
|
|
339
|
+
summary.failed += counts.failed;
|
|
340
|
+
summary.skipped += counts.skipped;
|
|
341
|
+
summary.caps.push({ id: cap.id, stability: stability(cap), results: capResults });
|
|
342
|
+
|
|
343
|
+
if (bailed) break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!jsonMode) {
|
|
347
|
+
console.log();
|
|
348
|
+
const statusColor = summary.failed > 0 ? red : summary.passed > 0 ? green : gray;
|
|
349
|
+
console.log(` ${statusColor(bold(String(summary.passed)))} passed ${summary.failed > 0 ? red(bold(String(summary.failed))) : gray("0")} failed ${gray(String(summary.skipped))} skipped`);
|
|
350
|
+
if (bailed) console.log(` ${yellow("(bailed on first failure)")}`);
|
|
351
|
+
console.log();
|
|
352
|
+
if (!summary.failed) {
|
|
353
|
+
console.log(gray(" ── infernoflow test complete"));
|
|
354
|
+
}
|
|
355
|
+
console.log();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (jsonMode) {
|
|
359
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
process.exit(summary.failed > 0 ? 1 : 0);
|
|
363
|
+
}
|