- VERSION file as authoritative version source - export.mjs reads VERSION with package.json fallback - bw-ota.ps1 DryRun mode for safe testing - auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
331 lines
11 KiB
JavaScript
331 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
/**
|
|
* Phase 1 B · 补测试 —— 3 份 vitest 测试文件落地
|
|
*
|
|
* 产出:
|
|
* - hooks/__tests__/mcp-usage-tracker.test.js
|
|
* - hooks/__tests__/mcp-prune.test.js
|
|
* - hooks/__tests__/session-start-mcp-probe.test.js
|
|
*
|
|
* sentinel: PHASE1_B_TESTS_2026_04_24
|
|
* 幂等: 已存在且一致则跳过;不同则备份
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const CLAUDE_ROOT = path.join(__dirname, '..', '..');
|
|
const TESTS_DIR = path.join(CLAUDE_ROOT, 'hooks', '__tests__');
|
|
const SENTINEL = 'PHASE1_B_TESTS_2026_04_24';
|
|
|
|
const TRACKER_TEST = `/**
|
|
* ${SENTINEL} — mcp-usage-tracker 单元测试
|
|
* 覆盖: parseDetail / aggregate / identifyPruneCandidates
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { createRequire } from 'node:module';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { parseDetail, aggregate, identifyPruneCandidates } = require('../../scripts/mcp-usage-tracker.js');
|
|
|
|
describe('parseDetail', () => {
|
|
it('处理 null / undefined / 空串', () => {
|
|
expect(parseDetail(null)).toEqual({ server: null, tool: null });
|
|
expect(parseDetail(undefined)).toEqual({ server: null, tool: null });
|
|
expect(parseDetail('')).toEqual({ server: null, tool: null });
|
|
});
|
|
|
|
it('处理无斜杠: server 裸名', () => {
|
|
expect(parseDetail('github')).toEqual({ server: 'github', tool: null });
|
|
});
|
|
|
|
it('处理标准 server/tool', () => {
|
|
expect(parseDetail('github/create_issue')).toEqual({
|
|
server: 'github', tool: 'create_issue'
|
|
});
|
|
});
|
|
|
|
it('处理多层 slash: 第一个 / 为分界', () => {
|
|
expect(parseDetail('server/tool/with/nested')).toEqual({
|
|
server: 'server', tool: 'tool/with/nested'
|
|
});
|
|
});
|
|
|
|
it('处理非字符串类型 (防 NPE)', () => {
|
|
expect(parseDetail(123)).toEqual({ server: null, tool: null });
|
|
expect(parseDetail({})).toEqual({ server: null, tool: null });
|
|
});
|
|
});
|
|
|
|
describe('aggregate', () => {
|
|
it('空事件 + 已知 server → 全 0 骨架', () => {
|
|
const stats = aggregate([], ['github', 'context7']);
|
|
expect(Object.keys(stats).sort()).toEqual(['context7', 'github']);
|
|
expect(stats.github.totalCalls).toBe(0);
|
|
expect(stats.github.firstUsed).toBe(null);
|
|
expect(stats.github.lastUsed).toBe(null);
|
|
});
|
|
|
|
it('事件统计 success/error 分桶', () => {
|
|
const events = [
|
|
{ ts: '2026-04-20T10:00:00Z', detail: 'github/issue_create', success: true },
|
|
{ ts: '2026-04-21T10:00:00Z', detail: 'github/issue_create', success: false },
|
|
{ ts: '2026-04-22T10:00:00Z', detail: 'github/pr_list', success: true },
|
|
];
|
|
const stats = aggregate(events, ['github']);
|
|
expect(stats.github.totalCalls).toBe(3);
|
|
expect(stats.github.successCount).toBe(2);
|
|
expect(stats.github.errorCount).toBe(1);
|
|
expect(stats.github.firstUsed).toBe('2026-04-20T10:00:00Z');
|
|
expect(stats.github.lastUsed).toBe('2026-04-22T10:00:00Z');
|
|
expect(stats.github.tools.issue_create.count).toBe(2);
|
|
expect(stats.github.tools.issue_create.errorCount).toBe(1);
|
|
expect(stats.github.tools.pr_list.count).toBe(1);
|
|
});
|
|
|
|
it('未知 server (不在 allServers) 也入表', () => {
|
|
const events = [
|
|
{ ts: '2026-04-20T10:00:00Z', detail: 'unknown-server/x', success: true },
|
|
];
|
|
const stats = aggregate(events, []);
|
|
expect(stats['unknown-server']).toBeDefined();
|
|
expect(stats['unknown-server'].totalCalls).toBe(1);
|
|
});
|
|
|
|
it('detail 无效事件被忽略', () => {
|
|
const events = [
|
|
{ ts: '2026-04-20T10:00:00Z', detail: null, success: true },
|
|
{ ts: '2026-04-20T10:00:00Z', detail: '', success: true },
|
|
];
|
|
const stats = aggregate(events, ['github']);
|
|
expect(stats.github.totalCalls).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('identifyPruneCandidates', () => {
|
|
it('critical 名单永不候选', () => {
|
|
const stats = {
|
|
github: { server: 'github', totalCalls: 0 },
|
|
other: { server: 'other', totalCalls: 0 },
|
|
};
|
|
const critical = new Set(['github']);
|
|
const candidates = identifyPruneCandidates(stats, critical);
|
|
expect(candidates.map(c => c.server)).toEqual(['other']);
|
|
});
|
|
|
|
it('有调用的 server 不候选', () => {
|
|
const stats = {
|
|
active: { server: 'active', totalCalls: 5 },
|
|
idle: { server: 'idle', totalCalls: 0 },
|
|
};
|
|
const candidates = identifyPruneCandidates(stats, new Set());
|
|
expect(candidates).toHaveLength(1);
|
|
expect(candidates[0].server).toBe('idle');
|
|
expect(candidates[0].reason).toBe('zero-calls-in-window');
|
|
});
|
|
|
|
it('空 stats 返回空数组', () => {
|
|
expect(identifyPruneCandidates({}, new Set())).toEqual([]);
|
|
});
|
|
});
|
|
`;
|
|
|
|
const PRUNE_TEST = `/**
|
|
* ${SENTINEL} — mcp-prune CLI 集成测试
|
|
* 覆盖: 默认报告 / --plan / --help / 退出码
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import os from 'node:os';
|
|
|
|
const HOME = process.env.USERPROFILE || process.env.HOME || os.homedir();
|
|
const CLAUDE_ROOT = join(HOME, '.claude');
|
|
const SCRIPT = join(CLAUDE_ROOT, 'scripts', 'mcp-prune.js');
|
|
|
|
function run(args) {
|
|
return execFileSync(process.execPath, [SCRIPT, ...args], {
|
|
encoding: 'utf8',
|
|
stdio: 'pipe',
|
|
});
|
|
}
|
|
|
|
describe('mcp-prune CLI', () => {
|
|
it('--help 打印用法且 exit 0', () => {
|
|
const out = run(['--help']);
|
|
expect(out).toContain('用法:');
|
|
expect(out).toContain('--plan');
|
|
expect(out).toContain('--confirm');
|
|
});
|
|
|
|
it('默认模式输出包含报告标题', () => {
|
|
const out = run(['--days', '7']);
|
|
expect(out).toContain('/mcp-prune');
|
|
expect(out).toContain('剪枝分析');
|
|
expect(out).toContain('7天窗口');
|
|
});
|
|
|
|
it('报告含总数 / critical / 活跃 / 候选 四个统计', () => {
|
|
const out = run(['--days', '30']);
|
|
expect(out).toMatch(/总 MCP 数:/);
|
|
expect(out).toMatch(/critical/);
|
|
expect(out).toMatch(/活跃 MCP/);
|
|
expect(out).toMatch(/剪枝候选/);
|
|
});
|
|
|
|
it('--plan 写入 plan 文件', () => {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const planFile = join(CLAUDE_ROOT, 'mcp-prune-plan-' + today + '.json');
|
|
// 清理旧 plan 避免干扰断言
|
|
if (existsSync(planFile)) unlinkSync(planFile);
|
|
|
|
const out = run(['--days', '30', '--plan']);
|
|
// 若候选为 0 则不会生成 plan 文件 (正常);有候选才检查
|
|
if (out.includes('Plan 写入:')) {
|
|
expect(existsSync(planFile)).toBe(true);
|
|
const plan = JSON.parse(readFileSync(planFile, 'utf8'));
|
|
expect(plan.schema_version).toBe(1);
|
|
expect(plan.tool).toBe('mcp-prune');
|
|
expect(Array.isArray(plan.candidates)).toBe(true);
|
|
expect(Array.isArray(plan.critical)).toBe(true);
|
|
expect(plan.note).toMatch(/不自动修改/);
|
|
}
|
|
});
|
|
});
|
|
`;
|
|
|
|
const PROBE_TEST = `/**
|
|
* ${SENTINEL} — session-start-mcp-probe 单元测试
|
|
* 覆盖: commandPlausible / probeMcp (http+sse+stdio)
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { createRequire } from 'node:module';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { commandPlausible, probeMcp } = require('../../hooks/session-start-mcp-probe.js');
|
|
|
|
describe('commandPlausible', () => {
|
|
it('空 / null / 非字符串 → false', () => {
|
|
expect(commandPlausible(null)).toBe(false);
|
|
expect(commandPlausible(undefined)).toBe(false);
|
|
expect(commandPlausible('')).toBe(false);
|
|
expect(commandPlausible(123)).toBe(false);
|
|
});
|
|
|
|
it('well-known 命令裸名 → true', () => {
|
|
expect(commandPlausible('npx')).toBe(true);
|
|
expect(commandPlausible('node')).toBe(true);
|
|
expect(commandPlausible('python')).toBe(true);
|
|
});
|
|
|
|
it('Windows .cmd 后缀 → true (剥离后匹配)', () => {
|
|
expect(commandPlausible('npx.cmd')).toBe(true);
|
|
expect(commandPlausible('node.exe')).toBe(true);
|
|
expect(commandPlausible('python.bat')).toBe(true);
|
|
});
|
|
|
|
it('绝对路径 + 不存在 → false', () => {
|
|
expect(commandPlausible('C:/definitely-not-exists/xyz.exe')).toBe(false);
|
|
});
|
|
|
|
it('未知命令 → PATH 回退扫描 (通常 false 除非 PATH 里真有)', () => {
|
|
expect(commandPlausible('__definitely_not_installed_cmd_xyz__')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('probeMcp', () => {
|
|
it('http 类型 + 合法 URL → reachable true', () => {
|
|
const r = probeMcp('x', { type: 'http', url: 'https://example.com/mcp' });
|
|
expect(r.kind).toBe('http');
|
|
expect(r.urlValid).toBe(true);
|
|
expect(r.reachable).toBe(true);
|
|
});
|
|
|
|
it('http 类型 + 非法 URL → reachable false', () => {
|
|
const r = probeMcp('x', { type: 'http', url: 'not-a-url' });
|
|
expect(r.urlValid).toBe(false);
|
|
expect(r.reachable).toBe(false);
|
|
});
|
|
|
|
it('sse 类型 + 无 url → reachable false', () => {
|
|
const r = probeMcp('x', { type: 'sse' });
|
|
expect(r.reachable).toBe(false);
|
|
});
|
|
|
|
it('stdio 类型 + 已知命令 → reachable true', () => {
|
|
const r = probeMcp('x', { command: 'npx' });
|
|
expect(r.kind).toBe('stdio');
|
|
expect(r.commandExists).toBe(true);
|
|
expect(r.reachable).toBe(true);
|
|
});
|
|
|
|
it('stdio 类型 + 空命令 → reachable false', () => {
|
|
const r = probeMcp('x', { command: '' });
|
|
expect(r.reachable).toBe(false);
|
|
});
|
|
|
|
it('cfg 为 null → 不抛异常', () => {
|
|
const r = probeMcp('x', null);
|
|
expect(r.reachable).toBe(false);
|
|
});
|
|
});
|
|
`;
|
|
|
|
const FILES = [
|
|
{ name: 'mcp-usage-tracker.test.js', content: TRACKER_TEST },
|
|
{ name: 'mcp-prune.test.js', content: PRUNE_TEST },
|
|
{ name: 'session-start-mcp-probe.test.js', content: PROBE_TEST },
|
|
];
|
|
|
|
function writeIfDifferent(target, content, label) {
|
|
if (fs.existsSync(target)) {
|
|
const current = fs.readFileSync(target, 'utf8');
|
|
if (current === content) {
|
|
console.log('[patch-phase1-B] ' + label + ' 已一致,跳过');
|
|
return 'skipped';
|
|
}
|
|
const bak = target + '.bak.phase1-b';
|
|
fs.copyFileSync(target, bak);
|
|
console.log('[patch-phase1-B] 已备份:', bak);
|
|
}
|
|
const tmp = target + '.tmp.js';
|
|
fs.writeFileSync(tmp, content, 'utf8');
|
|
try {
|
|
const { execFileSync } = require('child_process');
|
|
execFileSync(process.execPath, ['--check', tmp], { stdio: 'pipe' });
|
|
} catch (e) {
|
|
try { fs.unlinkSync(tmp); } catch {}
|
|
console.error('[patch-phase1-B] ' + label + ' 语法检查失败:',
|
|
(e.stderr || e.message || '').toString().slice(0, 500));
|
|
process.exit(3);
|
|
}
|
|
fs.renameSync(tmp, target);
|
|
console.log('[patch-phase1-B] 已写入 ' + label + ':', target);
|
|
return 'written';
|
|
}
|
|
|
|
function main() {
|
|
if (!fs.existsSync(TESTS_DIR)) {
|
|
console.error('[patch-phase1-B] 测试目录不存在:', TESTS_DIR);
|
|
process.exit(1);
|
|
}
|
|
|
|
const results = [];
|
|
for (const f of FILES) {
|
|
const r = writeIfDifferent(path.join(TESTS_DIR, f.name), f.content, f.name);
|
|
results.push({ file: f.name, result: r });
|
|
}
|
|
|
|
console.log('');
|
|
console.log('[patch-phase1-B] sentinel:', SENTINEL);
|
|
console.log('[patch-phase1-B] 结果:', JSON.stringify(results));
|
|
console.log('[patch-phase1-B] 完成。');
|
|
console.log('');
|
|
console.log('验证: pnpm test -- mcp-usage-tracker mcp-prune session-start-mcp-probe');
|
|
process.exit(0);
|
|
}
|
|
|
|
main();
|