@metrio-ai/cli 1.2.0 → 1.3.1

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 CHANGED
@@ -29,7 +29,7 @@ export METRIO_PROJECT=my-project # or --project, or "project: my-project" in me
29
29
 
30
30
  metrio prompt list
31
31
  metrio version get 34 -o system.md # the system prompt as a file
32
- metrio version create 12 -f system.md --model-name gpt-5
32
+ metrio version create 12 -f system.md --model-provider openai --model-name gpt-5
33
33
  metrio version get 34 -o v.yaml && metrio version update 34 -f v.yaml # round-trip
34
34
  metrio skill get 5 -o skill.md # frontmatter (name, description, references) + body
35
35
  metrio skill update 5 -f skill.md
@@ -67,3 +67,30 @@ metrio version list pick-products # prompt directories work wherever a promp
67
67
  - `pull` adopts untracked local files that are identical to the console. `pull` and `push` report per-resource failures, exit 1, and still save state for the resources that succeeded.
68
68
  - Renaming a prompt or skill directory is detected by content (status `renamed`); pull/push move the state entry. Workflow directories are named by workflow id and cannot be renamed locally; push refuses it.
69
69
  - Known limitation: renaming AND editing a prompt/skill directory in one go looks like a delete plus a new resource (push would create a second one; `--prune --yes` would delete the old one). Rename first, sync, then edit.
