voyageai-cli 1.29.0 → 1.30.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/README.md +82 -8
- package/package.json +1 -1
- package/src/commands/benchmark.js +22 -8
- package/src/commands/chat.js +18 -0
- package/src/commands/chunk.js +10 -0
- package/src/commands/demo.js +4 -0
- package/src/commands/embed.js +13 -0
- package/src/commands/estimate.js +3 -0
- package/src/commands/eval.js +6 -0
- package/src/commands/explain.js +2 -0
- package/src/commands/generate.js +2 -0
- package/src/commands/ingest.js +4 -0
- package/src/commands/init.js +2 -0
- package/src/commands/mcp-server.js +2 -0
- package/src/commands/models.js +2 -0
- package/src/commands/ping.js +7 -0
- package/src/commands/pipeline.js +15 -0
- package/src/commands/playground.js +52 -6
- package/src/commands/query.js +16 -0
- package/src/commands/rerank.js +12 -0
- package/src/commands/scaffold.js +2 -0
- package/src/commands/search.js +11 -0
- package/src/commands/similarity.js +9 -0
- package/src/commands/store.js +4 -0
- package/src/commands/workflow.js +286 -0
- package/src/lib/capability-report.js +134 -0
- package/src/lib/chat.js +32 -1
- package/src/lib/config.js +2 -0
- package/src/lib/cost-display.js +107 -0
- package/src/lib/explanations.js +6 -0
- package/src/lib/llm.js +125 -18
- package/src/lib/quality-audit.js +71 -0
- package/src/lib/security/blocked-domains.json +17 -0
- package/src/lib/security-audit.js +198 -0
- package/src/lib/telemetry.js +23 -1
- package/src/lib/workflow-scaffold.js +61 -0
- package/src/lib/workflow-test-runner.js +208 -0
- package/src/lib/workflow.js +128 -2
- package/src/playground/announcements.md +9 -0
- package/src/playground/assets/announcements/appstore.jpg +0 -0
- package/src/playground/assets/announcements/circuits.jpg +0 -0
- package/src/playground/assets/announcements/csvingest.jpg +0 -0
- package/src/playground/assets/announcements/green-wave.jpg +0 -0
- package/src/playground/help/workflow-nodes.js +472 -0
- package/src/playground/index.html +1482 -184
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[
|
|
2
|
+
"evil.com",
|
|
3
|
+
"malware.com",
|
|
4
|
+
"requestbin.com",
|
|
5
|
+
"webhook.site",
|
|
6
|
+
"pipedream.net",
|
|
7
|
+
"hookbin.com",
|
|
8
|
+
"requestcatcher.com",
|
|
9
|
+
"canarytokens.com",
|
|
10
|
+
"burpcollaborator.net",
|
|
11
|
+
"interact.sh",
|
|
12
|
+
"oastify.com",
|
|
13
|
+
"dnslog.cn",
|
|
14
|
+
"ceye.io",
|
|
15
|
+
"bxss.me",
|
|
16
|
+
"xss.ht"
|
|
17
|
+
]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// ════════════════════════════════════════════════════════════════════
|
|
7
|
+
// Blocked Domains
|
|
8
|
+
// ════════════════════════════════════════════════════════════════════
|
|
9
|
+
|
|
10
|
+
const BLOCKED_DOMAINS = new Set(
|
|
11
|
+
JSON.parse(fs.readFileSync(path.join(__dirname, 'security', 'blocked-domains.json'), 'utf8'))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a URL targets a blocked domain.
|
|
16
|
+
* @param {string} url
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function isBlockedDomain(url) {
|
|
20
|
+
try {
|
|
21
|
+
const hostname = new URL(url).hostname;
|
|
22
|
+
return BLOCKED_DOMAINS.has(hostname);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ════════════════════════════════════════════════════════════════════
|
|
29
|
+
// Capability Flags
|
|
30
|
+
// ════════════════════════════════════════════════════════════════════
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract capability flags from a workflow definition.
|
|
34
|
+
* @param {object} definition
|
|
35
|
+
* @returns {Set<string>}
|
|
36
|
+
*/
|
|
37
|
+
function extractCapabilities(definition) {
|
|
38
|
+
const caps = new Set();
|
|
39
|
+
if (!definition || !Array.isArray(definition.steps)) return caps;
|
|
40
|
+
|
|
41
|
+
for (const step of definition.steps) {
|
|
42
|
+
switch (step.tool) {
|
|
43
|
+
case 'http':
|
|
44
|
+
caps.add('NETWORK');
|
|
45
|
+
break;
|
|
46
|
+
case 'ingest':
|
|
47
|
+
caps.add('WRITE_DB');
|
|
48
|
+
break;
|
|
49
|
+
case 'aggregate':
|
|
50
|
+
caps.add('READ_DB');
|
|
51
|
+
if (step.inputs?.allowWrites) caps.add('WRITE_DB');
|
|
52
|
+
if (Array.isArray(step.inputs?.pipeline)) {
|
|
53
|
+
for (const stage of step.inputs.pipeline) {
|
|
54
|
+
const key = Object.keys(stage)[0];
|
|
55
|
+
if (key === '$out' || key === '$merge') caps.add('WRITE_DB');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
case 'generate':
|
|
60
|
+
caps.add('LLM');
|
|
61
|
+
break;
|
|
62
|
+
case 'loop':
|
|
63
|
+
caps.add('LOOP');
|
|
64
|
+
break;
|
|
65
|
+
case 'query':
|
|
66
|
+
case 'search':
|
|
67
|
+
case 'collections':
|
|
68
|
+
caps.add('READ_DB');
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// forEach counts as LOOP
|
|
73
|
+
if (step.forEach) caps.add('LOOP');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return caps;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ════════════════════════════════════════════════════════════════════
|
|
80
|
+
// Security Audit
|
|
81
|
+
// ════════════════════════════════════════════════════════════════════
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run security audit on a workflow definition.
|
|
85
|
+
* @param {object} definition - Parsed workflow JSON
|
|
86
|
+
* @param {string} [packagePath] - Path to the package directory (for package-level checks)
|
|
87
|
+
* @returns {Array<{severity: string, message: string, stepId?: string}>}
|
|
88
|
+
*/
|
|
89
|
+
function securityAudit(definition, packagePath) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
|
|
92
|
+
if (!definition || !Array.isArray(definition.steps)) return findings;
|
|
93
|
+
|
|
94
|
+
for (const step of definition.steps) {
|
|
95
|
+
// HTTP step checks
|
|
96
|
+
if (step.tool === 'http') {
|
|
97
|
+
const url = step.inputs?.url;
|
|
98
|
+
if (typeof url === 'string') {
|
|
99
|
+
if (!url.includes('{{')) {
|
|
100
|
+
// Static URL — check against blocklist
|
|
101
|
+
if (isBlockedDomain(url)) {
|
|
102
|
+
findings.push({ severity: 'critical', message: `HTTP step "${step.id}" targets blocked domain`, stepId: step.id });
|
|
103
|
+
}
|
|
104
|
+
// Flag non-HTTPS
|
|
105
|
+
if (url.startsWith('http://')) {
|
|
106
|
+
findings.push({ severity: 'medium', message: `HTTP step "${step.id}" uses insecure HTTP`, stepId: step.id });
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// Dynamic URLs always flagged for review
|
|
110
|
+
findings.push({ severity: 'high', message: `HTTP step "${step.id}" has dynamic URL (needs review)`, stepId: step.id });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Aggregate: Flag write stages
|
|
116
|
+
if (step.tool === 'aggregate') {
|
|
117
|
+
if (step.inputs?.allowWrites) {
|
|
118
|
+
findings.push({ severity: 'high', message: `Aggregate step "${step.id}" allows write operations ($out/$merge)`, stepId: step.id });
|
|
119
|
+
}
|
|
120
|
+
const pipeline = step.inputs?.pipeline;
|
|
121
|
+
if (Array.isArray(pipeline)) {
|
|
122
|
+
for (const stage of pipeline) {
|
|
123
|
+
const key = Object.keys(stage)[0];
|
|
124
|
+
if (key === '$out' || key === '$merge') {
|
|
125
|
+
findings.push({ severity: 'critical', message: `Aggregate step "${step.id}" contains ${key} stage`, stepId: step.id });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Generate: Check for prompt injection patterns
|
|
132
|
+
if (step.tool === 'generate') {
|
|
133
|
+
const prompt = step.inputs?.prompt || '';
|
|
134
|
+
const systemPrompt = step.inputs?.systemPrompt || '';
|
|
135
|
+
for (const text of [prompt, systemPrompt]) {
|
|
136
|
+
if (typeof text === 'string') {
|
|
137
|
+
if (/ignore\s+(previous|all)\s+instructions/i.test(text)) {
|
|
138
|
+
findings.push({ severity: 'high', message: `Suspicious prompt pattern in "${step.id}"`, stepId: step.id });
|
|
139
|
+
}
|
|
140
|
+
if (/system\s*:\s*/i.test(text)) {
|
|
141
|
+
findings.push({ severity: 'medium', message: `Prompt contains "system:" prefix in "${step.id}"`, stepId: step.id });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Ingest: Flag dynamic db/collection names
|
|
148
|
+
if (step.tool === 'ingest') {
|
|
149
|
+
const db = step.inputs?.db;
|
|
150
|
+
const coll = step.inputs?.collection;
|
|
151
|
+
if (typeof db === 'string' && db.includes('{{')) {
|
|
152
|
+
findings.push({ severity: 'medium', message: `Ingest step "${step.id}" uses dynamic database name`, stepId: step.id });
|
|
153
|
+
}
|
|
154
|
+
if (typeof coll === 'string' && coll.includes('{{')) {
|
|
155
|
+
findings.push({ severity: 'medium', message: `Ingest step "${step.id}" uses dynamic collection name`, stepId: step.id });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Loop: Check for unbounded iterations
|
|
160
|
+
if (step.tool === 'loop') {
|
|
161
|
+
if (!step.inputs?.maxIterations || step.inputs.maxIterations > 1000) {
|
|
162
|
+
findings.push({ severity: 'medium', message: `Loop step "${step.id}" has high/unbounded maxIterations`, stepId: step.id });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Package-level checks
|
|
168
|
+
if (packagePath) {
|
|
169
|
+
try {
|
|
170
|
+
const files = fs.readdirSync(packagePath);
|
|
171
|
+
const jsFiles = files.filter(f => /\.(js|ts|mjs|cjs)$/.test(f) && f !== 'node_modules');
|
|
172
|
+
if (jsFiles.length > 0) {
|
|
173
|
+
findings.push({ severity: 'critical', message: `Package contains executable code: ${jsFiles.join(', ')}` });
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Ignore if can't read directory
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const pkgPath = path.join(packagePath, 'package.json');
|
|
181
|
+
if (fs.existsSync(pkgPath)) {
|
|
182
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
183
|
+
const dangerousScripts = ['preinstall', 'install', 'postinstall', 'preuninstall', 'postuninstall'];
|
|
184
|
+
for (const script of dangerousScripts) {
|
|
185
|
+
if (pkg.scripts?.[script]) {
|
|
186
|
+
findings.push({ severity: 'critical', message: `Package has "${script}" lifecycle script` });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// Ignore if can't read package.json
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return findings;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { securityAudit, extractCapabilities, isBlockedDomain };
|
package/src/lib/telemetry.js
CHANGED
|
@@ -69,4 +69,26 @@ function send(event, extra = {}) {
|
|
|
69
69
|
} catch { /* telemetry should never break the CLI */ }
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Create a timer that auto-sends a telemetry event on completion.
|
|
74
|
+
* Usage:
|
|
75
|
+
* const done = telemetry.timer('cli_query', { model: 'voyage-4-large' });
|
|
76
|
+
* // ... do work ...
|
|
77
|
+
* done({ resultCount: 5 }); // sends event with durationMs calculated
|
|
78
|
+
*
|
|
79
|
+
* @param {string} event - Event name
|
|
80
|
+
* @param {object} [baseFields] - Fields known at start time
|
|
81
|
+
* @returns {function} done(extraFields) - Call to send the event
|
|
82
|
+
*/
|
|
83
|
+
function timer(event, baseFields = {}) {
|
|
84
|
+
const start = Date.now();
|
|
85
|
+
return (extraFields = {}) => {
|
|
86
|
+
send(event, {
|
|
87
|
+
...baseFields,
|
|
88
|
+
...extraFields,
|
|
89
|
+
durationMs: Date.now() - start,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { send, isEnabled, timer };
|
|
@@ -288,6 +288,67 @@ function scaffoldPackage(options) {
|
|
|
288
288
|
fs.writeFileSync(path.join(outputDir, 'LICENSE'), license);
|
|
289
289
|
files.push('LICENSE');
|
|
290
290
|
|
|
291
|
+
// Create tests/ directory with a sample test case
|
|
292
|
+
const testsDir = path.join(outputDir, 'tests');
|
|
293
|
+
fs.mkdirSync(testsDir, { recursive: true });
|
|
294
|
+
|
|
295
|
+
// Build sample mocks based on the tools used
|
|
296
|
+
const sampleMocks = {};
|
|
297
|
+
for (const tool of tools) {
|
|
298
|
+
if (tool === 'query' || tool === 'search') {
|
|
299
|
+
sampleMocks[tool] = { results: [{ text: 'Sample result', score: 0.95 }], resultCount: 1 };
|
|
300
|
+
} else if (tool === 'embed') {
|
|
301
|
+
sampleMocks[tool] = { embedding: [0.1, 0.2, 0.3], model: 'voyage-3-large', dimensions: 3 };
|
|
302
|
+
} else if (tool === 'rerank') {
|
|
303
|
+
sampleMocks[tool] = { results: [{ text: 'Reranked result', score: 0.98 }], resultCount: 1 };
|
|
304
|
+
} else if (tool === 'generate') {
|
|
305
|
+
sampleMocks[tool] = { text: 'Generated text response', model: 'mock-llm', provider: 'mock' };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Build sample inputs from definition
|
|
310
|
+
const sampleInputs = {};
|
|
311
|
+
if (definition.inputs) {
|
|
312
|
+
for (const [key, schema] of Object.entries(definition.inputs)) {
|
|
313
|
+
if (schema.default !== undefined) {
|
|
314
|
+
sampleInputs[key] = schema.default;
|
|
315
|
+
} else if (schema.type === 'number') {
|
|
316
|
+
sampleInputs[key] = 10;
|
|
317
|
+
} else if (schema.type === 'boolean') {
|
|
318
|
+
sampleInputs[key] = true;
|
|
319
|
+
} else {
|
|
320
|
+
sampleInputs[key] = 'test value';
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Build expected steps
|
|
326
|
+
const expectedSteps = {};
|
|
327
|
+
if (definition.steps) {
|
|
328
|
+
for (const step of definition.steps) {
|
|
329
|
+
expectedSteps[step.id] = { status: 'completed' };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const sampleTestCase = {
|
|
334
|
+
name: 'basic workflow test',
|
|
335
|
+
inputs: sampleInputs,
|
|
336
|
+
mocks: sampleMocks,
|
|
337
|
+
expect: {
|
|
338
|
+
steps: expectedSteps,
|
|
339
|
+
noErrors: true,
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
fs.writeFileSync(path.join(testsDir, 'basic.test.json'), JSON.stringify(sampleTestCase, null, 2) + '\n');
|
|
344
|
+
files.push('tests/basic.test.json');
|
|
345
|
+
|
|
346
|
+
// Create fixtures directory with .gitkeep
|
|
347
|
+
const fixturesDir = path.join(testsDir, 'fixtures');
|
|
348
|
+
fs.mkdirSync(fixturesDir, { recursive: true });
|
|
349
|
+
fs.writeFileSync(path.join(fixturesDir, '.gitkeep'), '');
|
|
350
|
+
files.push('tests/fixtures/.gitkeep');
|
|
351
|
+
|
|
291
352
|
return { dir: outputDir, files };
|
|
292
353
|
}
|
|
293
354
|
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { executeWorkflow } = require('./workflow');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a single workflow test case against a definition.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} definition - Parsed workflow JSON
|
|
11
|
+
* @param {object} testCase - Test case object { name, inputs, mocks, expect }
|
|
12
|
+
* @returns {Promise<{ passed: boolean, assertions: Array<{pass: boolean, message: string}>, errors: string[] }>}
|
|
13
|
+
*/
|
|
14
|
+
async function runWorkflowTest(definition, testCase) {
|
|
15
|
+
const results = { passed: true, assertions: [], errors: [] };
|
|
16
|
+
|
|
17
|
+
// Build mock executors from test case mocks
|
|
18
|
+
const mockExecutors = {};
|
|
19
|
+
for (const [tool, mockOutput] of Object.entries(testCase.mocks || {})) {
|
|
20
|
+
mockExecutors[tool] = async () => mockOutput;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let result;
|
|
24
|
+
try {
|
|
25
|
+
result = await executeWorkflow(definition, {
|
|
26
|
+
inputs: testCase.inputs || {},
|
|
27
|
+
_mockExecutors: mockExecutors,
|
|
28
|
+
});
|
|
29
|
+
} catch (err) {
|
|
30
|
+
results.passed = false;
|
|
31
|
+
results.errors.push(err.message);
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check step statuses
|
|
36
|
+
if (testCase.expect && testCase.expect.steps) {
|
|
37
|
+
for (const [stepId, expected] of Object.entries(testCase.expect.steps)) {
|
|
38
|
+
const stepResult = result.steps.find(s => s.id === stepId);
|
|
39
|
+
if (!stepResult) {
|
|
40
|
+
results.assertions.push({ pass: false, message: `Step "${stepId}" not found in results` });
|
|
41
|
+
results.passed = false;
|
|
42
|
+
} else if (expected.status === 'completed') {
|
|
43
|
+
if (stepResult.skipped) {
|
|
44
|
+
results.assertions.push({ pass: false, message: `Step "${stepId}" was skipped, expected completed` });
|
|
45
|
+
results.passed = false;
|
|
46
|
+
} else if (stepResult.error) {
|
|
47
|
+
results.assertions.push({ pass: false, message: `Step "${stepId}" errored: ${stepResult.error}` });
|
|
48
|
+
results.passed = false;
|
|
49
|
+
} else {
|
|
50
|
+
results.assertions.push({ pass: true, message: `Step "${stepId}" completed` });
|
|
51
|
+
}
|
|
52
|
+
} else if (expected.status === 'skipped') {
|
|
53
|
+
if (stepResult.skipped) {
|
|
54
|
+
results.assertions.push({ pass: true, message: `Step "${stepId}" skipped` });
|
|
55
|
+
} else {
|
|
56
|
+
results.assertions.push({ pass: false, message: `Step "${stepId}" should have been skipped` });
|
|
57
|
+
results.passed = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check output shape
|
|
64
|
+
if (testCase.expect && testCase.expect.output) {
|
|
65
|
+
for (const [key, constraint] of Object.entries(testCase.expect.output)) {
|
|
66
|
+
const value = result.output && result.output[key];
|
|
67
|
+
if (constraint.type === 'array') {
|
|
68
|
+
if (!Array.isArray(value)) {
|
|
69
|
+
results.assertions.push({ pass: false, message: `output.${key} should be array, got ${typeof value}` });
|
|
70
|
+
results.passed = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (constraint.type === 'string') {
|
|
75
|
+
if (typeof value !== 'string') {
|
|
76
|
+
results.assertions.push({ pass: false, message: `output.${key} should be string, got ${typeof value}` });
|
|
77
|
+
results.passed = false;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (constraint.type === 'number') {
|
|
82
|
+
if (typeof value !== 'number') {
|
|
83
|
+
results.assertions.push({ pass: false, message: `output.${key} should be number, got ${typeof value}` });
|
|
84
|
+
results.passed = false;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (constraint.type === 'object') {
|
|
89
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
90
|
+
results.assertions.push({ pass: false, message: `output.${key} should be object` });
|
|
91
|
+
results.passed = false;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (constraint.minLength != null) {
|
|
96
|
+
if (!value || value.length < constraint.minLength) {
|
|
97
|
+
results.assertions.push({ pass: false, message: `output.${key} length ${value ? value.length : 0} < ${constraint.minLength}` });
|
|
98
|
+
results.passed = false;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// If we got here with a type check, it passed
|
|
103
|
+
if (constraint.type || constraint.minLength != null) {
|
|
104
|
+
results.assertions.push({ pass: true, message: `output.${key} matches expected shape` });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check noErrors flag
|
|
110
|
+
if (testCase.expect && testCase.expect.noErrors) {
|
|
111
|
+
const hasErrors = result.steps.some(s => s.error);
|
|
112
|
+
if (hasErrors) {
|
|
113
|
+
const errorSteps = result.steps.filter(s => s.error).map(s => `${s.id}: ${s.error}`);
|
|
114
|
+
results.assertions.push({ pass: false, message: `Expected no errors but found: ${errorSteps.join('; ')}` });
|
|
115
|
+
results.passed = false;
|
|
116
|
+
} else {
|
|
117
|
+
results.assertions.push({ pass: true, message: 'No step errors' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load test cases from a workflow package's tests/ directory.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} packagePath - Path to the workflow package directory
|
|
128
|
+
* @returns {Array<object>} Array of test case objects
|
|
129
|
+
*/
|
|
130
|
+
function loadTestCases(packagePath) {
|
|
131
|
+
const testsDir = path.join(packagePath, 'tests');
|
|
132
|
+
if (!fs.existsSync(testsDir)) {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.test.json'));
|
|
137
|
+
const testCases = [];
|
|
138
|
+
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
const filePath = path.join(testsDir, file);
|
|
141
|
+
try {
|
|
142
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
143
|
+
const testCase = JSON.parse(content);
|
|
144
|
+
testCase._file = file;
|
|
145
|
+
testCases.push(testCase);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
testCases.push({
|
|
148
|
+
name: file,
|
|
149
|
+
_file: file,
|
|
150
|
+
_error: `Failed to load: ${err.message}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return testCases;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run all test cases for a workflow package.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} definition - Parsed workflow JSON
|
|
162
|
+
* @param {string} packagePath - Path to the workflow package directory
|
|
163
|
+
* @param {object} [options]
|
|
164
|
+
* @param {string} [options.testName] - Run only a specific test by name
|
|
165
|
+
* @returns {Promise<{ total: number, passed: number, failed: number, results: Array }>}
|
|
166
|
+
*/
|
|
167
|
+
async function runAllTests(definition, packagePath, options = {}) {
|
|
168
|
+
let testCases = loadTestCases(packagePath);
|
|
169
|
+
|
|
170
|
+
if (options.testName) {
|
|
171
|
+
testCases = testCases.filter(t => t.name === options.testName);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const results = [];
|
|
175
|
+
let passed = 0;
|
|
176
|
+
let failed = 0;
|
|
177
|
+
|
|
178
|
+
for (const testCase of testCases) {
|
|
179
|
+
if (testCase._error) {
|
|
180
|
+
results.push({ name: testCase.name, file: testCase._file, passed: false, error: testCase._error, assertions: [] });
|
|
181
|
+
failed++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = await runWorkflowTest(definition, testCase);
|
|
186
|
+
results.push({
|
|
187
|
+
name: testCase.name || testCase._file,
|
|
188
|
+
file: testCase._file,
|
|
189
|
+
passed: result.passed,
|
|
190
|
+
assertions: result.assertions,
|
|
191
|
+
errors: result.errors,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (result.passed) {
|
|
195
|
+
passed++;
|
|
196
|
+
} else {
|
|
197
|
+
failed++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { total: testCases.length, passed, failed, results };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
runWorkflowTest,
|
|
206
|
+
loadTestCases,
|
|
207
|
+
runAllTests,
|
|
208
|
+
};
|