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 { 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 { 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 { 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 "'); } 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 { const environment = input.environment || 'local'; const baseUrl = input.baseUrl || (environment === 'remote' ? 'https://待填写' : 'http://127.0.0.1'); const flows: Record> = { '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]> = !input.flow || input.flow === 'all' ? Object.entries(flows) : [[input.flow, flows[input.flow] as Record]]; return { baseUrl, environment, maxRounds: 3, protocol: [ '业务测试先写用例,再按环境 -> 账号 -> 数据 -> 接口 -> 页面预检。', '失败必须记录复现步骤、证据和初步归因。', '同一用例最多三轮,第三轮仍失败则停止等待建议。', '每轮结束后执行 kt_cleanup_history 预览;如存在待删历史产物,确认范围后执行清理,只保留最近 3 轮并保留模板目录。', ], selectedFlows: selected.map(([name, plan]) => ({ name, ...plan, })), }; }