70
+
71
+ ## Verify prompts and workflows
72
+
73
+ Put a `cases.yaml` next to a synced resource (`prompts/<name>/cases.yaml`, `workflows/<id>/cases.yaml`) and run it with the project API key (`METRIOAI_API_KEY` in `.env`, or `--api-key`; it must belong to the workspace's project):
74
+
75
+ ```yaml
76
+ defaults:
77
+ variables: { region: TW }
78
+ cases:
79
+ - name: regular order
80
+ message: "我要訂 3 箱蘋果" # or messages: [...] or messages_file: path
81
+ expect:
82
+ contains: ["蘋果"] # not_contains, regex, equals read the reply text
83
+ json:
84
+ "$.state.order.qty": { equals: 3 } # $.content $.structContent $.state $.tags $.json
85
+ path_includes: [classify, create_order] # workflows only (also terminal_node,
86
+ max_latency_ms: 8000 # response_type, max_llm_calls)
87
+ ```
88
+
89
+ ```bash
90
+ metrio push -m "new wording" # creates a version that is not live yet
91
+ metrio verify --version pushed # run every cases.yaml against what you just pushed
92
+ metrio push --activate # make it live once the cases pass
93
+ metrio verify -p 12 --cases cases.yaml --junit report.xml # outside a workspace
94
+ ```
95
+
96
+ Every `cases.yaml` is validated before the first request (a typo in an assertion is exit 2). A failed case prints each failing assertion with expected vs actual and exits 1. `--json` prints machine-readable results, `--junit <file>` writes JUnit XML, `--fail-fast` stops starting new cases after the first failure. Run `metrio verify --help` for the full format.
@@ -38,6 +38,7 @@ exports.parseSyncArgs = parseSyncArgs;
38
38
  exports.openWorkspace = openWorkspace;
39
39
  exports.filterEntries = filterEntries;
40
40
  exports.loadEntries = loadEntries;
41
+ exports.safe = safe;
41
42
  exports.runDiffCommand = runDiffCommand;
42
43
  exports.runPullCommand = runPullCommand;
43
44
  exports.runPushCommand = runPushCommand;
@@ -0,0 +1,288 @@
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.VERIFY_HELP = void 0;
37
+ exports.parseVerifyArgs = parseVerifyArgs;
38
+ exports.resolveTargets = resolveTargets;
39
+ exports.runVerifyCommand = runVerifyCommand;
40
+ /**
41
+ * `metrio verify` — runs cases.yaml against prompts and workflows and checks every reply.
42
+ * Execution uses the project API key, like `chat` and `run-batch`. Targets come from the
43
+ * workspace (.metrio/state.json) or from -p / -w with --cases. Every cases.yaml is
44
+ * validated before the first request.
45
+ */
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const client_1 = require("@metrio-ai/client");
49
+ const errors_1 = require("../errors");
50
+ const shared_1 = require("./shared");
51
+ const sync_1 = require("./sync");
52
+ const workspace_1 = require("../sync/workspace");
53
+ const cases_1 = require("../verify/cases");
54
+ const execute_1 = require("../verify/execute");
55
+ const report_1 = require("../verify/report");
56
+ exports.VERIFY_HELP = `metrio verify - run cases.yaml and check every reply
57
+
58
+ Usage:
59
+ metrio verify [paths…] [options] Workspace prompts/<name>/cases.yaml and
60
+ workflows/<id>/cases.yaml
61
+ metrio verify -p <prompt-id> --cases <file> One prompt, any cases file
62
+ metrio verify -w <workflow-id> --cases <file> One workflow, any cases file
63
+
64
+ In a workspace (see "metrio pull --help") each cases.yaml runs against the resource
65
+ recorded in .metrio/state.json. Paths narrow the run: a resource directory, prompts/,
66
+ workflows/ or the root.
67
+
68
+ Options:
69
+ --version active|pushed|<n> active (default): the live version; pushed: the version
70
+ the last "metrio push" created; <n>: prompt version serial
71
+ or workflow version
72
+ (a number needs a single prompt or workflow path)
73
+ --concurrency <n> Cases in flight (default 4)
74
+ --json Print results as JSON instead of the text report
75
+ --junit <file> Also write a JUnit XML report
76
+ --fail-fast Start no new case after the first failure
77
+ --timezone <tz> IANA timezone sent with every case
78
+ -e, --env <file> Env file (default: .env in the current dir)
79
+ --api-key <key> Project API key (default: METRIOAI_API_KEY); it must belong
80
+ to the workspace's project
81
+ --base-url <url> API base URL
82
+
83
+ cases.yaml:
84
+ defaults:
85
+ channel: { User: { customer_id: "123" } } # workflows only
86
+ variables: { region: TW }
87
+ cases:
88
+ - name: regular order
89
+ message: "我要訂 3 箱蘋果" # or messages: [...] or messages_file: path
90
+ variables: { region: HK } # merged key by key over defaults
91
+ expect: # optional; every listed assertion must hold
92
+ contains: ["蘋果"] # reply text; also not_contains, regex, equals
93
+ json: # $.content $.structContent $.state $.tags, and
94
+ "$.state.order.qty": { equals: 3 } # $.json (reply text parsed as JSON);
95
+ "$.tags": { contains: order } # matchers: equals, exists, contains
96
+ path_includes: [classify, create_order] # workflows: ordered node subsequence
97
+ terminal_node: create_order # workflows
98
+ response_type: text # workflows: text | struct | none
99
+ max_latency_ms: 8000
100
+ max_tokens: 4000 # input + output
101
+ max_llm_calls: 3 # workflows
102
+
103
+ Agent loop: pull → edit → push → verify --version pushed → push --activate
104
+
105
+ Exit codes: 0 all passed, 1 a case failed / errored / was skipped, 2 invalid cases or options.
106
+ `;
107
+ function positive(raw, flag) {
108
+ const n = Number(raw);
109
+ if (!Number.isInteger(n) || n <= 0)
110
+ throw new errors_1.CliError(`${flag} must be a positive integer, got "${raw}"`);
111
+ return n;
112
+ }
113
+ function parseVerifyArgs(argv) {
114
+ const o = { paths: [], version: 'active', concurrency: 4, json: false, failFast: false };
115
+ for (let i = 0; i < argv.length; i += 1) {
116
+ const arg = argv[i];
117
+ const value = () => {
118
+ const v = argv[i + 1];
119
+ if (v === undefined)
120
+ throw new errors_1.CliError(`${arg} needs a value`);
121
+ i += 1;
122
+ return v;
123
+ };
124
+ switch (arg) {
125
+ case '--version': {
126
+ const v = value();
127
+ o.version = v === 'active' || v === 'pushed' ? v : positive(v, '--version');
128
+ break;
129
+ }
130
+ case '--concurrency':
131
+ o.concurrency = positive(value(), arg);
132
+ break;
133
+ case '--json':
134
+ o.json = true;
135
+ break;
136
+ case '--junit':
137
+ o.junit = value();
138
+ break;
139
+ case '--fail-fast':
140
+ o.failFast = true;
141
+ break;
142
+ case '-p':
143
+ case '--prompt-id':
144
+ o.promptId = positive(value(), arg);
145
+ break;
146
+ case '-w':
147
+ case '--workflow-id': {
148
+ const v = value().trim();
149
+ if (!v)
150
+ throw new errors_1.CliError(`${arg} cannot be empty`);
151
+ o.workflowId = v;
152
+ break;
153
+ }
154
+ case '--cases':
155
+ o.cases = value();
156
+ break;
157
+ case '--timezone':
158
+ o.timezone = value();
159
+ break;
160
+ case '-e':
161
+ case '--env':
162
+ o.envFile = value();
163
+ break;
164
+ case '--api-key':
165
+ o.apiKey = value();
166
+ break;
167
+ case '--base-url':
168
+ o.baseUrl = value();
169
+ break;
170
+ default:
171
+ if (arg.startsWith('-'))
172
+ throw new errors_1.CliError(`Unknown option ${arg}. Run "metrio verify --help".`);
173
+ o.paths.push(arg);
174
+ }
175
+ }
176
+ const direct = o.promptId !== undefined || o.workflowId !== undefined;
177
+ if (o.promptId !== undefined && o.workflowId !== undefined)
178
+ throw new errors_1.CliError('Use -p or -w, not both');
179
+ if (direct && !o.cases)
180
+ throw new errors_1.CliError('-p / -w need --cases <file>');
181
+ if (!direct && o.cases)
182
+ throw new errors_1.CliError('--cases needs -p <prompt-id> or -w <workflow-id>');
183
+ if (direct && o.paths.length)
184
+ throw new errors_1.CliError('Workspace paths cannot be combined with -p / -w');
185
+ if (direct && o.version === 'pushed') {
186
+ throw new errors_1.CliError('--version pushed reads .metrio/state.json; with -p / -w use active or a version number');
187
+ }
188
+ return o;
189
+ }
190
+ function directTarget(o) {
191
+ const kind = o.promptId !== undefined ? 'prompt' : 'workflow';
192
+ const file = o.cases;
193
+ let raw;
194
+ try {
195
+ if (!fs.statSync(file).isFile())
196
+ throw new Error('not a file');
197
+ raw = fs.readFileSync(file, 'utf8');
198
+ }
199
+ catch (err) {
200
+ throw new errors_1.CliError(`Cannot read cases file ${file}: ${err.message}`);
201
+ }
202
+ const ref = kind === 'prompt' ? o.promptId : o.workflowId;
203
+ return {
204
+ label: `${kind} ${ref}`,
205
+ kind,
206
+ ref,
207
+ version: typeof o.version === 'number' ? o.version : undefined,
208
+ cases: (0, cases_1.parseCasesYaml)(raw, { kind, file, baseDir: path.dirname(path.resolve(file)) }),
209
+ };
210
+ }
211
+ function resolveTargets(o) {
212
+ if (o.cases)
213
+ return [directTarget(o)];
214
+ const root = (0, workspace_1.findRoot)();
215
+ if (!root)
216
+ throw new errors_1.CliError('Not in a metrio workspace (no metrio.yaml). Run it inside one, or use -p / -w with --cases.');
217
+ const state = (0, workspace_1.readState)(root, (0, workspace_1.readProject)(root));
218
+ const listed = (0, workspace_1.listLocal)(root, ['prompt', 'workflow']);
219
+ // Filter by path first so an unrelated resource's cases.yaml is never read.
220
+ const inScope = (0, sync_1.filterEntries)(listed, root, o.paths);
221
+ const read = (rs) => rs.map((r) => ({ ...r, cases: (0, workspace_1.readResourceFile)(root, r.key, 'cases.yaml') }));
222
+ for (const p of o.paths) {
223
+ if (!read((0, sync_1.filterEntries)(listed, root, [p])).some((r) => r.cases !== undefined))
224
+ throw new errors_1.CliError(`${p}: no cases.yaml found`);
225
+ }
226
+ const selected = read(inScope).filter((r) => r.cases !== undefined);
227
+ if (!selected.length) {
228
+ throw new errors_1.CliError('No cases.yaml in this workspace. Add prompts/<name>/cases.yaml or workflows/<id>/cases.yaml.');
229
+ }
230
+ if (typeof o.version === 'number' && selected.length > 1) {
231
+ throw new errors_1.CliError(`--version <n> pins one resource; name a single prompt or workflow path (matched: ${selected.map((r) => r.key).join(', ')})`);
232
+ }
233
+ return selected.map((r) => {
234
+ const entry = state.resources[r.key];
235
+ if (!entry)
236
+ throw new errors_1.CliError(`${r.key} is not in .metrio/state.json yet; run "metrio push" first`);
237
+ const kind = r.kind;
238
+ let version;
239
+ if (typeof o.version === 'number')
240
+ version = o.version;
241
+ else if (o.version === 'pushed') {
242
+ version = kind === 'prompt' ? entry.pushed_serial : entry.pushed_workflow_version;
243
+ if (version === undefined) {
244
+ throw new errors_1.CliError(`${r.key}: no pushed version recorded in .metrio/state.json; run "metrio push"; a resource created by its first push (e.g. a new workflow) has none, so use --version active for it`);
245
+ }
246
+ }
247
+ const ref = kind === 'prompt' ? Number(entry.id) : entry.id;
248
+ if (kind === 'prompt' && (!Number.isInteger(ref) || ref <= 0)) {
249
+ throw new errors_1.CliError(`${r.key}: prompt id "${entry.id}" in .metrio/state.json is not a positive integer`);
250
+ }
251
+ return {
252
+ label: r.key,
253
+ kind,
254
+ ref,
255
+ version,
256
+ cases: (0, cases_1.parseCasesYaml)(r.cases, { kind, file: `${r.key}/cases.yaml`, baseDir: path.join(root, r.key), root }),
257
+ };
258
+ });
259
+ }
260
+ async function runVerifyCommand(argv) {
261
+ if (argv.includes('-h') || argv.includes('--help')) {
262
+ process.stdout.write(exports.VERIFY_HELP);
263
+ return;
264
+ }
265
+ const o = parseVerifyArgs(argv);
266
+ const targets = resolveTargets(o);
267
+ (0, shared_1.loadEnv)(o.envFile);
268
+ const client = new client_1.MetrioAI({ apiKey: (0, shared_1.resolveApiKey)(o.apiKey), baseUrl: (0, shared_1.resolveBaseUrl)(o.baseUrl) });
269
+ const results = await (0, execute_1.runAll)(client, targets, {
270
+ concurrency: o.concurrency,
271
+ failFast: o.failFast,
272
+ timezone: o.timezone,
273
+ onResult: (r, done, total) => process.stderr.write((0, sync_1.safe)(`[${done}/${total}] ${r.resource} › ${r.case} ${r.status}\n`)),
274
+ });
275
+ process.stdout.write(o.json ? (0, report_1.formatJson)(results) : (0, report_1.formatHuman)(results));
276
+ if (o.junit) {
277
+ try {
278
+ fs.writeFileSync(path.resolve(o.junit), (0, report_1.formatJunit)(results));
279
+ }
280
+ catch (err) {
281
+ process.stderr.write(`Error: cannot write --junit file ${o.junit} (${err.message})\n`);
282
+ process.exitCode = 1;
283
+ }
284
+ }
285
+ const s = (0, report_1.summarize)(results);
286
+ if (s.passed !== s.total)
287
+ process.exitCode = 1;
288
+ }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const chat_1 = require("./commands/chat");
18
18
  const prompt_1 = require("./commands/prompt");
19
19
  const workflow_1 = require("./commands/workflow");
20
20
  const tools_1 = require("./commands/tools");
21
+ const verify_1 = require("./commands/verify");
21
22
  const run_1 = require("./manage/run");
22
23
  const sync_1 = require("./commands/sync");
23
24
  const registry_1 = require("./manage/registry");
@@ -31,6 +32,7 @@ Run (project API key, see "metrio chat --help"):
31
32
  workflow run-batch Run several workflow cases in one process
32
33
  prompt run-batch Run several prompt cases in one process (metrio prompt --help)
33
34
  prompt create-version Create a prompt version from a file
35
+ verify [paths…] Run cases.yaml and check replies ("metrio verify --help")
34
36
 
35
37
  Account (console OAuth):
36
38
  login Sign in to the Metrio console in your browser
@@ -89,6 +91,8 @@ async function main(argv) {
89
91
  return (0, sync_1.runDiffCommand)(argv.slice(1));
90
92
  case 'push':
91
93
  return (0, sync_1.runPushCommand)(argv.slice(1));
94
+ case 'verify':
95
+ return (0, verify_1.runVerifyCommand)(argv.slice(1));
92
96
  case 'login':
93
97
  return (0, auth_1.runLoginCommand)(argv.slice(1));
94
98
  case 'logout':
@@ -43,6 +43,7 @@ exports.acquireLock = acquireLock;
43
43
  exports.hashFiles = hashFiles;
44
44
  exports.slugify = slugify;
45
45
  exports.readLocal = readLocal;
46
+ exports.readResourceFile = readResourceFile;
46
47
  exports.writeLocal = writeLocal;
47
48
  exports.removeLocal = removeLocal;
48
49
  exports.listLocal = listLocal;
@@ -323,6 +324,16 @@ function readLocal(root, key, kind) {
323
324
  }
324
325
  return Object.keys(files).length ? files : undefined;
325
326
  }
327
+ /** An extra file inside a resource directory (e.g. cases.yaml); undefined when absent. Refuses symlinks. */
328
+ function readResourceFile(root, key, name) {
329
+ const file = path.join(resolveKey(root, key), name);
330
+ assertNotSymlink(file, `${key}/${name}`);
331
+ if (!fs.existsSync(file))
332
+ return undefined;
333
+ if (!fs.lstatSync(file).isFile())
334
+ throw new errors_1.CliError(`${key}/${name} is not a file`);
335
+ return fs.readFileSync(file, 'utf8');
336
+ }
326
337
  function writeLocal(root, key, kind, files) {
327
338
  const targetPath = resolveKey(root, key);
328
339
  if (kind === 'skill') {
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.observePrompt = observePrompt;
4
+ exports.observeWorkflow = observeWorkflow;
5
+ exports.evaluate = evaluate;
6
+ /**
7
+ * Turns a chat / workflow response into one Observation, and checks an Expect against it.
8
+ */
9
+ const util_1 = require("util");
10
+ const workflow_1 = require("../commands/workflow");
11
+ const jsonpath_1 = require("./jsonpath");
12
+ function parseJson(text) {
13
+ try {
14
+ return JSON.parse(text);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
21
+ const pretty = (v) => JSON.stringify(v ?? null, null, 2);
22
+ function observePrompt(res) {
23
+ const raw = res.response ?? '';
24
+ const parsed = parseJson(raw);
25
+ // A prompt using the final_response tool answers with that JSON as its response string.
26
+ const final = isObject(parsed) && typeof parsed.responseType === 'string' ? parsed : undefined;
27
+ const content = final ? (typeof final.content === 'string' ? final.content : '') : raw;
28
+ // undefined (not null) when absent, so `$.structContent… exists: false` holds.
29
+ const structContent = final?.structContent;
30
+ const json = final ? parseJson(content) : parsed;
31
+ return {
32
+ text: final?.responseType === 'struct' ? pretty(structContent) : content,
33
+ root: { content, structContent, state: {}, tags: [], json: json ?? null },
34
+ path: [],
35
+ terminalNodeId: null,
36
+ responseType: final ? final.responseType : null,
37
+ latencyMs: res.elapsedTime,
38
+ tokens: (res.inputTokens ?? 0) + (res.outputTokens ?? 0),
39
+ llmCalls: null,
40
+ ...(final?.status === 'failed' ? { failedReason: typeof final.reason === 'string' ? final.reason : '' } : {}),
41
+ };
42
+ }
43
+ function observeWorkflow(res) {
44
+ const shaped = (0, workflow_1.shapeWorkflowOutput)(res, 'summary');
45
+ const reply = res.reply ?? {};
46
+ const content = typeof reply.content === 'string' ? reply.content : '';
47
+ const structContent = reply.structContent;
48
+ const text = reply.responseType === 'struct' ? pretty(structContent) : reply.responseType === 'text' ? content : '';
49
+ return {
50
+ text,
51
+ root: {
52
+ content,
53
+ structContent,
54
+ state: shaped.state ?? {},
55
+ tags: shaped.tags ?? [],
56
+ json: parseJson(content) ?? null,
57
+ },
58
+ path: shaped.path,
59
+ terminalNodeId: shaped.terminalNodeId ?? null,
60
+ responseType: reply.responseType ?? null,
61
+ latencyMs: res.elapsedTime,
62
+ tokens: (res.inputTokens ?? 0) + (res.outputTokens ?? 0),
63
+ llmCalls: res.llmCalls ?? 0,
64
+ };
65
+ }
66
+ function containsValue(actual, wanted) {
67
+ if (Array.isArray(actual))
68
+ return actual.some((item) => (0, util_1.isDeepStrictEqual)(item, wanted));
69
+ if (typeof actual === 'string' && typeof wanted === 'string')
70
+ return actual.includes(wanted);
71
+ return false;
72
+ }
73
+ /** Ordered, not necessarily contiguous. */
74
+ function isSubsequence(wanted, path) {
75
+ let i = 0;
76
+ for (const node of path)
77
+ if (node === wanted[i])
78
+ i += 1;
79
+ return i === wanted.length;
80
+ }
81
+ function evaluate(e, obs) {
82
+ const failures = [];
83
+ const fail = (assertion, expected, actual) => failures.push({ assertion, expected, actual });
84
+ for (const s of e.contains ?? [])
85
+ if (!obs.text.includes(s))
86
+ fail('contains', s, obs.text);
87
+ for (const s of e.not_contains ?? [])
88
+ if (obs.text.includes(s))
89
+ fail('not_contains', s, obs.text);
90
+ for (const r of e.regex ?? [])
91
+ if (!new RegExp(r, 'u').test(obs.text))
92
+ fail('regex', r, obs.text);
93
+ if (e.equals !== undefined && obs.text.trim() !== e.equals.trim())
94
+ fail('equals', e.equals, obs.text);
95
+ for (const check of e.json ?? []) {
96
+ const actual = (0, jsonpath_1.readPath)(obs.root, check.segments);
97
+ if (check.exists !== undefined && (actual !== undefined) !== check.exists)
98
+ fail(`json ${check.path} exists`, check.exists, actual);
99
+ if (check.equals && !(0, util_1.isDeepStrictEqual)(actual, check.equals.value))
100
+ fail(`json ${check.path} equals`, check.equals.value, actual);
101
+ if (check.contains && !containsValue(actual, check.contains.value))
102
+ fail(`json ${check.path} contains`, check.contains.value, actual);
103
+ }
104
+ if (e.path_includes && !isSubsequence(e.path_includes, obs.path))
105
+ fail('path_includes', e.path_includes, obs.path);
106
+ if (e.terminal_node !== undefined && obs.terminalNodeId !== e.terminal_node)
107
+ fail('terminal_node', e.terminal_node, obs.terminalNodeId);
108
+ if (e.response_type !== undefined && obs.responseType !== e.response_type)
109
+ fail('response_type', e.response_type, obs.responseType);
110
+ if (e.max_latency_ms !== undefined && obs.latencyMs > e.max_latency_ms)
111
+ fail('max_latency_ms', e.max_latency_ms, obs.latencyMs);
112
+ if (e.max_tokens !== undefined && obs.tokens > e.max_tokens)
113
+ fail('max_tokens', e.max_tokens, obs.tokens);
114
+ if (e.max_llm_calls !== undefined && (obs.llmCalls ?? 0) > e.max_llm_calls)
115
+ fail('max_llm_calls', e.max_llm_calls, obs.llmCalls);
116
+ return failures;
117
+ }
@@ -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,87 @@
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
+ // Workflow runs report fractional milliseconds; the text report shows whole ones.
34
+ r.latencyMs !== undefined ? `${Math.round(r.latencyMs)}ms` : '',
35
+ r.tokens !== undefined ? `${r.tokens} tokens` : '',
36
+ r.version !== undefined ? `v${r.version}` : '',
37
+ ].filter(Boolean).join(', ');
38
+ lines.push(` ${LABEL[r.status]} ${r.case}${stats ? ` (${stats})` : ''}`);
39
+ for (const f of r.failures)
40
+ lines.push(` ${describeFailure(f)}`);
41
+ if (r.error)
42
+ lines.push(` ${r.error.status ? `HTTP ${r.error.status}: ` : ''}${r.error.message}`);
43
+ }
44
+ const s = summarize(results);
45
+ lines.push(`${s.total} cases: ${s.passed} passed, ${s.failed} failed, ${s.errors} errors, ${s.skipped} skipped`);
46
+ return (0, sync_1.safe)(`${lines.join('\n')}\n`);
47
+ }
48
+ function formatJson(results) {
49
+ return `${JSON.stringify({ summary: summarize(results), results }, null, 2)}\n`;
50
+ }
51
+ const xml = (s) => s
52
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, '')
53
+ .replace(/&/g, '&amp;')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;')
56
+ .replace(/"/g, '&quot;');
57
+ function formatJunit(results) {
58
+ const counts = (s) => `tests="${s.total}" failures="${s.failed}" errors="${s.errors}" skipped="${s.skipped}"`;
59
+ const out = ['<?xml version="1.0" encoding="UTF-8"?>', `<testsuites name="metrio verify" ${counts(summarize(results))}>`];
60
+ for (const name of [...new Set(results.map((r) => r.resource))]) {
61
+ const rows = results.filter((r) => r.resource === name);
62
+ out.push(` <testsuite name="${xml(name)}" ${counts(summarize(rows))}>`);
63
+ for (const r of rows) {
64
+ const time = r.latencyMs !== undefined ? ` time="${(r.latencyMs / 1000).toFixed(3)}"` : '';
65
+ const open = ` <testcase classname="${xml(name)}" name="${xml(r.case)}"${time}`;
66
+ if (r.status === 'pass') {
67
+ out.push(`${open}/>`);
68
+ continue;
69
+ }
70
+ out.push(`${open}>`);
71
+ if (r.status === 'fail') {
72
+ const body = r.failures.map(describeFailure).join('\n');
73
+ out.push(` <failure message="${r.failures.length} assertion(s) failed">${xml(body)}</failure>`);
74
+ }
75
+ else if (r.status === 'error') {
76
+ out.push(` <error message="${xml(r.error?.message ?? 'error')}"/>`);
77
+ }
78
+ else {
79
+ out.push(' <skipped/>');
80
+ }
81
+ out.push(' </testcase>');
82
+ }
83
+ out.push(' </testsuite>');
84
+ }
85
+ out.push('</testsuites>', '');
86
+ return out.join('\n');
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metrio-ai/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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": {