mcp-vitals 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/bin/mcp-vitals.js +12 -0
- package/dist/args.d.ts +9 -0
- package/dist/args.js +50 -0
- package/dist/assertions/loader.d.ts +9 -0
- package/dist/assertions/loader.js +75 -0
- package/dist/assertions/run.d.ts +19 -0
- package/dist/assertions/run.js +154 -0
- package/dist/assertions/schema.d.ts +147 -0
- package/dist/assertions/schema.js +72 -0
- package/dist/bench/engine.d.ts +7 -0
- package/dist/bench/engine.js +121 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +137 -0
- package/dist/commands/bench.d.ts +13 -0
- package/dist/commands/bench.js +129 -0
- package/dist/commands/call.d.ts +8 -0
- package/dist/commands/call.js +84 -0
- package/dist/commands/check.d.ts +13 -0
- package/dist/commands/check.js +140 -0
- package/dist/commands/inspect.d.ts +10 -0
- package/dist/commands/inspect.js +129 -0
- package/dist/commands/ping.d.ts +6 -0
- package/dist/commands/ping.js +55 -0
- package/dist/context.d.ts +30 -0
- package/dist/context.js +114 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.js +52 -0
- package/dist/glob.d.ts +3 -0
- package/dist/glob.js +40 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/mcpClient.d.ts +38 -0
- package/dist/mcpClient.js +192 -0
- package/dist/output.d.ts +2 -0
- package/dist/output.js +8 -0
- package/dist/renderers/colors.d.ts +9 -0
- package/dist/renderers/colors.js +16 -0
- package/dist/renderers/json.d.ts +2 -0
- package/dist/renderers/json.js +5 -0
- package/dist/renderers/junit.d.ts +3 -0
- package/dist/renderers/junit.js +32 -0
- package/dist/renderers/progress.d.ts +16 -0
- package/dist/renderers/progress.js +29 -0
- package/dist/renderers/table.d.ts +14 -0
- package/dist/renderers/table.js +43 -0
- package/dist/schema.d.ts +12 -0
- package/dist/schema.js +47 -0
- package/dist/stats.d.ts +12 -0
- package/dist/stats.js +54 -0
- package/dist/thresholds.d.ts +21 -0
- package/dist/thresholds.js +109 -0
- package/dist/transport.d.ts +8 -0
- package/dist/transport.js +68 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.js +2 -0
- package/package.json +83 -0
- package/schema/assertions.schema.json +177 -0
package/dist/glob.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Minimal name-glob matcher for --filter/--only/--skip. Supports `*` and `?`.
|
|
2
|
+
// No external dependency, no path semantics — just simple name matching.
|
|
3
|
+
//
|
|
4
|
+
// Implemented as a linear two-pointer matcher rather than a `.*`-based regex,
|
|
5
|
+
// so there is no catastrophic backtracking (ReDoS) on pathological globs like
|
|
6
|
+
// `"*".repeat(20) + "x"` against a long name.
|
|
7
|
+
export function matchesGlob(name, glob) {
|
|
8
|
+
let n = 0;
|
|
9
|
+
let g = 0;
|
|
10
|
+
let star = -1;
|
|
11
|
+
let mark = 0;
|
|
12
|
+
while (n < name.length) {
|
|
13
|
+
if (g < glob.length && (glob[g] === '?' || glob[g] === name[n])) {
|
|
14
|
+
n++;
|
|
15
|
+
g++;
|
|
16
|
+
}
|
|
17
|
+
else if (g < glob.length && glob[g] === '*') {
|
|
18
|
+
star = g;
|
|
19
|
+
mark = n;
|
|
20
|
+
g++;
|
|
21
|
+
}
|
|
22
|
+
else if (star !== -1) {
|
|
23
|
+
g = star + 1;
|
|
24
|
+
mark++;
|
|
25
|
+
n = mark;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
while (g < glob.length && glob[g] === '*')
|
|
32
|
+
g++;
|
|
33
|
+
return g === glob.length;
|
|
34
|
+
}
|
|
35
|
+
/** True if `name` matches any of the provided globs (empty list => true). */
|
|
36
|
+
export function matchesAny(name, globs) {
|
|
37
|
+
if (!globs || globs.length === 0)
|
|
38
|
+
return true;
|
|
39
|
+
return globs.some((g) => matchesGlob(name, g));
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Connection } from './mcpClient.js';
|
|
2
|
+
export type { CallResult } from './mcpClient.js';
|
|
3
|
+
export { runBench } from './bench/engine.js';
|
|
4
|
+
export type { BenchHooks } from './bench/engine.js';
|
|
5
|
+
export { computeStats, percentile } from './stats.js';
|
|
6
|
+
export { parseExpr, parseDuration, evaluate, evaluateExpr } from './thresholds.js';
|
|
7
|
+
export { validateJsonSchema } from './schema.js';
|
|
8
|
+
export { loadConfig, discoverConfig } from './assertions/loader.js';
|
|
9
|
+
export { runChecks } from './assertions/run.js';
|
|
10
|
+
export { main, buildProgram } from './cli.js';
|
|
11
|
+
export { EXIT_CODES } from './errors.js';
|
|
12
|
+
export type * from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Public library surface. The CLI is the primary interface, but the protocol
|
|
2
|
+
// client, bench engine, stats, and assertion runner are exported for reuse.
|
|
3
|
+
export { Connection } from './mcpClient.js';
|
|
4
|
+
export { runBench } from './bench/engine.js';
|
|
5
|
+
export { computeStats, percentile } from './stats.js';
|
|
6
|
+
export { parseExpr, parseDuration, evaluate, evaluateExpr } from './thresholds.js';
|
|
7
|
+
export { validateJsonSchema } from './schema.js';
|
|
8
|
+
export { loadConfig, discoverConfig } from './assertions/loader.js';
|
|
9
|
+
export { runChecks } from './assertions/run.js';
|
|
10
|
+
export { main, buildProgram } from './cli.js';
|
|
11
|
+
export { EXIT_CODES } from './errors.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import type { CapabilitySummary, ProbeOp, PromptInfo, ResourceInfo, ServerIdentity, ToolInfo, TransportKind, TransportSpec } from './types.js';
|
|
3
|
+
export interface CallResult {
|
|
4
|
+
content: unknown[];
|
|
5
|
+
isError: boolean;
|
|
6
|
+
structuredContent?: unknown;
|
|
7
|
+
}
|
|
8
|
+
/** A live, connected MCP client plus the metadata mcp-vitals needs. */
|
|
9
|
+
export declare class Connection {
|
|
10
|
+
readonly client: Client;
|
|
11
|
+
readonly kind: TransportKind;
|
|
12
|
+
readonly target: string;
|
|
13
|
+
readonly coldStartMs: number;
|
|
14
|
+
private readonly requestTimeoutMs;
|
|
15
|
+
private constructor();
|
|
16
|
+
/**
|
|
17
|
+
* Wrap an already-connected SDK Client (e.g. over InMemoryTransport in tests,
|
|
18
|
+
* or a client you manage yourself). The client must already be `connect()`ed.
|
|
19
|
+
*/
|
|
20
|
+
static fromClient(client: Client, opts?: {
|
|
21
|
+
kind?: TransportKind;
|
|
22
|
+
target?: string;
|
|
23
|
+
coldStartMs?: number;
|
|
24
|
+
requestTimeoutMs?: number;
|
|
25
|
+
}): Connection;
|
|
26
|
+
/** Connect (timed), with an automatic Streamable-HTTP → SSE fallback. */
|
|
27
|
+
static connect(spec: TransportSpec): Promise<Connection>;
|
|
28
|
+
identity(): ServerIdentity;
|
|
29
|
+
capabilities(): CapabilitySummary;
|
|
30
|
+
listTools(): Promise<ToolInfo[]>;
|
|
31
|
+
listResources(): Promise<ResourceInfo[]>;
|
|
32
|
+
listPrompts(): Promise<PromptInfo[]>;
|
|
33
|
+
callTool(name: string, args: unknown, timeoutMs?: number): Promise<CallResult>;
|
|
34
|
+
ping(timeoutMs?: number): Promise<void>;
|
|
35
|
+
/** A single no-arg protocol round-trip (always hits the server, no early return). */
|
|
36
|
+
probeOnce(op: ProbeOp, timeoutMs?: number): Promise<void>;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { createTransport, describeTarget, inferKind } from './transport.js';
|
|
3
|
+
import { ConnectionError } from './errors.js';
|
|
4
|
+
const CLIENT_INFO = { name: 'mcp-vitals', version: '0.1.0' };
|
|
5
|
+
function nowMs() {
|
|
6
|
+
return process.hrtime.bigint();
|
|
7
|
+
}
|
|
8
|
+
function elapsedMs(start) {
|
|
9
|
+
return Number(nowMs() - start) / 1e6;
|
|
10
|
+
}
|
|
11
|
+
/** A live, connected MCP client plus the metadata mcp-vitals needs. */
|
|
12
|
+
export class Connection {
|
|
13
|
+
client;
|
|
14
|
+
kind;
|
|
15
|
+
target;
|
|
16
|
+
coldStartMs;
|
|
17
|
+
requestTimeoutMs;
|
|
18
|
+
constructor(client, kind, target, coldStartMs, requestTimeoutMs) {
|
|
19
|
+
this.client = client;
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
this.target = target;
|
|
22
|
+
this.coldStartMs = coldStartMs;
|
|
23
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Wrap an already-connected SDK Client (e.g. over InMemoryTransport in tests,
|
|
27
|
+
* or a client you manage yourself). The client must already be `connect()`ed.
|
|
28
|
+
*/
|
|
29
|
+
static fromClient(client, opts = {}) {
|
|
30
|
+
return new Connection(client, opts.kind ?? 'stdio', opts.target ?? 'in-memory', opts.coldStartMs ?? 0, opts.requestTimeoutMs ?? 10_000);
|
|
31
|
+
}
|
|
32
|
+
/** Connect (timed), with an automatic Streamable-HTTP → SSE fallback. */
|
|
33
|
+
static async connect(spec) {
|
|
34
|
+
const kind = inferKind(spec);
|
|
35
|
+
const target = describeTarget(spec, kind);
|
|
36
|
+
// First attempt with the resolved/forced transport.
|
|
37
|
+
let firstErr;
|
|
38
|
+
try {
|
|
39
|
+
const r = await connectOnce(spec, kind);
|
|
40
|
+
return new Connection(r.client, kind, target, r.coldStartMs, spec.requestTimeoutMs);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
// Fall back to SSE only when http was inferred (not explicitly forced).
|
|
44
|
+
const canFallback = kind === 'http' && spec.forced === undefined && Boolean(spec.url);
|
|
45
|
+
if (!canFallback) {
|
|
46
|
+
throw new ConnectionError(connectMessage(target, spec, err));
|
|
47
|
+
}
|
|
48
|
+
firstErr = err;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const r = await connectOnce(spec, 'sse');
|
|
52
|
+
return new Connection(r.client, 'sse', target, r.coldStartMs, spec.requestTimeoutMs);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const base = connectMessage(target, spec, err);
|
|
56
|
+
// Preserve the original Streamable-HTTP error (e.g. the server's auth body)
|
|
57
|
+
// instead of misattributing the failure to the SSE attempt.
|
|
58
|
+
const httpNote = firstErr
|
|
59
|
+
? `\n (Streamable HTTP attempt failed first: ${firstErr.message})`
|
|
60
|
+
: '';
|
|
61
|
+
throw new ConnectionError(base + httpNote);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
identity() {
|
|
65
|
+
const v = this.client.getServerVersion();
|
|
66
|
+
// The negotiated protocol version has no stable client getter in SDK 1.29.
|
|
67
|
+
// Streamable HTTP exposes a public `protocolVersion`; SSE keeps it in a
|
|
68
|
+
// private field. stdio tracks it nowhere, so it stays undefined there.
|
|
69
|
+
const t = this.client
|
|
70
|
+
.transport;
|
|
71
|
+
const proto = (typeof t?.protocolVersion === 'string' ? t.protocolVersion : undefined) ??
|
|
72
|
+
(typeof t?._protocolVersion === 'string' ? t._protocolVersion : undefined);
|
|
73
|
+
return {
|
|
74
|
+
name: v?.name ?? '(unknown)',
|
|
75
|
+
version: v?.version ?? '0.0.0',
|
|
76
|
+
protocolVersion: proto,
|
|
77
|
+
instructions: this.client.getInstructions(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
capabilities() {
|
|
81
|
+
const caps = (this.client.getServerCapabilities() ?? {});
|
|
82
|
+
return {
|
|
83
|
+
tools: caps.tools !== undefined,
|
|
84
|
+
resources: caps.resources !== undefined,
|
|
85
|
+
prompts: caps.prompts !== undefined,
|
|
86
|
+
logging: caps.logging !== undefined,
|
|
87
|
+
completions: caps.completions !== undefined,
|
|
88
|
+
raw: caps,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async listTools() {
|
|
92
|
+
if (!this.capabilities().tools)
|
|
93
|
+
return [];
|
|
94
|
+
const out = [];
|
|
95
|
+
let cursor;
|
|
96
|
+
do {
|
|
97
|
+
const res = await this.client.listTools(cursor ? { cursor } : undefined, { timeout: this.requestTimeoutMs });
|
|
98
|
+
for (const t of res.tools) {
|
|
99
|
+
const input = t.inputSchema;
|
|
100
|
+
const props = input?.properties ?? {};
|
|
101
|
+
out.push({
|
|
102
|
+
name: t.name,
|
|
103
|
+
title: t.title ?? t.annotations?.title,
|
|
104
|
+
description: t.description,
|
|
105
|
+
inputSchema: t.inputSchema,
|
|
106
|
+
outputSchema: t.outputSchema,
|
|
107
|
+
requiredArgs: input?.required?.length ?? 0,
|
|
108
|
+
totalArgs: Object.keys(props).length,
|
|
109
|
+
schemaValid: true,
|
|
110
|
+
schemaErrors: [],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
cursor = res.nextCursor;
|
|
114
|
+
} while (cursor);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
async listResources() {
|
|
118
|
+
if (!this.capabilities().resources)
|
|
119
|
+
return [];
|
|
120
|
+
const out = [];
|
|
121
|
+
let cursor;
|
|
122
|
+
do {
|
|
123
|
+
const res = await this.client.listResources(cursor ? { cursor } : undefined, { timeout: this.requestTimeoutMs });
|
|
124
|
+
for (const r of res.resources) {
|
|
125
|
+
out.push({ uri: r.uri, name: r.name, mimeType: r.mimeType });
|
|
126
|
+
}
|
|
127
|
+
cursor = res.nextCursor;
|
|
128
|
+
} while (cursor);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
async listPrompts() {
|
|
132
|
+
if (!this.capabilities().prompts)
|
|
133
|
+
return [];
|
|
134
|
+
const out = [];
|
|
135
|
+
let cursor;
|
|
136
|
+
do {
|
|
137
|
+
const res = await this.client.listPrompts(cursor ? { cursor } : undefined, { timeout: this.requestTimeoutMs });
|
|
138
|
+
for (const p of res.prompts) {
|
|
139
|
+
out.push({
|
|
140
|
+
name: p.name,
|
|
141
|
+
description: p.description,
|
|
142
|
+
arguments: (p.arguments ?? []).map((a) => ({ name: a.name, required: a.required })),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
cursor = res.nextCursor;
|
|
146
|
+
} while (cursor);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
async callTool(name, args, timeoutMs) {
|
|
150
|
+
const res = await this.client.callTool({ name, arguments: (args ?? {}) }, undefined, { timeout: timeoutMs ?? this.requestTimeoutMs });
|
|
151
|
+
return {
|
|
152
|
+
content: (res.content ?? []),
|
|
153
|
+
isError: res.isError === true,
|
|
154
|
+
structuredContent: res.structuredContent,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async ping(timeoutMs) {
|
|
158
|
+
await this.client.ping({ timeout: timeoutMs ?? this.requestTimeoutMs });
|
|
159
|
+
}
|
|
160
|
+
/** A single no-arg protocol round-trip (always hits the server, no early return). */
|
|
161
|
+
async probeOnce(op, timeoutMs) {
|
|
162
|
+
const options = { timeout: timeoutMs ?? this.requestTimeoutMs };
|
|
163
|
+
if (op === 'listTools')
|
|
164
|
+
await this.client.listTools(undefined, options);
|
|
165
|
+
else if (op === 'listResources')
|
|
166
|
+
await this.client.listResources(undefined, options);
|
|
167
|
+
else
|
|
168
|
+
await this.client.listPrompts(undefined, options);
|
|
169
|
+
}
|
|
170
|
+
async close() {
|
|
171
|
+
try {
|
|
172
|
+
await this.client.close();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// closing best-effort; never mask the real result.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Build a transport and connect a fresh Client over it (timed cold start). */
|
|
180
|
+
async function connectOnce(spec, kind) {
|
|
181
|
+
const start = nowMs();
|
|
182
|
+
const client = new Client(CLIENT_INFO);
|
|
183
|
+
await client.connect(createTransport(spec, kind), { timeout: spec.connectTimeoutMs });
|
|
184
|
+
return { client, coldStartMs: elapsedMs(start) };
|
|
185
|
+
}
|
|
186
|
+
function connectMessage(target, spec, err) {
|
|
187
|
+
const base = `could not connect to ${target}: ${err.message}`;
|
|
188
|
+
if (spec.command) {
|
|
189
|
+
return `${base}\n hint: the server may need env vars — try --env KEY=VALUE or --inherit-env`;
|
|
190
|
+
}
|
|
191
|
+
return base;
|
|
192
|
+
}
|
package/dist/output.d.ts
ADDED
package/dist/output.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Centralized stdout/stderr writers to keep --json output pure.
|
|
2
|
+
// Human/result output -> stdout. Progress/logs -> stderr.
|
|
3
|
+
export function writeOut(line = '') {
|
|
4
|
+
process.stdout.write(line + '\n');
|
|
5
|
+
}
|
|
6
|
+
export function writeErr(line = '') {
|
|
7
|
+
process.stderr.write(line + '\n');
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
export type Colors = ReturnType<typeof pc.createColors>;
|
|
3
|
+
/** Build a picocolors instance honoring our resolved color decision. */
|
|
4
|
+
export declare function makeColors(enabled: boolean): Colors;
|
|
5
|
+
/**
|
|
6
|
+
* Decide whether to emit ANSI color: explicit --no-color and NO_COLOR win,
|
|
7
|
+
* otherwise only when stdout is a TTY.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveColor(noColor: boolean): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
/** Build a picocolors instance honoring our resolved color decision. */
|
|
3
|
+
export function makeColors(enabled) {
|
|
4
|
+
return pc.createColors(enabled);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Decide whether to emit ANSI color: explicit --no-color and NO_COLOR win,
|
|
8
|
+
* otherwise only when stdout is a TTY.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveColor(noColor) {
|
|
11
|
+
if (noColor)
|
|
12
|
+
return false;
|
|
13
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '')
|
|
14
|
+
return false;
|
|
15
|
+
return process.stdout.isTTY === true;
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function escapeXml(s) {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
/** Render check results as JUnit XML (one <testcase> per assertion). */
|
|
10
|
+
export function renderJUnit(rows, summary) {
|
|
11
|
+
const seconds = (summary.durationMs / 1000).toFixed(3);
|
|
12
|
+
const lines = [];
|
|
13
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
14
|
+
lines.push(`<testsuites name="mcp-vitals" tests="${rows.length}" failures="${summary.failed}" skipped="${summary.skipped}" time="${seconds}">`);
|
|
15
|
+
lines.push(` <testsuite name="mcp-vitals" tests="${rows.length}" failures="${summary.failed}" skipped="${summary.skipped}" time="${seconds}">`);
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
const name = escapeXml(row.id);
|
|
18
|
+
const cls = `mcp-vitals.${row.kind}`;
|
|
19
|
+
lines.push(` <testcase name="${name}" classname="${escapeXml(cls)}" time="0">`);
|
|
20
|
+
if (row.status === 'fail') {
|
|
21
|
+
const msg = escapeXml(`${row.target}: expected ${row.expected}, got ${row.actual}`);
|
|
22
|
+
lines.push(` <failure message="${msg}">${msg}</failure>`);
|
|
23
|
+
}
|
|
24
|
+
else if (row.status === 'skip') {
|
|
25
|
+
lines.push(' <skipped/>');
|
|
26
|
+
}
|
|
27
|
+
lines.push(' </testcase>');
|
|
28
|
+
}
|
|
29
|
+
lines.push(' </testsuite>');
|
|
30
|
+
lines.push('</testsuites>');
|
|
31
|
+
return lines.join('\n');
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ProgressOptions {
|
|
2
|
+
quiet: boolean;
|
|
3
|
+
json: boolean;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Stderr-only progress reporter. No-ops under --quiet. Output always goes to
|
|
7
|
+
* stderr so --json stdout stays a single clean object.
|
|
8
|
+
*/
|
|
9
|
+
export declare class Progress {
|
|
10
|
+
private readonly enabled;
|
|
11
|
+
constructor(opts: ProgressOptions);
|
|
12
|
+
note(message: string): void;
|
|
13
|
+
/** Emit a one-line status that overwrites in place on a TTY. */
|
|
14
|
+
status(message: string): void;
|
|
15
|
+
clearStatus(): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { writeErr } from '../output.js';
|
|
2
|
+
const ERASE_LINE = '\r[2K';
|
|
3
|
+
/**
|
|
4
|
+
* Stderr-only progress reporter. No-ops under --quiet. Output always goes to
|
|
5
|
+
* stderr so --json stdout stays a single clean object.
|
|
6
|
+
*/
|
|
7
|
+
export class Progress {
|
|
8
|
+
enabled;
|
|
9
|
+
constructor(opts) {
|
|
10
|
+
this.enabled = !opts.quiet;
|
|
11
|
+
}
|
|
12
|
+
note(message) {
|
|
13
|
+
if (this.enabled)
|
|
14
|
+
writeErr(message);
|
|
15
|
+
}
|
|
16
|
+
/** Emit a one-line status that overwrites in place on a TTY. */
|
|
17
|
+
status(message) {
|
|
18
|
+
if (!this.enabled)
|
|
19
|
+
return;
|
|
20
|
+
if (process.stderr.isTTY) {
|
|
21
|
+
process.stderr.write(`\r${message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
clearStatus() {
|
|
25
|
+
if (this.enabled && process.stderr.isTTY) {
|
|
26
|
+
process.stderr.write(ERASE_LINE);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Colors } from './colors.js';
|
|
2
|
+
export interface Column {
|
|
3
|
+
header: string;
|
|
4
|
+
align?: 'left' | 'right';
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Render an aligned text table. Padding is computed on plain text, then color
|
|
8
|
+
* is applied to already-padded cells so ANSI codes never break alignment.
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderTable(columns: Column[], rows: string[][], c: Colors): string;
|
|
11
|
+
/** A "Key: value" header block, keys dimmed. */
|
|
12
|
+
export declare function renderKeyValues(pairs: [string, string][], c: Colors): string;
|
|
13
|
+
export declare function formatMs(n: number | null | undefined): string;
|
|
14
|
+
export declare function formatPct(fraction: number): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an aligned text table. Padding is computed on plain text, then color
|
|
3
|
+
* is applied to already-padded cells so ANSI codes never break alignment.
|
|
4
|
+
*/
|
|
5
|
+
export function renderTable(columns, rows, c) {
|
|
6
|
+
const widths = columns.map((col, i) => {
|
|
7
|
+
let w = col.header.length;
|
|
8
|
+
for (const row of rows) {
|
|
9
|
+
const cell = row[i] ?? '';
|
|
10
|
+
if (cell.length > w)
|
|
11
|
+
w = cell.length;
|
|
12
|
+
}
|
|
13
|
+
return w;
|
|
14
|
+
});
|
|
15
|
+
const pad = (text, i) => {
|
|
16
|
+
const w = widths[i] ?? text.length;
|
|
17
|
+
const align = columns[i]?.align ?? 'left';
|
|
18
|
+
return align === 'right' ? text.padStart(w) : text.padEnd(w);
|
|
19
|
+
};
|
|
20
|
+
const headerLine = columns.map((col, i) => c.bold(pad(col.header, i))).join(' ');
|
|
21
|
+
const lines = [headerLine];
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
lines.push(columns.map((_, i) => pad(row[i] ?? '', i)).join(' '));
|
|
24
|
+
}
|
|
25
|
+
return lines.join('\n');
|
|
26
|
+
}
|
|
27
|
+
/** A "Key: value" header block, keys dimmed. */
|
|
28
|
+
export function renderKeyValues(pairs, c) {
|
|
29
|
+
const keyWidth = Math.max(0, ...pairs.map(([k]) => k.length));
|
|
30
|
+
return pairs.map(([k, v]) => `${c.dim((k + ':').padEnd(keyWidth + 1))} ${v}`).join('\n');
|
|
31
|
+
}
|
|
32
|
+
export function formatMs(n) {
|
|
33
|
+
if (n === null || n === undefined || Number.isNaN(n))
|
|
34
|
+
return '—';
|
|
35
|
+
if (n >= 1000)
|
|
36
|
+
return `${(n / 1000).toFixed(2)} s`;
|
|
37
|
+
if (n >= 100)
|
|
38
|
+
return `${n.toFixed(0)} ms`;
|
|
39
|
+
return `${n.toFixed(1)} ms`;
|
|
40
|
+
}
|
|
41
|
+
export function formatPct(fraction) {
|
|
42
|
+
return `${(fraction * 100).toFixed(1)}%`;
|
|
43
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SchemaError } from './types.js';
|
|
2
|
+
export interface SchemaCheck {
|
|
3
|
+
valid: boolean;
|
|
4
|
+
errors: SchemaError[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Validate that `schema` is itself a compilable JSON Schema (draft 2020-12).
|
|
8
|
+
* A tool's inputSchema is "valid" iff Ajv can compile it.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateJsonSchema(schema: unknown): SchemaCheck;
|
|
11
|
+
/** Validate `data` against a (trusted) JSON Schema. Used for the assertions config. */
|
|
12
|
+
export declare function validateData(schema: object, data: unknown): SchemaCheck;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as ajv2020 from 'ajv/dist/2020.js';
|
|
2
|
+
import * as ajvFormats from 'ajv-formats';
|
|
3
|
+
// ajv + ajv-formats are CJS with `export default`; under NodeNext the default
|
|
4
|
+
// can land on `.default` or on the namespace itself. Tolerate both, and type
|
|
5
|
+
// structurally against the small surface we use (avoids ajv's class/namespace merge).
|
|
6
|
+
const Ajv2020 = (ajv2020.default ?? ajv2020);
|
|
7
|
+
const addFormats = (ajvFormats.default ?? ajvFormats);
|
|
8
|
+
function makeAjv() {
|
|
9
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: true });
|
|
10
|
+
addFormats(ajv);
|
|
11
|
+
return ajv;
|
|
12
|
+
}
|
|
13
|
+
function formatErrors(errors) {
|
|
14
|
+
if (!errors)
|
|
15
|
+
return [];
|
|
16
|
+
return errors.map((e) => ({
|
|
17
|
+
path: e.instancePath || e.schemaPath || '/',
|
|
18
|
+
message: e.message ?? 'invalid',
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Validate that `schema` is itself a compilable JSON Schema (draft 2020-12).
|
|
23
|
+
* A tool's inputSchema is "valid" iff Ajv can compile it.
|
|
24
|
+
*/
|
|
25
|
+
export function validateJsonSchema(schema) {
|
|
26
|
+
if (schema === null || typeof schema !== 'object') {
|
|
27
|
+
return { valid: false, errors: [{ path: '/', message: 'schema is not an object' }] };
|
|
28
|
+
}
|
|
29
|
+
const ajv = makeAjv();
|
|
30
|
+
try {
|
|
31
|
+
ajv.compile(schema);
|
|
32
|
+
return { valid: true, errors: [] };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return {
|
|
36
|
+
valid: false,
|
|
37
|
+
errors: [{ path: '/', message: err.message.split('\n')[0] ?? 'invalid schema' }],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Validate `data` against a (trusted) JSON Schema. Used for the assertions config. */
|
|
42
|
+
export function validateData(schema, data) {
|
|
43
|
+
const ajv = makeAjv();
|
|
44
|
+
const validate = ajv.compile(schema);
|
|
45
|
+
const ok = validate(data);
|
|
46
|
+
return { valid: ok, errors: ok ? [] : formatErrors(validate.errors) };
|
|
47
|
+
}
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Stats } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Nearest-rank percentile on an already-sorted ascending array.
|
|
4
|
+
* pct(q) = arr[min(len-1, ceil(q/100*len)-1)].
|
|
5
|
+
*/
|
|
6
|
+
export declare function percentile(sortedAsc: number[], q: number): number;
|
|
7
|
+
export declare function mean(values: number[]): number;
|
|
8
|
+
export declare function stddev(values: number[], mu?: number): number;
|
|
9
|
+
/** Compute the full latency distribution from raw (unsorted) samples. */
|
|
10
|
+
export declare function computeStats(samples: number[]): Stats | null;
|
|
11
|
+
/** Round to 2 decimals for display without lying about precision. */
|
|
12
|
+
export declare function round2(n: number): number;
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nearest-rank percentile on an already-sorted ascending array.
|
|
3
|
+
* pct(q) = arr[min(len-1, ceil(q/100*len)-1)].
|
|
4
|
+
*/
|
|
5
|
+
export function percentile(sortedAsc, q) {
|
|
6
|
+
const len = sortedAsc.length;
|
|
7
|
+
if (len === 0)
|
|
8
|
+
return NaN;
|
|
9
|
+
const rank = Math.ceil((q / 100) * len) - 1;
|
|
10
|
+
const idx = Math.min(len - 1, Math.max(0, rank));
|
|
11
|
+
return sortedAsc[idx];
|
|
12
|
+
}
|
|
13
|
+
export function mean(values) {
|
|
14
|
+
if (values.length === 0)
|
|
15
|
+
return NaN;
|
|
16
|
+
let sum = 0;
|
|
17
|
+
for (const v of values)
|
|
18
|
+
sum += v;
|
|
19
|
+
return sum / values.length;
|
|
20
|
+
}
|
|
21
|
+
export function stddev(values, mu) {
|
|
22
|
+
const n = values.length;
|
|
23
|
+
if (n === 0)
|
|
24
|
+
return NaN;
|
|
25
|
+
const m = mu ?? mean(values);
|
|
26
|
+
let acc = 0;
|
|
27
|
+
for (const v of values)
|
|
28
|
+
acc += (v - m) ** 2;
|
|
29
|
+
// Population standard deviation (we have the full sample set).
|
|
30
|
+
return Math.sqrt(acc / n);
|
|
31
|
+
}
|
|
32
|
+
/** Compute the full latency distribution from raw (unsorted) samples. */
|
|
33
|
+
export function computeStats(samples) {
|
|
34
|
+
if (samples.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
37
|
+
const m = mean(sorted);
|
|
38
|
+
return {
|
|
39
|
+
count: sorted.length,
|
|
40
|
+
min: sorted[0],
|
|
41
|
+
mean: m,
|
|
42
|
+
p50: percentile(sorted, 50),
|
|
43
|
+
p90: percentile(sorted, 90),
|
|
44
|
+
p95: percentile(sorted, 95),
|
|
45
|
+
p99: percentile(sorted, 99),
|
|
46
|
+
max: sorted[sorted.length - 1],
|
|
47
|
+
stddev: stddev(sorted, m),
|
|
48
|
+
unit: 'ms',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Round to 2 decimals for display without lying about precision. */
|
|
52
|
+
export function round2(n) {
|
|
53
|
+
return Math.round(n * 100) / 100;
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AssertionOutcome, CompareOp, Stats, Throughput } from './types.js';
|
|
2
|
+
export declare const METRICS: readonly ["p50", "p90", "p95", "p99", "max", "min", "mean", "stddev", "errorRate"];
|
|
3
|
+
export type Metric = (typeof METRICS)[number];
|
|
4
|
+
/**
|
|
5
|
+
* Parse a duration to milliseconds.
|
|
6
|
+
* Bare number => ms. Suffix `ms` => ms. Suffix `s` => seconds.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseDuration(input: string | number): number;
|
|
9
|
+
/** errorRate is a fraction 0..1; everything else is a duration. */
|
|
10
|
+
export declare function parseBound(metric: string, raw: string | number): number;
|
|
11
|
+
export interface ParsedExpr {
|
|
12
|
+
metric: string;
|
|
13
|
+
op: CompareOp;
|
|
14
|
+
bound: number;
|
|
15
|
+
}
|
|
16
|
+
/** Parse an inline `--fail-on` expression like `p95<200ms` or `errorRate<=0`. */
|
|
17
|
+
export declare function parseExpr(expr: string): ParsedExpr;
|
|
18
|
+
/** Evaluate one parsed expression against a stats + throughput pair. */
|
|
19
|
+
export declare function evaluate(parsed: ParsedExpr, stats: Stats | null, throughput: Throughput): AssertionOutcome;
|
|
20
|
+
/** Convenience for inline string expressions. */
|
|
21
|
+
export declare function evaluateExpr(expr: string, stats: Stats | null, throughput: Throughput): AssertionOutcome;
|