@metrio-ai/cli 1.1.0 → 1.3.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 +46 -0
- package/dist/commands/sync.js +489 -0
- package/dist/commands/verify.js +288 -0
- package/dist/index.js +12 -0
- package/dist/manage/output.js +6 -1
- package/dist/manage/run.js +33 -1
- package/dist/sync/push.js +329 -0
- package/dist/sync/remote.js +79 -0
- package/dist/sync/render.js +181 -0
- package/dist/sync/status.js +85 -0
- package/dist/sync/workspace.js +430 -0
- package/dist/verify/assert.js +117 -0
- package/dist/verify/cases.js +302 -0
- package/dist/verify/execute.js +109 -0
- package/dist/verify/jsonpath.js +58 -0
- package/dist/verify/report.js +86 -0
- package/package.json +3 -1
|
@@ -0,0 +1,302 @@
|
|
|
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.parseCasesYaml = parseCasesYaml;
|
|
37
|
+
/**
|
|
38
|
+
* cases.yaml for `metrio verify`. Everything is parsed and validated before any case runs,
|
|
39
|
+
* so a typo is exit 2 instead of a paid run that silently checks nothing.
|
|
40
|
+
*/
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const yaml_1 = require("yaml");
|
|
44
|
+
const errors_1 = require("../errors");
|
|
45
|
+
const shared_1 = require("../commands/shared");
|
|
46
|
+
const jsonpath_1 = require("./jsonpath");
|
|
47
|
+
const WORKFLOW_ONLY = ['path_includes', 'terminal_node', 'response_type', 'max_llm_calls'];
|
|
48
|
+
const EXPECT_KEYS = ['contains', 'not_contains', 'regex', 'equals', 'json', ...WORKFLOW_ONLY, 'max_latency_ms', 'max_tokens'];
|
|
49
|
+
const MESSAGE_KEYS = ['role', 'content', 'text', 'file', 'mime', 'fileType'];
|
|
50
|
+
const ROLES = ['system', 'user', 'assistant'];
|
|
51
|
+
const FILE_TYPES = ['image', 'pdf', 'binary'];
|
|
52
|
+
const CASE_KEYS = ['name', 'message', 'messages', 'messages_file', 'variables', 'channel', 'expect'];
|
|
53
|
+
const JSON_ROOTS = ['content', 'structContent', 'state', 'tags', 'json'];
|
|
54
|
+
const MATCHERS = ['equals', 'exists', 'contains'];
|
|
55
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
56
|
+
function stringList(v, where) {
|
|
57
|
+
const list = typeof v === 'string' ? [v] : v;
|
|
58
|
+
if (!Array.isArray(list) || list.length === 0 || !list.every((s) => typeof s === 'string' && s !== '')) {
|
|
59
|
+
throw new Error(`${where} must be a non-empty string or a list of non-empty strings`);
|
|
60
|
+
}
|
|
61
|
+
return list;
|
|
62
|
+
}
|
|
63
|
+
function limit(v, where) {
|
|
64
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
|
|
65
|
+
throw new Error(`${where} must be a number >= 0`);
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
function mapping(v, where) {
|
|
69
|
+
if (!isObject(v))
|
|
70
|
+
throw new Error(`${where} must be a mapping`);
|
|
71
|
+
return v;
|
|
72
|
+
}
|
|
73
|
+
/** A file a case reads, resolved against baseDir; in a workspace it must stay inside the root. */
|
|
74
|
+
function localPath(p, opts, where) {
|
|
75
|
+
const full = path.resolve(opts.baseDir, p);
|
|
76
|
+
if (opts.root) {
|
|
77
|
+
let real;
|
|
78
|
+
try {
|
|
79
|
+
real = fs.realpathSync(full);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
throw new Error(`${where} "${p}": ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
const rel = path.relative(fs.realpathSync(opts.root), real);
|
|
85
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
86
|
+
throw new Error(`${where} "${p}" is outside the metrio workspace`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return full;
|
|
90
|
+
}
|
|
91
|
+
function parseJsonChecks(raw, where) {
|
|
92
|
+
if (!isObject(raw) || Object.keys(raw).length === 0)
|
|
93
|
+
throw new Error(`${where} must map JSON paths to matchers`);
|
|
94
|
+
return Object.entries(raw).map(([expr, m]) => {
|
|
95
|
+
const at = `${where}["${expr}"]`;
|
|
96
|
+
let segments;
|
|
97
|
+
try {
|
|
98
|
+
segments = (0, jsonpath_1.parsePath)(expr);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
throw new Error(`${at}: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof segments[0] !== 'string' || !JSON_ROOTS.includes(segments[0])) {
|
|
104
|
+
throw new Error(`${at}: a path starts at one of $.${JSON_ROOTS.join(', $.')}`);
|
|
105
|
+
}
|
|
106
|
+
if (!isObject(m) || Object.keys(m).length === 0)
|
|
107
|
+
throw new Error(`${at} must be a mapping of ${MATCHERS.join(' / ')}`);
|
|
108
|
+
const check = { path: expr, segments };
|
|
109
|
+
for (const [name, value] of Object.entries(m)) {
|
|
110
|
+
if (name === 'equals')
|
|
111
|
+
check.equals = { value };
|
|
112
|
+
else if (name === 'contains')
|
|
113
|
+
check.contains = { value };
|
|
114
|
+
else if (name === 'exists') {
|
|
115
|
+
if (typeof value !== 'boolean')
|
|
116
|
+
throw new Error(`${at}.exists must be true or false`);
|
|
117
|
+
check.exists = value;
|
|
118
|
+
}
|
|
119
|
+
else
|
|
120
|
+
throw new Error(`${at}.${name}: unknown matcher (known: ${MATCHERS.join(', ')})`);
|
|
121
|
+
}
|
|
122
|
+
return check;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function parseExpect(raw, kind, where) {
|
|
126
|
+
if (raw === undefined || raw === null)
|
|
127
|
+
return {};
|
|
128
|
+
const e = {};
|
|
129
|
+
for (const [key, v] of Object.entries(mapping(raw, where))) {
|
|
130
|
+
const at = `${where}.${key}`;
|
|
131
|
+
if (!EXPECT_KEYS.includes(key))
|
|
132
|
+
throw new Error(`${at}: unknown assertion (known: ${EXPECT_KEYS.join(', ')})`);
|
|
133
|
+
if (kind === 'prompt' && WORKFLOW_ONLY.includes(key))
|
|
134
|
+
throw new Error(`${at}: only workflows support this assertion`);
|
|
135
|
+
switch (key) {
|
|
136
|
+
case 'contains':
|
|
137
|
+
case 'not_contains':
|
|
138
|
+
case 'path_includes':
|
|
139
|
+
e[key] = stringList(v, at);
|
|
140
|
+
break;
|
|
141
|
+
case 'regex':
|
|
142
|
+
e.regex = stringList(v, at).map((r) => {
|
|
143
|
+
try {
|
|
144
|
+
new RegExp(r, 'u');
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
throw new Error(`${at}: invalid regex ${JSON.stringify(r)} (${err.message})`);
|
|
148
|
+
}
|
|
149
|
+
return r;
|
|
150
|
+
});
|
|
151
|
+
break;
|
|
152
|
+
case 'equals':
|
|
153
|
+
if (typeof v !== 'string')
|
|
154
|
+
throw new Error(`${at} must be a string`);
|
|
155
|
+
e.equals = v;
|
|
156
|
+
break;
|
|
157
|
+
case 'json':
|
|
158
|
+
e.json = parseJsonChecks(v, at);
|
|
159
|
+
break;
|
|
160
|
+
case 'terminal_node':
|
|
161
|
+
if (typeof v !== 'string' || v === '')
|
|
162
|
+
throw new Error(`${at} must be a node id`);
|
|
163
|
+
e.terminal_node = v;
|
|
164
|
+
break;
|
|
165
|
+
case 'response_type':
|
|
166
|
+
if (v !== 'text' && v !== 'struct' && v !== 'none')
|
|
167
|
+
throw new Error(`${at} must be text, struct or none`);
|
|
168
|
+
e.response_type = v;
|
|
169
|
+
break;
|
|
170
|
+
default:
|
|
171
|
+
e[key] = limit(v, at);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return e;
|
|
175
|
+
}
|
|
176
|
+
function parseVariables(v, kind, where) {
|
|
177
|
+
if (v === undefined)
|
|
178
|
+
return undefined;
|
|
179
|
+
const vars = mapping(v, where);
|
|
180
|
+
if (kind === 'prompt') {
|
|
181
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
182
|
+
if (!['string', 'number', 'boolean'].includes(typeof value)) {
|
|
183
|
+
throw new Error(`${where}.${name} must be a string, number or boolean (prompt variables are text)`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return vars;
|
|
188
|
+
}
|
|
189
|
+
function caseMessages(c, opts, where) {
|
|
190
|
+
const given = ['message', 'messages', 'messages_file'].filter((k) => c[k] !== undefined);
|
|
191
|
+
if (given.length !== 1)
|
|
192
|
+
throw new Error(`${where} needs exactly one of message, messages, messages_file`);
|
|
193
|
+
let inputs;
|
|
194
|
+
let baseDir = opts.baseDir;
|
|
195
|
+
if (c.message !== undefined) {
|
|
196
|
+
if (typeof c.message !== 'string' || c.message.trim() === '')
|
|
197
|
+
throw new Error(`${where}.message must be a non-empty string`);
|
|
198
|
+
inputs = [{ role: 'user', content: c.message }];
|
|
199
|
+
}
|
|
200
|
+
else if (c.messages !== undefined) {
|
|
201
|
+
if (!Array.isArray(c.messages) || c.messages.length === 0)
|
|
202
|
+
throw new Error(`${where}.messages must be a non-empty list`);
|
|
203
|
+
inputs = c.messages;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
if (typeof c.messages_file !== 'string' || c.messages_file === '')
|
|
207
|
+
throw new Error(`${where}.messages_file must be a path`);
|
|
208
|
+
const file = localPath(c.messages_file, opts, `${where}.messages_file`);
|
|
209
|
+
inputs = (0, shared_1.readMessagesFile)(file);
|
|
210
|
+
if (inputs.length === 0)
|
|
211
|
+
throw new Error(`${where}.messages_file has no messages`);
|
|
212
|
+
baseDir = path.dirname(file);
|
|
213
|
+
}
|
|
214
|
+
inputs.forEach((m, i) => {
|
|
215
|
+
if (!isObject(m))
|
|
216
|
+
throw new Error(`${where} message #${i} must be a mapping`);
|
|
217
|
+
for (const key of Object.keys(m)) {
|
|
218
|
+
if (!MESSAGE_KEYS.includes(key))
|
|
219
|
+
throw new Error(`${where} message #${i}: unknown key "${key}" (expected ${MESSAGE_KEYS.join(', ')})`);
|
|
220
|
+
}
|
|
221
|
+
const at = `${where} message #${i}`;
|
|
222
|
+
if (!ROLES.includes(m.role))
|
|
223
|
+
throw new Error(`${at}: invalid role ${JSON.stringify(m.role)} (expected ${ROLES.join(', ')})`);
|
|
224
|
+
for (const key of ['content', 'text']) {
|
|
225
|
+
if (m[key] !== undefined && typeof m[key] !== 'string')
|
|
226
|
+
throw new Error(`${at}: ${key} must be a string`);
|
|
227
|
+
}
|
|
228
|
+
if (m.file !== undefined && (typeof m.file !== 'string' || m.file === ''))
|
|
229
|
+
throw new Error(`${at}: file must be a path string`);
|
|
230
|
+
if (m.mime !== undefined && (typeof m.mime !== 'string' || m.mime === ''))
|
|
231
|
+
throw new Error(`${at}: mime must be a non-empty string`);
|
|
232
|
+
if (m.fileType !== undefined && !FILE_TYPES.includes(m.fileType)) {
|
|
233
|
+
throw new Error(`${at}: fileType must be one of ${FILE_TYPES.join(', ')}`);
|
|
234
|
+
}
|
|
235
|
+
if (typeof m.file === 'string') {
|
|
236
|
+
localPath(m.file, { ...opts, baseDir }, `${where} message #${i} file`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
return (0, shared_1.buildMessages)(inputs, baseDir);
|
|
240
|
+
}
|
|
241
|
+
function parseDoc(raw, opts) {
|
|
242
|
+
let doc;
|
|
243
|
+
try {
|
|
244
|
+
doc = (0, yaml_1.parse)(raw);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
throw new Error(`not valid YAML: ${err.message}`);
|
|
248
|
+
}
|
|
249
|
+
if (!isObject(doc))
|
|
250
|
+
throw new Error('must be a mapping with a "cases" list');
|
|
251
|
+
for (const key of Object.keys(doc)) {
|
|
252
|
+
if (key !== 'defaults' && key !== 'cases')
|
|
253
|
+
throw new Error(`unknown top-level key "${key}" (expected defaults, cases)`);
|
|
254
|
+
}
|
|
255
|
+
const defaults = mapping(doc.defaults ?? {}, 'defaults');
|
|
256
|
+
for (const key of Object.keys(defaults)) {
|
|
257
|
+
if (key !== 'variables' && key !== 'channel')
|
|
258
|
+
throw new Error(`defaults.${key}: unknown key (expected variables, channel)`);
|
|
259
|
+
}
|
|
260
|
+
if (opts.kind === 'prompt' && defaults.channel !== undefined)
|
|
261
|
+
throw new Error('defaults.channel: only workflows take a channel');
|
|
262
|
+
const defaultVars = parseVariables(defaults.variables, opts.kind, 'defaults.variables');
|
|
263
|
+
const defaultChannel = defaults.channel === undefined ? undefined : mapping(defaults.channel, 'defaults.channel');
|
|
264
|
+
if (!Array.isArray(doc.cases) || doc.cases.length === 0)
|
|
265
|
+
throw new Error('"cases" must be a non-empty list');
|
|
266
|
+
const names = new Set();
|
|
267
|
+
return doc.cases.map((c, i) => {
|
|
268
|
+
const where = `cases[${i}]`;
|
|
269
|
+
if (!isObject(c))
|
|
270
|
+
throw new Error(`${where} must be a mapping`);
|
|
271
|
+
for (const key of Object.keys(c)) {
|
|
272
|
+
if (!CASE_KEYS.includes(key))
|
|
273
|
+
throw new Error(`${where}.${key}: unknown key (expected ${CASE_KEYS.join(', ')})`);
|
|
274
|
+
}
|
|
275
|
+
if (typeof c.name !== 'string' || c.name.trim() === '')
|
|
276
|
+
throw new Error(`${where}.name must be a non-empty string`);
|
|
277
|
+
if (names.has(c.name))
|
|
278
|
+
throw new Error(`${where}.name "${c.name}" is used twice`);
|
|
279
|
+
names.add(c.name);
|
|
280
|
+
const at = `${where} (${c.name})`;
|
|
281
|
+
if (opts.kind === 'prompt' && c.channel !== undefined)
|
|
282
|
+
throw new Error(`${at}.channel: only workflows take a channel`);
|
|
283
|
+
const vars = parseVariables(c.variables, opts.kind, `${at}.variables`);
|
|
284
|
+
const channel = c.channel === undefined ? undefined : mapping(c.channel, `${at}.channel`);
|
|
285
|
+
return {
|
|
286
|
+
name: c.name,
|
|
287
|
+
messages: caseMessages(c, opts, at),
|
|
288
|
+
variables: vars || defaultVars ? { ...defaultVars, ...vars } : undefined,
|
|
289
|
+
channel: channel || defaultChannel ? { ...defaultChannel, ...channel } : undefined,
|
|
290
|
+
expect: parseExpect(c.expect, opts.kind, `${at}.expect`),
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
/** Parses and validates a cases.yaml; every problem is a CliError (exit 2) prefixed with opts.file. */
|
|
295
|
+
function parseCasesYaml(raw, opts) {
|
|
296
|
+
try {
|
|
297
|
+
return parseDoc(raw, opts);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
throw new errors_1.CliError(`${opts.file}: ${err.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCase = runCase;
|
|
4
|
+
exports.runAll = runAll;
|
|
5
|
+
/**
|
|
6
|
+
* Runs verify cases with the project API key and checks each reply. A case never throws:
|
|
7
|
+
* an API failure becomes an `error` row so one bad case does not hide the others.
|
|
8
|
+
*/
|
|
9
|
+
const client_1 = require("@metrio-ai/client");
|
|
10
|
+
const shared_1 = require("../commands/shared");
|
|
11
|
+
const prompt_1 = require("../commands/prompt");
|
|
12
|
+
const assert_1 = require("./assert");
|
|
13
|
+
async function runCase(client, t, c, timezone) {
|
|
14
|
+
const base = { resource: t.label, case: c.name };
|
|
15
|
+
try {
|
|
16
|
+
let obs;
|
|
17
|
+
let version;
|
|
18
|
+
let taskId;
|
|
19
|
+
if (t.kind === 'prompt') {
|
|
20
|
+
const res = await client.chatCompletion({
|
|
21
|
+
promptId: t.ref,
|
|
22
|
+
messages: c.messages,
|
|
23
|
+
// A numeric tag is the prompt version serial.
|
|
24
|
+
tag: t.version !== undefined ? String(t.version) : undefined,
|
|
25
|
+
timezone,
|
|
26
|
+
variables: (0, prompt_1.mergeVariables)([], c.variables),
|
|
27
|
+
});
|
|
28
|
+
obs = (0, assert_1.observePrompt)(res);
|
|
29
|
+
version = t.version;
|
|
30
|
+
taskId = res.taskId;
|
|
31
|
+
if (obs.failedReason !== undefined) {
|
|
32
|
+
return {
|
|
33
|
+
...base,
|
|
34
|
+
status: 'error',
|
|
35
|
+
failures: [],
|
|
36
|
+
error: { message: `prompt reply failed: ${obs.failedReason || 'no reason given'}` },
|
|
37
|
+
latencyMs: obs.latencyMs,
|
|
38
|
+
tokens: obs.tokens,
|
|
39
|
+
...(version !== undefined ? { version } : {}),
|
|
40
|
+
...(taskId ? { taskId } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const res = await client.workflowCompletion({
|
|
46
|
+
workflowId: t.ref,
|
|
47
|
+
messages: c.messages,
|
|
48
|
+
version: t.version,
|
|
49
|
+
channel: c.channel,
|
|
50
|
+
variables: c.variables,
|
|
51
|
+
timezone,
|
|
52
|
+
});
|
|
53
|
+
version = res.version;
|
|
54
|
+
taskId = res.taskId;
|
|
55
|
+
// The backend answers 200 for a failed or skipped run; never let that pass.
|
|
56
|
+
const broken = res.skipped ? 'workflow skipped' : res.reply?.status === 'failed' ? 'workflow run failed' : undefined;
|
|
57
|
+
if (broken) {
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
status: 'error',
|
|
61
|
+
failures: [],
|
|
62
|
+
error: { message: `${broken}: ${res.reply?.reason || 'no reason given'}` },
|
|
63
|
+
latencyMs: res.elapsedTime,
|
|
64
|
+
tokens: (res.inputTokens ?? 0) + (res.outputTokens ?? 0),
|
|
65
|
+
...(version !== undefined ? { version } : {}),
|
|
66
|
+
...(taskId ? { taskId } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
obs = (0, assert_1.observeWorkflow)(res);
|
|
70
|
+
}
|
|
71
|
+
const failures = (0, assert_1.evaluate)(c.expect, obs);
|
|
72
|
+
return {
|
|
73
|
+
...base,
|
|
74
|
+
status: failures.length ? 'fail' : 'pass',
|
|
75
|
+
failures,
|
|
76
|
+
latencyMs: obs.latencyMs,
|
|
77
|
+
tokens: obs.tokens,
|
|
78
|
+
...(obs.llmCalls !== null ? { llmCalls: obs.llmCalls } : {}),
|
|
79
|
+
...(version !== undefined ? { version } : {}),
|
|
80
|
+
...(taskId ? { taskId } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
status: 'error',
|
|
87
|
+
failures: [],
|
|
88
|
+
error: {
|
|
89
|
+
message: err instanceof Error ? err.message : String(err),
|
|
90
|
+
status: err instanceof client_1.MetrioApiError ? err.statusCode : undefined,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function runAll(client, targets, opts) {
|
|
96
|
+
const jobs = targets.flatMap((t) => t.cases.map((c) => ({ t, c })));
|
|
97
|
+
let done = 0;
|
|
98
|
+
let stop = false;
|
|
99
|
+
return (0, shared_1.mapWithConcurrency)(jobs, opts.concurrency, async ({ t, c }) => {
|
|
100
|
+
const result = stop
|
|
101
|
+
? { resource: t.label, case: c.name, status: 'skipped', failures: [] }
|
|
102
|
+
: await runCase(client, t, c, opts.timezone);
|
|
103
|
+
if (opts.failFast && (result.status === 'fail' || result.status === 'error'))
|
|
104
|
+
stop = true;
|
|
105
|
+
done += 1;
|
|
106
|
+
opts.onResult?.(result, done, jobs.length);
|
|
107
|
+
return result;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parsePath = parsePath;
|
|
4
|
+
exports.readPath = readPath;
|
|
5
|
+
function parsePath(expr) {
|
|
6
|
+
if (!expr.startsWith('$'))
|
|
7
|
+
throw new Error(`JSON path must start with "$": ${expr}`);
|
|
8
|
+
const segments = [];
|
|
9
|
+
let i = 1;
|
|
10
|
+
while (i < expr.length) {
|
|
11
|
+
if (expr[i] === '.') {
|
|
12
|
+
const name = /^[^.[\]]+/.exec(expr.slice(i + 1));
|
|
13
|
+
if (!name)
|
|
14
|
+
throw new Error(`Bad JSON path ${expr}: empty name at position ${i}`);
|
|
15
|
+
segments.push(name[0]);
|
|
16
|
+
i += 1 + name[0].length;
|
|
17
|
+
}
|
|
18
|
+
else if (expr[i] === '[') {
|
|
19
|
+
const rest = expr.slice(i);
|
|
20
|
+
const index = /^\[(\d+)\]/.exec(rest);
|
|
21
|
+
const quoted = /^\[(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\]/.exec(rest);
|
|
22
|
+
if (index) {
|
|
23
|
+
segments.push(Number(index[1]));
|
|
24
|
+
i += index[0].length;
|
|
25
|
+
}
|
|
26
|
+
else if (quoted) {
|
|
27
|
+
segments.push(quoted[1] !== undefined ? JSON.parse(`"${quoted[1]}"`) : quoted[2]);
|
|
28
|
+
i += quoted[0].length;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
throw new Error(`Bad JSON path ${expr}: unsupported bracket at position ${i} (use [0] or ["key"])`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
throw new Error(`Bad JSON path ${expr}: unexpected "${expr[i]}" at position ${i}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return segments;
|
|
39
|
+
}
|
|
40
|
+
/** The value at `segments`, or undefined when any step is missing (own properties only). */
|
|
41
|
+
function readPath(root, segments) {
|
|
42
|
+
let current = root;
|
|
43
|
+
for (const segment of segments) {
|
|
44
|
+
if (typeof segment === 'number') {
|
|
45
|
+
if (!Array.isArray(current) || segment >= current.length)
|
|
46
|
+
return undefined;
|
|
47
|
+
current = current[segment];
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
if (current === null || typeof current !== 'object' || Array.isArray(current))
|
|
51
|
+
return undefined;
|
|
52
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment))
|
|
53
|
+
return undefined;
|
|
54
|
+
current = current[segment];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.summarize = summarize;
|
|
4
|
+
exports.formatHuman = formatHuman;
|
|
5
|
+
exports.formatJson = formatJson;
|
|
6
|
+
exports.formatJunit = formatJunit;
|
|
7
|
+
/**
|
|
8
|
+
* verify output: a grouped text report (stdout default), JSON (--json) and JUnit XML (--junit).
|
|
9
|
+
* Reply text is model output: the text report escapes control characters, JUnit escapes XML.
|
|
10
|
+
*/
|
|
11
|
+
const sync_1 = require("../commands/sync");
|
|
12
|
+
function summarize(results) {
|
|
13
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
14
|
+
return { total: results.length, passed: count('pass'), failed: count('fail'), errors: count('error'), skipped: count('skipped') };
|
|
15
|
+
}
|
|
16
|
+
const MAX = 300;
|
|
17
|
+
function show(value) {
|
|
18
|
+
const s = value === undefined ? '(missing)' : JSON.stringify(value);
|
|
19
|
+
const chars = [...s];
|
|
20
|
+
return chars.length > MAX ? `${chars.slice(0, MAX).join('')}…` : s;
|
|
21
|
+
}
|
|
22
|
+
const describeFailure = (f) => `${f.assertion}: expected ${show(f.expected)}, got ${show(f.actual)}`;
|
|
23
|
+
const LABEL = { pass: 'PASS ', fail: 'FAIL ', error: 'ERROR', skipped: 'SKIP ' };
|
|
24
|
+
function formatHuman(results) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
let current;
|
|
27
|
+
for (const r of results) {
|
|
28
|
+
if (r.resource !== current) {
|
|
29
|
+
current = r.resource;
|
|
30
|
+
lines.push(r.resource);
|
|
31
|
+
}
|
|
32
|
+
const stats = [
|
|
33
|
+
r.latencyMs !== undefined ? `${r.latencyMs}ms` : '',
|
|
34
|
+
r.tokens !== undefined ? `${r.tokens} tokens` : '',
|
|
35
|
+
r.version !== undefined ? `v${r.version}` : '',
|
|
36
|
+
].filter(Boolean).join(', ');
|
|
37
|
+
lines.push(` ${LABEL[r.status]} ${r.case}${stats ? ` (${stats})` : ''}`);
|
|
38
|
+
for (const f of r.failures)
|
|
39
|
+
lines.push(` ${describeFailure(f)}`);
|
|
40
|
+
if (r.error)
|
|
41
|
+
lines.push(` ${r.error.status ? `HTTP ${r.error.status}: ` : ''}${r.error.message}`);
|
|
42
|
+
}
|
|
43
|
+
const s = summarize(results);
|
|
44
|
+
lines.push(`${s.total} cases: ${s.passed} passed, ${s.failed} failed, ${s.errors} errors, ${s.skipped} skipped`);
|
|
45
|
+
return (0, sync_1.safe)(`${lines.join('\n')}\n`);
|
|
46
|
+
}
|
|
47
|
+
function formatJson(results) {
|
|
48
|
+
return `${JSON.stringify({ summary: summarize(results), results }, null, 2)}\n`;
|
|
49
|
+
}
|
|
50
|
+
const xml = (s) => s
|
|
51
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, '')
|
|
52
|
+
.replace(/&/g, '&')
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"');
|
|
56
|
+
function formatJunit(results) {
|
|
57
|
+
const counts = (s) => `tests="${s.total}" failures="${s.failed}" errors="${s.errors}" skipped="${s.skipped}"`;
|
|
58
|
+
const out = ['<?xml version="1.0" encoding="UTF-8"?>', `<testsuites name="metrio verify" ${counts(summarize(results))}>`];
|
|
59
|
+
for (const name of [...new Set(results.map((r) => r.resource))]) {
|
|
60
|
+
const rows = results.filter((r) => r.resource === name);
|
|
61
|
+
out.push(` <testsuite name="${xml(name)}" ${counts(summarize(rows))}>`);
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
const time = r.latencyMs !== undefined ? ` time="${(r.latencyMs / 1000).toFixed(3)}"` : '';
|
|
64
|
+
const open = ` <testcase classname="${xml(name)}" name="${xml(r.case)}"${time}`;
|
|
65
|
+
if (r.status === 'pass') {
|
|
66
|
+
out.push(`${open}/>`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
out.push(`${open}>`);
|
|
70
|
+
if (r.status === 'fail') {
|
|
71
|
+
const body = r.failures.map(describeFailure).join('\n');
|
|
72
|
+
out.push(` <failure message="${r.failures.length} assertion(s) failed">${xml(body)}</failure>`);
|
|
73
|
+
}
|
|
74
|
+
else if (r.status === 'error') {
|
|
75
|
+
out.push(` <error message="${xml(r.error?.message ?? 'error')}"/>`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
out.push(' <skipped/>');
|
|
79
|
+
}
|
|
80
|
+
out.push(' </testcase>');
|
|
81
|
+
}
|
|
82
|
+
out.push(' </testsuite>');
|
|
83
|
+
}
|
|
84
|
+
out.push('</testsuites>', '');
|
|
85
|
+
return out.join('\n');
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metrio-ai/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"packageManager": "yarn@4.14.1",
|
|
5
5
|
"description": "Metrio AI command-line interface: run prompts and workflows, manage Metrio resources",
|
|
6
6
|
"bin": {
|
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@metrio-ai/client": "^1.5.2",
|
|
33
33
|
"ajv": "^8.17.1",
|
|
34
|
+
"diff": "^5.2.0",
|
|
34
35
|
"yaml": "^2.8.3"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
38
|
+
"@types/diff": "^5.2.3",
|
|
37
39
|
"@types/jest": "^29.5.14",
|
|
38
40
|
"@types/node": "^20.10.0",
|
|
39
41
|
"jest": "^29.7.0",
|