320 lines
14 KiB
TypeScript
320 lines
14 KiB
TypeScript
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,
|
||
})),
|
||
};
|
||
}
|