ktworkflow-mcp/src/tools/testing.ts

320 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type {
ApiTestPlanInput,
BusinessFlow,
BusinessTestPlanInput,
NapcatDeviceProfileCheckInput,
PageTestCaseInput,
PageTestCaseResult,
} from '../types.js';
import { resolveProject } from '../core/workspace.js';
/**
* Reads a project-relative source file for static NapCat device profile checks.
* @param projectPath - Absolute project path returned by `resolveProject()`.
* @param relativePath - File path inside the API project.
* @returns UTF-8 file content, or an empty string when the file is absent.
*/
function readProjectFile(projectPath: string, relativePath: string): string {
const filePath = path.join(projectPath, relativePath);
return existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
}
/**
* Builds a boolean static check with a human-readable recovery hint.
* @param name - Stable check identifier for reports and self-test output.
* @param passed - Whether the source text still contains the expected guardrail.
* @param evidence - Short description of the expected code-level evidence.
* @returns Check result consumed by `buildNapcatDeviceProfileCheck()`.
*/
function createNapcatCheck(
name: string,
passed: boolean,
evidence: string,
): Record<string, unknown> {
return {
evidence,
name,
passed,
};
}
/**
* Checks that the API project still contains the NapCat device-profile guardrails needed to avoid unknown QQ devices.
* @param input - Project alias or path to scan; defaults to the API backend.
* @returns Static guardrail report covering hostname, physical-style MAC, machine-info, runtime dir, and DB timezone.
*/
export function buildNapcatDeviceProfileCheck(
input: NapcatDeviceProfileCheckInput = {},
): Record<string, unknown> {
const project = resolveProject(input.project || 'api');
const deviceIdentity = readProjectFile(
project.path,
'src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts',
);
const dockerOptions = readProjectFile(
project.path,
'src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts',
);
const containerService = readProjectFile(
project.path,
'src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts',
);
const appModule = readProjectFile(project.path, 'src/app.module.ts');
const runtimeConfig = readProjectFile(
project.path,
'src/runtime/config/runtime-config.service.ts',
);
const checks = [
createNapcatCheck(
'hostname-strategy',
deviceIdentity.includes('qqnt-visible-hostname-v1') &&
deviceIdentity.includes('pc-') &&
deviceIdentity.includes('hash.slice(0, 8)'),
'device identity uses qqnt-visible-hostname-v1 and pc-<8hex>',
),
createNapcatCheck(
'physical-oui-mac-strategy',
deviceIdentity.includes('physical-oui-mac-v1') &&
deviceIdentity.includes('hasPhysicalOuiMacPrefix') &&
deviceIdentity.includes('isRejectedVirtualMacPrefix') &&
deviceIdentity.includes('NAPCAT_PHYSICAL_OUI_PREFIXES'),
'device identity uses physical-oui-mac-v1 and rejects Docker/QEMU virtual MAC prefixes',
),
createNapcatCheck(
'machine-info-options',
dockerOptions.includes('machineInfoPath') &&
dockerOptions.includes('macAddressHyphen'),
'docker device options carry machineInfoPath and hyphenated MAC',
),
createNapcatCheck(
'machine-info-script',
containerService.includes('MACHINE_INFO_PATH') &&
containerService.includes('NAPCAT_MAC_HYPHEN') &&
containerService.includes("tr 'A-Za-z' 'N-ZA-Mn-za-m'") &&
containerService.includes("printf '\\\\000\\\\000\\\\000\\\\021'"),
'remote create script writes QQNT machine-info using ROT13 MAC format',
),
createNapcatCheck(
'runtime-dir-mount',
containerService.includes('$DATA_DIR/runtime:/tmp/runtime-napcat'),
'remote create script persists XDG_RUNTIME_DIR under the account data dir',
),
createNapcatCheck(
'db-timezone',
appModule.includes('DB_TIMEZONE') &&
appModule.includes("'+08:00'") &&
runtimeConfig.includes('DB_TIMEZONE'),
'TypeORM and runtime config expose DB_TIMEZONE with +08:00 default',
),
];
return {
checks,
ok: checks.every((item) => item.passed === true),
project,
};
}
export function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult {
const project = resolveProject(input.project);
const title = input.title || `${project.label} 页面级测试`;
const steps =
(input.steps?.length ?? 0) > 0
? input.steps!
: ['打开入口 URL', '完成登录或注入测试登录态', '执行用户关键路径操作', '保存关键步骤截图'];
const assertions =
(input.assertions?.length ?? 0) > 0
? input.assertions!
: ['页面无明显渲染错误', '控制台无 error', '关键接口返回成功', '核心 DOM 或业务状态符合预期'];
return {
account: input.account || '按当前环境使用数据库管理员账号或测试专用账号',
assertions,
cleanup: [
'关闭浏览器实例',
'清理本次启动的 Node/Vite 进程',
'保存截图和 result.json',
'先调用 kt_cleanup_history dryRun=true 预览,再执行 dryRun=false 或 pnpm run cleanup-history -- --execute仅保留最近 3 轮历史产物并保留模板目录',
],
entryUrl: input.entryUrl || '待填写',
maxRounds: 3,
project,
protocol: [
'测试前先写用例。',
'失败后先记录复现证据,再排查和修复。',
'按同一用例复测。',
'第三轮仍失败时停止并等待用户建议。',
],
steps,
title,
};
}
export function createApiTestPlan(input: ApiTestPlanInput): Record<string, unknown> {
const project = resolveProject(input.project || 'api');
const method = (input.method || 'GET').toUpperCase();
const endpoint = input.endpoint || '/api/待填写';
const baseUrl = input.baseUrl || 'http://localhost:3000';
const headers = [];
if (input.auth === 'bearer') {
headers.push('-H "Authorization: Bearer <accessToken>"');
}
if (input.contentType !== false && ['POST', 'PUT', 'PATCH'].includes(method)) {
headers.push('-H "Content-Type: application/json"');
}
const body =
input.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
? ` -d '${JSON.stringify(input.body)}'`
: '';
const curl = `curl -i -X ${method} ${headers.join(' ')} "${baseUrl}${endpoint}"${body}`;
return {
assertions: [
'HTTP 状态码符合预期。',
'成功响应结构为 { code: 200, msg: string, data: any }。',
'成功响应不返回 err 字段。',
...(input.expectedFields || []).map((field) => `data 中包含 ${field}`),
],
curl,
endpoint,
method,
notes: [
'接口改动后不要只跑类型检查,必须真实调用对应接口一次。',
'如依赖登录态,先调用 login 获取 token再带 Authorization 复测。',
],
project,
};
}
export function buildBusinessTestPlan(input: BusinessTestPlanInput = {}): Record<string, unknown> {
const environment = input.environment || 'local';
const baseUrl = input.baseUrl || (environment === 'remote' ? 'https://待填写' : 'http://127.0.0.1');
const flows: Record<BusinessFlow, Record<string, unknown>> = {
'admin-login': {
assertions: [
'登录接口返回 code=200 且 data 内有 accessToken/userInfo/menu/permissions。',
'Admin 页面进入后后端路由菜单正常渲染。',
'WordPress/NAS 不可用时不阻塞 Admin 登录。',
],
preflight: ['API 可达', 'Admin dev server 可达', '数据库管理员账号存在'],
projects: ['api', 'admin'],
steps: [
'调用 /auth/login 或使用 admin-login 脚本固化登录态。',
'进入 Admin 首页,确认菜单与按钮权限。',
'刷新页面后确认 auth 持久化仍有效。',
],
},
'blog-crud': {
assertions: [
'文章、分类、标签列表返回统一 code/msg/data。',
'新增/编辑/删除后后端刷新 WordPress 缓存。',
'文章编辑能绑定分类和标签,查询不额外打开新 tab。',
],
preflight: ['API 可达', 'WordPress 管理端认证可用或降级逻辑明确', 'Admin 博客菜单有权限'],
projects: ['api', 'admin', 'blog'],
steps: ['登录 Admin', '新增分类/标签', '新增文章并绑定分类标签', '编辑后查询列表', '清理 KT_TEST_ 数据'],
},
'fflogs-command': {
assertions: [
'命令注册表和在线命令表都能看到 FFLogs 查询命令。',
'中文任务名可查询,回显使用本地化任务名和字段名。',
'最近 10 次 logs 返回颜色、输出评分、治疗评分、dps、adps、rdps、ndps、hps。',
'FF14 查价和 FFLogs 映射优先来自字典表,不在插件代码硬编码。',
],
preflight: ['API 可达', 'FFLogs client id/secret 由环境变量注入', '字典表存在 FFLogs/FF14Market 映射项', 'QQBot 测试账号在线'],
projects: ['api', 'admin'],
steps: ['确认字典映射', '执行 /logs 角色 服务器 中文高难任务', '查询命令日志', '确认失败场景返回可读中文错误'],
},
'qqbot-account-scan': {
assertions: [
'扫码创建接口先返回 pending sessionId再通过 SSE/status 展示容器创建、二维码生成、等待扫码和终态。',
'新增账号首次容器创建必须在第一轮 Docker run 注入设备身份和中文桌面 runtime profile而不是登录后补 env。',
'取消/过期/失败会清理未绑定 NapCat 容器。',
'若真实接口 smoke 会创建线上 QQBot 账号或 NapCat 容器,必须先确认清理方案;不能造脏数据时用 RED/GREEN、只读线上计数复核和未执行真实造号原因替代。',
'删除账号会二次确认并删除专属容器。',
],
preflight: [
'NAS SSH 可达',
'NapCat WebUI 可达',
'API QQBot env 配置完整',
'确认本轮是否允许创建真实 QQBot 账号和 NapCat 容器',
],
projects: ['api', 'admin'],
steps: [
'打开账号连接页',
'点击扫码新增账号并确认接口不等待 Docker/WebUI 长耗时',
'订阅 scan/events 或轮询 status 确认中文进度',
'确认账号回填和 runtime/profile 归属',
'测试删除联动容器或记录未造号的替代验证证据',
],
},
'qqbot-login-sse': {
assertions: [
'更新登录接口快速返回 pending session不被前端 HTTP 超时截断。',
'scan/events SSE 能看到重置登录态、生成二维码、等待扫码、成功或错误等步骤。',
'账号被其他终端踢下线后,更新登录不复用旧二维码或旧 isLogin 状态。',
'手动刷新二维码不超时,且不会无意义重启容器。',
],
preflight: ['API 可达', 'NapCat 容器可达', '账号处于离线或可重置状态', 'Admin QQBot 账号页面可访问'],
projects: ['api', 'admin'],
steps: ['触发更新登录', '订阅 scan/events', '观察二维码刷新', '扫码或等待过期', '核对账号状态和容器状态'],
},
'qqbot-auto-reply': {
assertions: [
'消息日志能看到 OneBot 收到的私聊/群聊/频道消息。',
'权限名单按 QQ号、群/频道、精确 QQ 过滤生效。',
'规则命中后发送日志记录成功或失败原因。',
],
preflight: ['账号在线', 'MQTT 或 local bus 正常', '规则启用', '名单配置符合测试目标'],
projects: ['api', 'admin'],
steps: ['创建 KT_TEST_ 自动回复规则', '发送私聊测试消息', '查询消息日志', '查询发送日志', '清理测试规则'],
},
'system-log-visualization': {
assertions: [
'API pino 日志能写入 Loki 或在 Loki 不可用时不阻塞业务。',
'/system/logs、/system/logs/summary、/system/logs/status 返回 Vben 兼容结构。',
'Admin 系统日志页面能查询、筛选、刷新,控制台无 error。',
'时间字段统一显示为 YYYY-MM-DD HH:mm:ss。',
],
preflight: ['API 可达', 'Loki /ready 返回 200 或降级状态明确', 'Admin 系统日志菜单可见', 'K8s Secret/Jenkins env 已注入 LOKI_*'],
projects: ['api', 'admin'],
steps: ['触发一条 API 日志', '查询 Loki query_range', '打开 Admin #/system/logs', '执行筛选刷新', '核对 summary/status'],
},
'web-playground-auth': {
assertions: [
'无 token 时不主动 refresh不产生登录循环。',
'Admin 已登录时能带回授权态并重定向回原页面。',
'Playground 保存组件时截图上传 MinIO 并把 image 写入 API。',
],
preflight: ['Admin/API/Web/Playground 地址可达', '登录态存储为空或可控', 'MinIO 可达'],
projects: ['api', 'admin', 'web', 'playground'],
steps: ['清理本地 token', '从 Web/Playground 进入受保护页面', '跳 Admin 登录', '登录后回跳', '保存组件并查询接口'],
},
};
const selected: Array<[string, Record<string, unknown>]> =
!input.flow || input.flow === 'all'
? Object.entries(flows)
: [[input.flow, flows[input.flow] as Record<string, unknown>]];
return {
baseUrl,
environment,
maxRounds: 3,
protocol: [
'业务测试先写用例,再按环境 -> 账号 -> 数据 -> 接口 -> 页面预检。',
'失败必须记录复现步骤、证据和初步归因。',
'同一用例最多三轮,第三轮仍失败则停止等待建议。',
'每轮结束后执行 kt_cleanup_history 预览;如存在待删历史产物,确认范围后执行清理,只保留最近 3 轮并保留模板目录。',
],
selectedFlows: selected.map(([name, plan]) => ({
name,
...plan,
})),
};
}