@jmoak/vibrissa 0.1.0 → 2.0.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.
@@ -1,656 +0,0 @@
1
- // src/mcp-session.ts
2
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
- import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
5
- var CLIENT_INFO = { name: "vibrissa", version: "0.1.0" };
6
- function inheritedEnv(overrides) {
7
- const env = {};
8
- for (const [key, value] of Object.entries(process.env)) {
9
- if (typeof value === "string") env[key] = value;
10
- }
11
- return { ...env, ...overrides };
12
- }
13
- function errorCodeName(code) {
14
- const name = ErrorCode[code];
15
- return typeof name === "string" ? name : void 0;
16
- }
17
- function textFromContent(content) {
18
- if (!Array.isArray(content)) return "";
19
- return content.filter((item) => {
20
- return typeof item === "object" && item !== null && item.type === "text" && typeof item.text === "string";
21
- }).map((item) => item.text).join("\n");
22
- }
23
- function stripMcpErrorPrefix(message) {
24
- return message.replace(/^MCP error -?\d+:\s*/, "");
25
- }
26
- async function withTimeout(promise, timeoutMs, message) {
27
- let handle;
28
- const timeout = new Promise((_, reject) => {
29
- handle = setTimeout(() => reject(new Error(message)), timeoutMs);
30
- });
31
- try {
32
- return await Promise.race([promise, timeout]);
33
- } finally {
34
- if (handle) clearTimeout(handle);
35
- }
36
- }
37
- var McpSession = class _McpSession {
38
- constructor(client) {
39
- this.client = client;
40
- }
41
- static async start(server, timeoutMs) {
42
- const transport = new StdioClientTransport({
43
- command: server.cmd,
44
- args: server.args ?? [],
45
- cwd: server.cwd ?? ".",
46
- env: inheritedEnv(server.env ?? {}),
47
- stderr: "inherit"
48
- });
49
- const client = new Client(CLIENT_INFO, { capabilities: {} });
50
- try {
51
- await withTimeout(client.connect(transport), timeoutMs, "Server spawn timeout");
52
- } catch (err) {
53
- await transport.close().catch(() => {
54
- });
55
- if (err instanceof McpError && err.code === ErrorCode.ConnectionClosed) {
56
- throw new Error(`Server exited early before completing MCP initialization: ${err.message}`);
57
- }
58
- throw err;
59
- }
60
- return new _McpSession(client);
61
- }
62
- async callTool(name, args, timeoutMs) {
63
- try {
64
- const result = await this.client.callTool({ name, arguments: args }, void 0, {
65
- timeout: timeoutMs
66
- });
67
- if (result.isError) {
68
- return {
69
- kind: "error",
70
- error: { kind: "tool", message: textFromContent(result.content) }
71
- };
72
- }
73
- return { kind: "result", result };
74
- } catch (err) {
75
- if (err instanceof McpError) {
76
- return {
77
- kind: "error",
78
- error: {
79
- kind: "protocol",
80
- code: err.code,
81
- codeName: errorCodeName(err.code),
82
- message: stripMcpErrorPrefix(err.message)
83
- }
84
- };
85
- }
86
- throw err;
87
- }
88
- }
89
- async listTools() {
90
- const { tools } = await this.client.listTools();
91
- return tools.map((tool) => ({
92
- name: tool.name,
93
- description: tool.description,
94
- inputSchema: tool.inputSchema
95
- }));
96
- }
97
- async close() {
98
- await this.client.close().catch(() => {
99
- });
100
- }
101
- };
102
-
103
- // src/defaults.ts
104
- var defaultRunCasesOptions = {
105
- server: { cmd: "node", args: ["dist/index.js"], cwd: ".", env: {} },
106
- globs: ["tests/integration/**/*.json"],
107
- timeoutMs: 15e3,
108
- failFast: false,
109
- display: "pretty",
110
- allowEmpty: false
111
- };
112
-
113
- // src/display.ts
114
- import { diffStringsUnified } from "jest-diff";
115
- var ConsoleResultsDisplay = class {
116
- onStart(_total) {
117
- }
118
- onCasePass(_name) {
119
- }
120
- onCaseFail(name, error) {
121
- console.error(`Case failed: ${name}${error ? ` - ${error}` : ""}`);
122
- }
123
- onComplete(_summary) {
124
- }
125
- };
126
- var RESET = "\x1B[0m";
127
- var BOLD = "\x1B[1m";
128
- var FG_RED = "\x1B[31m";
129
- var FG_GREEN = "\x1B[32m";
130
- var FG_CYAN = "\x1B[36m";
131
- var FG_GRAY = "\x1B[90m";
132
- function color(text, code) {
133
- return `${code}${text}${RESET}`;
134
- }
135
- function visibleLength(text) {
136
- return text.replace(/\x1b\[[0-9;]*m/g, "").length;
137
- }
138
- function boxLine(content, width) {
139
- const pad = Math.max(0, width - 2 - visibleLength(content));
140
- return `|${content}${" ".repeat(pad)}|`;
141
- }
142
- function padCenter(text, width) {
143
- if (text.length >= width) return text.slice(0, width);
144
- const totalPad = width - text.length;
145
- const left = Math.floor(totalPad / 2);
146
- const right = totalPad - left;
147
- return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
148
- }
149
- function formatDuration(ms) {
150
- if (ms < 1e3) return `${ms}ms`;
151
- return `${(ms / 1e3).toFixed(2)}s`;
152
- }
153
- var PrettyConsoleResultsDisplay = class {
154
- total = 0;
155
- startedAt = 0;
156
- passed = [];
157
- failed = [];
158
- cases = [];
159
- onStart(total) {
160
- this.total = total;
161
- this.startedAt = Date.now();
162
- }
163
- onCasePass(name) {
164
- this.passed.push(name);
165
- this.cases.push({ name, status: "pass" });
166
- const badge = color("[OK]", FG_GREEN);
167
- const nameColored = color(name, FG_CYAN);
168
- console.log(`${badge} ${nameColored}`);
169
- }
170
- onCaseFail(name, error) {
171
- this.failed.push({ name, error });
172
- this.cases.push({ name, status: "fail", error });
173
- const badge = color("[!!]", FG_RED);
174
- const nameColored = color(name, FG_CYAN);
175
- console.log(`${badge} ${nameColored}`);
176
- }
177
- onComplete(summary) {
178
- const width = 64;
179
- const top = `+${"=".repeat(width - 2)}+`;
180
- const mid = `+${"-".repeat(width - 2)}+`;
181
- const title = boxLine(color(padCenter("vib-test results", width - 2), BOLD + FG_CYAN), width);
182
- const totals = [
183
- ` Total: ${color(String(summary.total), FG_CYAN)} `,
184
- ` Passed: ${color(String(summary.passed), FG_GREEN)} `,
185
- ` Failed: ${color(String(summary.failed), FG_RED)} `,
186
- ` Duration: ${color(formatDuration(summary.durationMs), FG_GRAY)}`
187
- ].join(" |");
188
- console.log(top);
189
- console.log(title);
190
- console.log(mid);
191
- console.log(boxLine(totals, width));
192
- if (this.failed.length > 0) {
193
- console.log(mid);
194
- console.log(color(boxLine(` Failures (${this.failed.length}):`, width), FG_RED));
195
- const maxList = 10;
196
- for (let i = 0; i < Math.min(this.failed.length, maxList); i++) {
197
- const f = this.failed[i];
198
- const lineRaw = ` - ${f.name}`;
199
- const maxContent = width - 3;
200
- const trimmed = lineRaw.length > maxContent ? `${lineRaw.slice(0, maxContent - 3)}...` : lineRaw;
201
- console.log(color(boxLine(` ${trimmed}`, width), FG_RED));
202
- if (f.error?.includes("::")) {
203
- const idx = f.error.indexOf("::");
204
- try {
205
- const json = f.error.slice(idx + 2).trim();
206
- const parsed = JSON.parse(json);
207
- const expectedStr = JSON.stringify(parsed.expected, null, 2);
208
- const actualStr = JSON.stringify(parsed.actual, null, 2);
209
- const unified = diffStringsUnified(expectedStr, actualStr, {
210
- aAnnotation: "expected",
211
- bAnnotation: "actual",
212
- expand: false,
213
- contextLines: 3
214
- });
215
- const diffLines = (unified ?? "").split(/\r?\n/);
216
- for (const d of diffLines) console.log(`|${d}`);
217
- } catch {
218
- }
219
- }
220
- }
221
- if (this.failed.length > maxList) {
222
- console.log(color(boxLine(` ...and ${this.failed.length - maxList} more`, width), FG_RED));
223
- }
224
- }
225
- console.log(top);
226
- }
227
- };
228
-
229
- // src/match.ts
230
- var MAX_MISMATCHES = 25;
231
- function matchValue(expected, actual) {
232
- const ctx = { partial: false, mismatches: [] };
233
- matchNode(expected, actual, "", ctx);
234
- return ctx.mismatches;
235
- }
236
- function matches(expected, actual) {
237
- return matchValue(expected, actual).length === 0;
238
- }
239
- function report(ctx, mismatch) {
240
- if (ctx.mismatches.length < MAX_MISMATCHES) ctx.mismatches.push(mismatch);
241
- }
242
- function displayPath(path3) {
243
- return path3 === "" ? "(root)" : path3;
244
- }
245
- function describeType(value) {
246
- if (value === null) return "null";
247
- if (Array.isArray(value)) return "array";
248
- return typeof value;
249
- }
250
- function isPlainObject(value) {
251
- return typeof value === "object" && value !== null && !Array.isArray(value);
252
- }
253
- function matchNode(expected, actual, path3, ctx) {
254
- if (isPlainObject(expected)) {
255
- if ("$regex" in expected) {
256
- matchRegexDirective(expected, actual, path3, ctx);
257
- return;
258
- }
259
- if ("$wildcard" in expected) {
260
- matchWildcardDirective(expected, actual, path3, ctx);
261
- return;
262
- }
263
- if (expected.$unordered === true && Array.isArray(expected.value)) {
264
- matchUnordered(expected.value, actual, path3, ctx);
265
- return;
266
- }
267
- matchObject(expected, actual, path3, ctx);
268
- return;
269
- }
270
- if (Array.isArray(expected)) {
271
- matchArray(expected, actual, path3, ctx);
272
- return;
273
- }
274
- if (!Object.is(expected, actual)) {
275
- report(ctx, {
276
- path: displayPath(path3),
277
- message: `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
278
- expected,
279
- actual
280
- });
281
- }
282
- }
283
- function matchRegexDirective(directive, actual, path3, ctx) {
284
- const pattern = directive.$regex;
285
- const flags = directive.flags;
286
- if (typeof pattern !== "string" || flags !== void 0 && typeof flags !== "string") {
287
- report(ctx, {
288
- path: displayPath(path3),
289
- message: `invalid $regex directive: ${JSON.stringify(directive)}`,
290
- expected: directive,
291
- actual
292
- });
293
- return;
294
- }
295
- if (typeof actual !== "string") {
296
- report(ctx, {
297
- path: displayPath(path3),
298
- message: `expected a string matching /${pattern}/${flags ?? ""}, got ${describeType(actual)}`,
299
- expected: directive,
300
- actual
301
- });
302
- return;
303
- }
304
- let regex;
305
- try {
306
- regex = new RegExp(pattern, flags);
307
- } catch (err) {
308
- report(ctx, {
309
- path: displayPath(path3),
310
- message: `invalid $regex pattern /${pattern}/${flags ?? ""}: ${err instanceof Error ? err.message : String(err)}`,
311
- expected: directive,
312
- actual
313
- });
314
- return;
315
- }
316
- if (!regex.test(actual)) {
317
- report(ctx, {
318
- path: displayPath(path3),
319
- message: `expected string matching /${pattern}/${flags ?? ""}, got ${JSON.stringify(actual)}`,
320
- expected: directive,
321
- actual
322
- });
323
- }
324
- }
325
- function wildcardToRegex(pattern) {
326
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
327
- const body = escaped.replace(/\*/g, "[\\s\\S]*").replace(/\?/g, "[\\s\\S]");
328
- return new RegExp(`^${body}$`);
329
- }
330
- function matchWildcardDirective(directive, actual, path3, ctx) {
331
- const pattern = directive.$wildcard;
332
- if (typeof pattern !== "string") {
333
- report(ctx, {
334
- path: displayPath(path3),
335
- message: `invalid $wildcard directive: ${JSON.stringify(directive)}`,
336
- expected: directive,
337
- actual
338
- });
339
- return;
340
- }
341
- if (typeof actual !== "string") {
342
- report(ctx, {
343
- path: displayPath(path3),
344
- message: `expected a string matching wildcard "${pattern}", got ${describeType(actual)}`,
345
- expected: directive,
346
- actual
347
- });
348
- return;
349
- }
350
- if (!wildcardToRegex(pattern).test(actual)) {
351
- report(ctx, {
352
- path: displayPath(path3),
353
- message: `expected string matching wildcard "${pattern}", got ${JSON.stringify(actual)}`,
354
- expected: directive,
355
- actual
356
- });
357
- }
358
- }
359
- function matchObject(expected, actual, path3, ctx) {
360
- if (!isPlainObject(actual)) {
361
- report(ctx, {
362
- path: displayPath(path3),
363
- message: `expected an object, got ${describeType(actual)}`,
364
- expected,
365
- actual
366
- });
367
- return;
368
- }
369
- const partial = ctx.partial || expected.$partial === true;
370
- const expectedKeys = Object.keys(expected).filter((key) => key !== "$partial");
371
- if (!partial) {
372
- const actualKeys = Object.keys(actual);
373
- const extra = actualKeys.filter((key) => !expectedKeys.includes(key));
374
- if (extra.length > 0) {
375
- report(ctx, {
376
- path: displayPath(path3),
377
- message: `unexpected key${extra.length > 1 ? "s" : ""} ${extra.map((k) => JSON.stringify(k)).join(", ")} (use "$partial": true to allow extra fields)`,
378
- expected,
379
- actual
380
- });
381
- }
382
- }
383
- for (const key of expectedKeys) {
384
- const childPath = path3 === "" ? key : `${path3}.${key}`;
385
- if (!(key in actual)) {
386
- report(ctx, {
387
- path: displayPath(childPath),
388
- message: `missing expected key (expected ${JSON.stringify(expected[key])})`,
389
- expected: expected[key],
390
- actual: void 0
391
- });
392
- continue;
393
- }
394
- const childCtx = { partial, mismatches: ctx.mismatches };
395
- matchNode(expected[key], actual[key], childPath, childCtx);
396
- }
397
- }
398
- function matchArray(expected, actual, path3, ctx) {
399
- if (!Array.isArray(actual)) {
400
- report(ctx, {
401
- path: displayPath(path3),
402
- message: `expected an array, got ${describeType(actual)}`,
403
- expected,
404
- actual
405
- });
406
- return;
407
- }
408
- const lengthOk = ctx.partial ? actual.length >= expected.length : actual.length === expected.length;
409
- if (!lengthOk) {
410
- report(ctx, {
411
- path: displayPath(path3),
412
- message: `expected array of length ${ctx.partial ? `>= ${expected.length}` : expected.length}, got ${actual.length}`,
413
- expected,
414
- actual
415
- });
416
- return;
417
- }
418
- for (let i = 0; i < expected.length; i++) {
419
- matchNode(expected[i], actual[i], `${path3}[${i}]`, ctx);
420
- }
421
- }
422
- function matchesQuietly(expected, actual, partial) {
423
- const scratch = { partial, mismatches: [] };
424
- matchNode(expected, actual, "", scratch);
425
- return scratch.mismatches.length === 0;
426
- }
427
- function matchUnordered(expected, actual, path3, ctx) {
428
- if (!Array.isArray(actual)) {
429
- report(ctx, {
430
- path: displayPath(path3),
431
- message: `expected an array (unordered), got ${describeType(actual)}`,
432
- expected,
433
- actual
434
- });
435
- return;
436
- }
437
- if (ctx.partial ? actual.length < expected.length : actual.length !== expected.length) {
438
- report(ctx, {
439
- path: displayPath(path3),
440
- message: `expected unordered array of length ${ctx.partial ? `>= ${expected.length}` : expected.length}, got ${actual.length}`,
441
- expected,
442
- actual
443
- });
444
- return;
445
- }
446
- const used = new Array(actual.length).fill(false);
447
- const assign = (index) => {
448
- if (index === expected.length) return true;
449
- for (let j = 0; j < actual.length; j++) {
450
- if (used[j]) continue;
451
- if (matchesQuietly(expected[index], actual[j], ctx.partial)) {
452
- used[j] = true;
453
- if (assign(index + 1)) return true;
454
- used[j] = false;
455
- }
456
- }
457
- return false;
458
- };
459
- if (!assign(0)) {
460
- report(ctx, {
461
- path: displayPath(path3),
462
- message: `no arrangement of the actual array satisfies the expected elements (unordered${ctx.partial ? ", partial" : ""})`,
463
- expected,
464
- actual
465
- });
466
- }
467
- }
468
-
469
- // src/runner.ts
470
- import fs from "fs";
471
- import path2 from "path";
472
- import fg from "fast-glob";
473
-
474
- // src/paths.ts
475
- import path from "path";
476
- function resolveRootDir(options) {
477
- return options.rootDir ? path.resolve(options.rootDir) : process.cwd();
478
- }
479
- function resolveServerConfig(options) {
480
- const rootDir = resolveRootDir(options);
481
- const cwd = options.server.cwd ?? ".";
482
- return {
483
- ...options.server,
484
- cwd: path.isAbsolute(cwd) ? cwd : path.resolve(rootDir, cwd)
485
- };
486
- }
487
-
488
- // src/runner.ts
489
- async function resolveCaseFiles(globs, cwd) {
490
- const patterns = globs.map((g) => {
491
- const isNegative = g.startsWith("!");
492
- const body = isNegative ? g.slice(1) : g;
493
- const resolved = (path2.isAbsolute(body) ? body : path2.join(cwd, body)).replace(/\\/g, "/");
494
- return isNegative ? `!${resolved}` : resolved;
495
- });
496
- return await fg(patterns, { dot: false, onlyFiles: true, unique: true });
497
- }
498
- function summarizeMismatches(mismatches, label) {
499
- const first = mismatches[0];
500
- const suffix = mismatches.length > 1 ? ` (+${mismatches.length - 1} more)` : "";
501
- return `${label} at ${first.path}: ${first.message}${suffix}`;
502
- }
503
- function matchExpectedError(expected, actual) {
504
- const observed = {
505
- code: actual.codeName ?? (actual.code !== void 0 ? String(actual.code) : void 0),
506
- message: actual.message
507
- };
508
- if (expected.code !== void 0) {
509
- const codeMatches = expected.code === actual.codeName || expected.code === String(actual.code ?? "");
510
- if (!codeMatches) {
511
- return {
512
- ok: false,
513
- error: `Error code mismatch: expected ${JSON.stringify(expected.code)}, got ${JSON.stringify(observed.code)}`,
514
- expected,
515
- actual: observed
516
- };
517
- }
518
- }
519
- if (expected.message !== void 0) {
520
- const mismatches = matchValue(expected.message, actual.message);
521
- if (mismatches.length > 0) {
522
- return {
523
- ok: false,
524
- error: summarizeMismatches(mismatches, "Error message mismatch"),
525
- expected,
526
- actual: observed
527
- };
528
- }
529
- }
530
- return { ok: true };
531
- }
532
- async function executeCase(session, testCase, timeoutMs) {
533
- if (!testCase.tool) {
534
- return { ok: false, error: 'Case is missing required "tool" field' };
535
- }
536
- const outcome = await session.callTool(testCase.tool, testCase.args ?? {}, timeoutMs);
537
- if (testCase.expectError !== void 0) {
538
- if (outcome.kind === "result") {
539
- return {
540
- ok: false,
541
- error: "Expected an error but tool call succeeded",
542
- expected: testCase.expectError,
543
- actual: outcome.result
544
- };
545
- }
546
- return matchExpectedError(testCase.expectError, outcome.error);
547
- }
548
- if (outcome.kind === "error") {
549
- const label = outcome.error.codeName ? ` [${outcome.error.codeName}]` : "";
550
- return { ok: false, error: `Tool call failed${label}: ${outcome.error.message}` };
551
- }
552
- if (testCase.expect !== void 0) {
553
- const mismatches = matchValue(testCase.expect, outcome.result);
554
- if (mismatches.length > 0) {
555
- return {
556
- ok: false,
557
- error: summarizeMismatches(mismatches, "Expectation failed"),
558
- expected: testCase.expect,
559
- actual: outcome.result
560
- };
561
- }
562
- }
563
- return { ok: true };
564
- }
565
- async function runWithTimeout(fn, timeoutMs) {
566
- let timeoutHandle;
567
- const timeoutPromise = new Promise((_, reject) => {
568
- timeoutHandle = setTimeout(() => reject(new Error("Case timeout exceeded")), timeoutMs);
569
- });
570
- try {
571
- return await Promise.race([fn(), timeoutPromise]);
572
- } finally {
573
- if (timeoutHandle) clearTimeout(timeoutHandle);
574
- }
575
- }
576
- var Runner = class {
577
- options;
578
- display;
579
- constructor(options, display = new ConsoleResultsDisplay()) {
580
- this.options = options;
581
- this.display = display;
582
- }
583
- async run() {
584
- const rootDir = resolveRootDir(this.options);
585
- const files = await resolveCaseFiles(this.options.globs, rootDir);
586
- if (files.length === 0) {
587
- const pattern = this.options.globs.join(", ");
588
- const message = `No test cases matched globs: ${pattern} (from ${rootDir})`;
589
- if (this.options.allowEmpty) {
590
- console.warn(message);
591
- return 0;
592
- }
593
- console.error(message);
594
- return 1;
595
- }
596
- this.display.onStart(files.length);
597
- const spawnTimeoutMs = Math.max(2e3, Math.min(1e4, this.options.timeoutMs));
598
- const server = resolveServerConfig(this.options);
599
- const session = await McpSession.start(server, spawnTimeoutMs);
600
- try {
601
- let failures = 0;
602
- const startedAt = Date.now();
603
- let processed = 0;
604
- for (const file of files) {
605
- let data;
606
- try {
607
- data = JSON.parse(fs.readFileSync(file, "utf8"));
608
- } catch (err) {
609
- failures++;
610
- const reason = err instanceof Error ? err.message : String(err);
611
- this.display.onCaseFail(path2.basename(file), `Failed to load case file: ${reason}`);
612
- processed++;
613
- if (this.options.failFast) break;
614
- continue;
615
- }
616
- const { ok, error, expected, actual } = await runWithTimeout(
617
- () => executeCase(session, data, this.options.timeoutMs),
618
- this.options.timeoutMs
619
- );
620
- if (!ok) {
621
- failures++;
622
- const caseName = data.name ?? path2.basename(file);
623
- const details = expected !== void 0 ? ` :: ${JSON.stringify({ expected, actual })}` : "";
624
- this.display.onCaseFail(caseName, `${error ?? "Error"}${details}`);
625
- if (this.options.failFast) break;
626
- } else {
627
- const caseName = data.name ?? path2.basename(file);
628
- this.display.onCasePass(caseName);
629
- }
630
- processed++;
631
- }
632
- this.display.onComplete({
633
- total: processed,
634
- passed: processed - failures,
635
- failed: failures,
636
- durationMs: Date.now() - startedAt
637
- });
638
- return failures === 0 ? 0 : 1;
639
- } finally {
640
- await session.close();
641
- }
642
- }
643
- };
644
-
645
- export {
646
- McpSession,
647
- defaultRunCasesOptions,
648
- resolveRootDir,
649
- resolveServerConfig,
650
- ConsoleResultsDisplay,
651
- PrettyConsoleResultsDisplay,
652
- matchValue,
653
- matches,
654
- Runner
655
- };
656
- //# sourceMappingURL=chunk-FTVETYUK.js.map