#!/usr/bin/env node /** * Self-Test Runner — orchestrates HAP install/uninstall + batch test execution. * * Mode (selected by flags, NOT subcommands): * default (run) — hdc uninstall + hdc install + spawn batch_runner + poll until done. * --status — report state from output files (summary.json / task_results.jsonl). * * STDOUT carries strict JSON; all logging goes to stderr / the log file. */ import { execFileSync, spawn, spawnSync } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HAP_INSTALL_WAIT = 3; const INSTALL_TIMEOUT = 120; const DEFAULT_CATEGORY = 'self_test'; const PER_CASE_TIMEOUT_SEC = 720; // ---- logging ---- let logStream: fs.WriteStream | null = null; function log(level: string, msg: string): void { const pad = (n: number) => String(n).padStart(2, '0'); const d = new Date(); const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; const line = `[${ts}] ${level} ${msg}`; if (logStream) logStream.write(line + '\n'); process.stderr.write(line + '\n'); } const logger = { info: (m: string) => log('INFO', m), warning: (m: string) => log('WARNING', m), error: (m: string) => log('ERROR', m), }; function setupLogging(outputDir: string): void { const pad = (n: number) => String(n).padStart(2, '0'); const d = new Date(); const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; const logPath = path.join(outputDir, `self_test_${ts}.log`); fs.mkdirSync(path.dirname(logPath), { recursive: true }); logStream = fs.createWriteStream(logPath, { encoding: 'utf-8' }); logger.info(`日志文件: ${logPath}`); } // ---- helpers ---- function timestampForFile(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function sleep(sec: number): void { if (sec <= 0) return; if (process.platform === 'win32') { spawnSync('powershell.exe', ['-NoProfile', '-Command', `Start-Sleep -Seconds ${sec}`], { stdio: 'ignore', timeout: sec * 1000 + 5000, windowsHide: true }); } else { spawnSync('sleep', [String(sec)], { stdio: 'ignore', timeout: sec * 1000 + 5000 }); } } function findOnPath(name: string): string | null { const finder = process.platform === 'win32' ? 'where' : 'which'; try { const out = execFileSync(finder, [name], { encoding: 'utf-8', windowsHide: true, timeout: 5000 }); const first = out.split(/\r?\n/).find((l) => l.trim()); return first ? first.trim() : null; } catch { return null; } } function isProcessAlive(pid: number | undefined): boolean { if (pid === undefined) return false; try { process.kill(pid, 0); return true; } catch { return false; } } function killProcessTree(pid: number | undefined): void { if (!pid) return; if (process.platform === 'win32') { // /T kills the whole descendant tree, /F forces termination try { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); } catch { /* already dead */ } return; } // POSIX: a detached child is a process-group leader, so -pid targets the whole group try { process.kill(-pid, 'SIGKILL'); return; } catch { /* group gone */ } try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ } } function getHdc(): string { const found = findOnPath('hdc'); if (!found) { logger.error('hdc 未找到'); process.exit(1); } return found; } // Dependency-free scan of a top-level YAML block (e.g. `agent:` / `device:`) // for a direct-child scalar key. Only matches the key at the block's // direct-child indent, so a deeper-nested same-named key (e.g. agent.unified.mode) // is never misread as the top-level value. Best-effort — batch_runner re-reads // the yaml itself, so this only needs to forward the common flat form. function readYamlDirectChild(yamlPath: string, block: string, key: string): string { try { let text = fs.readFileSync(yamlPath, 'utf-8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const lines = text.split(/\r?\n/); let i = 0; const blockRe = new RegExp(`^${block}:\\s*(#.*)?$`); for (; i < lines.length; i++) if (blockRe.test(lines[i])) break; if (i >= lines.length) return ''; // The first non-blank/non-comment line after `block:` defines the direct-child indent. let childIndent = -1; for (i++; i < lines.length; i++) { const ln = lines[i]; if (ln.trim() === '' || /^\s*#/.test(ln)) continue; childIndent = ln.match(/^(\s+)/)?.[1].length ?? 0; break; } if (childIndent <= 0) return ''; const keyRe = new RegExp(`^ {${childIndent}}${key}:\\s*["']?([A-Za-z0-9_-]+)["']?\\s*(#.*)?$`); for (; i < lines.length; i++) { const ln = lines[i]; if (ln.trim() === '' || /^\s*#/.test(ln)) continue; const indent = ln.match(/^(\s+)/)?.[1].length ?? 0; if (indent < childIndent) break; // dedented → block ended if (indent === childIndent) { const m = ln.match(keyRe); if (m) return m[1]; } } return ''; } catch { return ''; } } // Read `agent.mode` to forward to batch_runner via --mode. batch_runner 0.0.3+ // also reads the nested `agent.unified.mode` itself; this forwarding is // belt-and-suspenders so the flat `agent.mode` form is honored even if that read regresses. function readAgentModeFromYaml(yamlPath: string): string { return readYamlDirectChild(yamlPath, 'agent', 'mode'); } // Read `device.device_sn` so `hdc install`/`uninstall` can target a specific device // (-t ). Without it, bare `hdc install` fails with "need connect-key" when // more than one device is connected. function readDeviceSnFromYaml(yamlPath: string): string { return readYamlDirectChild(yamlPath, 'device', 'device_sn'); } // Refresh model.unified.api_key in autotest.yaml from the HOMETRANS_MODEL_API_KEY // env var if it's set and differs — so api_key rotation takes effect immediately // without regenerating the yaml. Only touches the unified block (not // execute/decision slots, which may carry different keys for layered mode). function refreshApiKeyFromEnv(yamlPath: string): void { const envKey = process.env.HOMETRANS_MODEL_API_KEY; if (!envKey || envKey.includes('placeholder')) return; try { let text = fs.readFileSync(yamlPath, 'utf-8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const lines = text.split(/\r?\n/); let inModel = false, inUnified = false, unifiedIndent = -1, replaced = false; for (let i = 0; i < lines.length; i++) { const ln = lines[i]; if (/^model:\s*(#.*)?$/.test(ln)) { inModel = true; inUnified = false; continue; } if (inModel && !/^\s/.test(ln) && ln.trim() && !/^#/.test(ln)) { inModel = false; inUnified = false; continue; } if (inModel && /^\s+unified:\s*(#.*)?$/.test(ln)) { inUnified = true; unifiedIndent = (ln.match(/^(\s+)/)?.[1]?.length ?? 2) + 1; continue; } if (inUnified) { const indent = (ln.match(/^(\s+)/)?.[1]?.length ?? 0); if (indent <= unifiedIndent - 1 && ln.trim() && !/^#/.test(ln)) { inUnified = false; continue; } const m = ln.match(/^(\s+)api_key:\s*["']?([^"'\s#]+)["']?\s*(#.*)?$/); if (m && m[2] !== envKey) { lines[i] = `${m[1]}api_key: "${envKey}"`; replaced = true; } } } if (replaced) { fs.writeFileSync(yamlPath, lines.join('\n'), 'utf-8'); logger.info('autotest.yaml model.unified.api_key 已从 HOMETRANS_MODEL_API_KEY 环境变量刷新'); } } catch { /* yaml read/parse failure is non-fatal — batch_runner will report config errors */ } } // ---- arg parsing ---- interface RunArgs { testcases: string; hap: string; bundleName: string; taskDir: string; outputDir?: string; category: string; config?: string; timeout?: number | 'auto'; } function parseRunArgs(argv: string[]): RunArgs { const args: Partial = { category: DEFAULT_CATEGORY, taskDir: 'task' }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const next = (): string => { if (i + 1 >= argv.length) { console.error(`Missing value for ${a}`); process.exit(1); } return argv[++i]; }; switch (a) { case '--testcases': args.testcases = next(); break; case '--hap': args.hap = next(); break; case '--bundle-name': args.bundleName = next(); break; case '--task-dir': args.taskDir = next(); break; case '--output-dir': args.outputDir = next(); break; case '--category': args.category = next(); break; case '--config': args.config = next(); break; case '--timeout': { const v = next(); args.timeout = v === 'auto' ? 'auto' : Number(v); break; } case '-h': case '--help': console.log(`Usage: self-test-runner [options] Options: --testcases 测试用例文件,JSON 数组或 JSONL --hap 签名包:单个 .hap/.hsp 文件、目录,或逗号分隔 --bundle-name 包名 --task-dir 任务目录 (default: "task") --output-dir 日志目录 --category 测试分类名称 (default: "self_test") --config autotest.yaml 路径(默认 ~/.hometrans/autotest.yaml) --timeout 阻塞直到终态或超时(秒),或 auto -h, --help display this help `); process.exit(0); default: console.error(`Unknown option: ${a}`); process.exit(1); } } for (const req of ['testcases', 'hap', 'bundleName'] as const) { if (!args[req]) { console.error(`Missing required option: --${req.replace(/([A-Z])/g, '-$1').toLowerCase()}`); process.exit(1); } } return args as RunArgs; } // ---- HAP install ---- function runCmd(cmd: string[], opts: { check?: boolean; timeout?: number } = {}): { returncode: number; stdout: string; stderr: string } { const { check = true, timeout } = opts; logger.info(`>>> ${cmd.join(' ')}`); const result = spawnSync(cmd[0], cmd.slice(1), { encoding: 'utf-8', timeout: timeout ? timeout * 1000 : undefined, maxBuffer: 1024 * 1024 * 1024, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); if (result.error) { logger.error(`命令异常: ${result.error.message}`); throw result.error; } const status = result.status ?? 0; if (status !== 0) { logger.warning(`退出码=${status}`); if (result.stdout) logger.warning(` stdout: ${result.stdout.trim()}`); if (result.stderr) logger.warning(` stderr: ${result.stderr.trim()}`); if (check) process.exit(status); } return { returncode: status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; } function installHaps(hapArg: string, hdcCmd: string[]): void { const entries = hapArg.split(',').map((s) => s.trim()).filter(Boolean); if (entries.length === 0) { logger.error('hap-path 为空'); process.exit(1); } const pkgs: string[] = []; for (const e of entries) { if (!fs.existsSync(e)) { logger.error(`hap 路径不存在: ${e}`); process.exit(1); } const stat = fs.statSync(e); const lower = e.toLowerCase(); if (stat.isDirectory()) { const found = fs.readdirSync(e).filter((f) => f.toLowerCase().endsWith('.hap') || f.toLowerCase().endsWith('.hsp')).sort().map((f) => path.join(e, f)); if (found.length === 0) { logger.error(`目录内无 .hap/.hsp 包: ${e}`); process.exit(1); } pkgs.push(...found); } else if (lower.endsWith('.hap') || lower.endsWith('.hsp')) { pkgs.push(e); } else { logger.error(`既不是目录也不是 .hap/.hsp 文件: ${e}`); process.exit(1); } } const seen = new Set(); const deduped = pkgs.map((p) => path.resolve(p)).filter((p) => (seen.has(p) ? false : (seen.add(p), true))); if (!deduped.some((p) => p.toLowerCase().endsWith('.hap'))) { logger.error(`缺少 entry HAP: ${hapArg}`); process.exit(1); } logger.info(`安装 ${deduped.length} 个包: ${deduped.map((p) => path.basename(p)).join(', ')}`); const result = runCmd([...hdcCmd, 'install', '-r', ...deduped], { timeout: INSTALL_TIMEOUT, check: false }); const combined = `${result.stdout.trim()}\n${result.stderr.trim()}`; const combinedLower = combined.toLowerCase(); const hasError = combined.includes('msg:error') || combinedLower.includes('failed to install') || (combined.includes('code:') && combinedLower.includes('error:')); const hasSuccess = combinedLower.includes('successfully'); if (result.returncode === 0 && !hasError && hasSuccess) { logger.info('安装成功'); sleep(HAP_INSTALL_WAIT); } else if (result.returncode === 0 && !hasError) { logger.warning(`安装结果不确定: ${combined}`); sleep(HAP_INSTALL_WAIT); } else { logger.error(`安装失败: rc=${result.returncode}, output=${combined}`); process.exit(1); } } // ---- testcases JSONL normalizer ---- function prepareTestcasesJsonl(inputPath: string, taskSubdir: string): string { if (!fs.existsSync(inputPath)) { logger.error(`测试用例文件不存在: ${inputPath}`); process.exit(1); } let text = fs.readFileSync(inputPath, 'utf-8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const stripped = text.trimStart(); if (!stripped) { logger.error(`测试用例文件为空: ${inputPath}`); process.exit(1); } if (stripped[0] === '[') { let arr: unknown; try { arr = JSON.parse(text); } catch (e) { logger.error(`JSON 解析失败: ${e}`); process.exit(1); } if (!Array.isArray(arr)) { logger.error('根不是数组'); process.exit(1); } const outPath = path.join(taskSubdir, 'testcases.jsonl'); const { createHash } = crypto; const lines = arr.map((entry: any, idx: number) => { if (!entry || typeof entry !== 'object') { logger.error(`用例 #${idx + 1} 不是 JSON 对象`); process.exit(1); } if (!entry.uuid) { const seed = typeof entry.case_name === 'string' && entry.case_name ? entry.case_name : `case_${idx + 1}`; entry.uuid = createHash('md5').update(seed, 'utf-8').digest('hex').slice(0, 8); } if (!('spec' in entry)) entry.spec = ''; return JSON.stringify(entry); }); fs.writeFileSync(outPath, lines.join('\n') + '\n', 'utf-8'); logger.info(`已将 JSON 数组转换为 JSONL: ${outPath}(${arr.length} 条用例)`); return outPath; } const lines = text.split(/\r?\n/).filter((l) => l.trim()); lines.forEach((line, i) => { try { JSON.parse(line); } catch (e) { logger.error(`JSONL 第 ${i + 1} 行解析失败: ${e}`); process.exit(1); } }); logger.info(`测试用例文件已为 JSONL: ${inputPath}(${lines.length} 条用例)`); return inputPath; } // ---- batch runner path ---- // Resolve the global npm root(s) where `npm install -g` places packages. // Used as a fallback when the script runs from a location with no // node_modules ancestor (e.g. the opencode skill dir), so the skill's own // self-test-runner.ts can still locate @autotest/agent installed globally // as a dependency of @buaa_smat/hometrans. let _globalRootsCache: string[] | null = null; function globalNodeModulesRoots(): string[] { if (_globalRootsCache) return _globalRootsCache; const roots = new Set(); // `npm root -g` is the most reliable source. try { const out = execFileSync('npm', ['root', '-g'], { encoding: 'utf-8', timeout: 15000, windowsHide: true }); const p = out.trim(); if (p) roots.add(p); } catch { /* npm not on PATH or timed out */ } // Heuristic fallbacks (cross-platform) in case `npm root -g` is unavailable. const home = os.homedir(); if (process.platform === 'win32') { if (process.env.APPDATA) roots.add(path.join(process.env.APPDATA, 'npm', 'node_modules')); } else { roots.add('/usr/local/lib/node_modules'); roots.add('/usr/lib/node_modules'); roots.add(path.join(home, '.npm-global', 'lib', 'node_modules')); roots.add(path.join(home, '.npm', 'lib', 'node_modules')); } _globalRootsCache = [...roots].filter((r) => r); return _globalRootsCache; } function batchRunnerPath(): string { const rel = path.join('@autotest', 'agent', 'dist', 'tools', 'batch_runner.js'); // 1. Walk up from the script's own location (works when the script lives // inside a project that has @autotest/agent in its node_modules tree, // e.g. the HomeTrans repo during development). const here = path.dirname(fileURLToPath(import.meta.url)); let dir = here; for (let i = 0; i < 20 && dir; i++) { const candidate = path.join(dir, 'node_modules', rel); if (fs.existsSync(candidate)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } // 2. Fallback: global npm root. @autotest/agent is either hoisted to the // global root itself, or nested under the globally-installed // @buaa_smat/hometrans package. for (const root of globalNodeModulesRoots()) { const hoisted = path.join(root, rel); if (fs.existsSync(hoisted)) return hoisted; const nested = path.join(root, '@buaa_smat', 'hometrans', 'node_modules', rel); if (fs.existsSync(nested)) return nested; } return ''; } // Locate the @buaa_smat/hometrans package.json (repo dev copy or installed // copy, local or global) so we can read the exact @autotest/agent version // hometrans depends on — auto-install then pins that version instead of // "latest" (which could pull a version without the required fixes). function findHometransPackageJson(): string | null { const tryRead = (p: string): string | null => { if (!fs.existsSync(p)) return null; try { const pkg = JSON.parse(fs.readFileSync(p, 'utf-8')); if (pkg && pkg.name === '@buaa_smat/hometrans') return p; } catch { /* not a valid package.json */ } return null; }; // 1. repo dev context: walk up from the script dir; the repo root itself is // the hometrans package. const here = path.dirname(fileURLToPath(import.meta.url)); let dir = here; for (let i = 0; i < 20 && dir; i++) { const self = tryRead(path.join(dir, 'package.json')); if (self) return self; const nested = tryRead(path.join(dir, 'node_modules', '@buaa_smat', 'hometrans', 'package.json')); if (nested) return nested; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } // 2. global npm root: /@buaa_smat/hometrans/package.json for (const root of globalNodeModulesRoots()) { const g = tryRead(path.join(root, '@buaa_smat', 'hometrans', 'package.json')); if (g) return g; } return null; } // Ensure @autotest/agent's batch_runner.js is findable. If batchRunnerPath() // cannot locate it, auto-install @autotest/agent (pinned to the version // hometrans depends on) and retry. This self-heals the common "forgot to // install / hometrans install didn't pull the dep" case so the skill doesn't // hard-fail on a missing agent. function ensureAutotestAgent(): string { const found = batchRunnerPath(); if (found) return found; // Resolve the required version from hometrans's package.json. let versionSpec = ''; const hometransPkg = findHometransPackageJson(); if (hometransPkg) { try { const deps = JSON.parse(fs.readFileSync(hometransPkg, 'utf-8')).dependencies || {}; versionSpec = (deps['@autotest/agent'] || '').trim(); } catch { /* ignore */ } } const installSpec = versionSpec ? `@autotest/agent@${versionSpec}` : '@autotest/agent'; logger.info(`@autotest/agent 未找到,尝试自动安装: npm install -g ${installSpec}`); let installOk = false; try { const result = spawnSync('npm', ['install', '-g', installSpec], { encoding: 'utf-8', timeout: 180000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); installOk = result.status === 0; if (!installOk) { const tail = ((result.stderr || '') + (result.stdout || '')).trim().slice(-400); logger.warning(`自动安装退出码=${result.status}: ${tail}`); } } catch (e) { logger.warning(`自动安装异常: ${e instanceof Error ? e.message : String(e)}`); } // npm install may have changed the global root layout; drop the cache and retry. _globalRootsCache = null; const retry = batchRunnerPath(); if (retry) { logger.info(`@autotest/agent 自动安装成功 → ${retry}`); return retry; } if (!installOk) { logger.error( `自动安装失败。@autotest/agent${versionSpec ? `@${versionSpec}` : ''} 可能未发布到 npm。` + `请手动安装对应 tarball:\`npm install -g \`,` + `或重装 hometrans:\`npm install -g @buaa_smat/hometrans\`。`, ); } else { logger.error('自动安装报告成功但 batch_runner.js 仍未找到;请检查 npm 全局目录权限或路径。'); } return ''; } // ---- status probe ---- function findTaskSubdir(taskDir: string): string | null { if (!fs.existsSync(taskDir)) return null; try { const candidates = fs.readdirSync(taskDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith('task_')).map((e) => e.name).sort().reverse(); return candidates.length ? path.join(taskDir, candidates[0]) : null; } catch { return null; } } function findLatestLog(outputDir: string): string | null { if (!fs.existsSync(outputDir)) return null; try { const entries = fs.readdirSync(outputDir, { withFileTypes: true }); const selfTest = entries.filter((e) => e.isFile() && e.name.startsWith('self_test_') && e.name.endsWith('.log')).map((e) => e.name).sort().reverse(); const batchLog = entries.filter((e) => e.isFile() && e.name === 'batch_stdout.log').map((e) => e.name); const ordered = [...selfTest, ...batchLog]; return ordered.length ? path.join(outputDir, ordered[0]) : null; } catch { return null; } } function probeStatus(taskDir: string, outputDir: string): { status: string; taskSubdir: string | null; casesDone: number; lastCase: string; logTail: string; passCount?: number; failCount?: number; unknownCount?: number; passRate?: string | number } { const taskSubdir = findTaskSubdir(taskDir); if (!taskSubdir) return { status: 'NOT_STARTED', taskSubdir: null, casesDone: 0, lastCase: '', logTail: '' }; let casesDone = 0; let lastCase = ''; const jsonlFile = path.join(taskSubdir, 'task_results.jsonl'); if (fs.existsSync(jsonlFile)) { const text = fs.readFileSync(jsonlFile, 'utf-8').trim(); const lines = text ? text.split(/\r?\n/) : []; casesDone = lines.length; if (lines.length) { try { const last = JSON.parse(lines[lines.length - 1]); if (typeof last.case_name === 'string') lastCase = last.case_name; } catch { } } } let logTail = ''; const logFile = findLatestLog(outputDir); if (logFile && fs.existsSync(logFile)) { const text = fs.readFileSync(logFile, 'utf-8').trim(); logTail = (text ? text.split(/\r?\n/) : []).slice(-5).join('\n'); } const summaryFile = path.join(taskSubdir, 'summary.json'); if (fs.existsSync(summaryFile)) { let summary: Record = {}; try { summary = JSON.parse(fs.readFileSync(summaryFile, 'utf-8')); } catch { } const num = (v: unknown, f: number) => (typeof v === 'number' ? v : f); return { status: 'COMPLETED', taskSubdir, casesDone: num(summary.total_cases, casesDone), lastCase, passCount: num(summary.pass_count, 0), failCount: num(summary.fail_count, 0), unknownCount: num(summary.unknown_count, 0), passRate: (summary.pass_rate as string | number | undefined) ?? 0, logTail, }; } return { status: 'RUNNING', taskSubdir, casesDone, lastCase, logTail }; } // ---- run command ---- async function cmdRun(args: RunArgs): Promise { const taskDir = path.resolve(args.taskDir); fs.mkdirSync(taskDir, { recursive: true }); const outputDir = args.outputDir ? path.resolve(args.outputDir) : taskDir; fs.mkdirSync(outputDir, { recursive: true }); setupLogging(outputDir); logger.info(`参数: testcases=${args.testcases}, hap=${args.hap}, bundle=${args.bundleName}, config=${args.config ?? '(default)'}`); const taskSubdir = path.join(taskDir, `task_${timestampForFile(new Date())}`); fs.mkdirSync(taskSubdir, { recursive: true }); const stdoutLog = path.join(taskDir, 'batch_stdout.log'); // Resolve autotest.yaml const configPath = args.config || path.join(os.homedir(), '.hometrans', 'autotest.yaml'); if (!fs.existsSync(configPath)) { logger.error(`autotest.yaml not found: ${configPath}`); console.log(JSON.stringify({ status: 'FAILED', error: `autotest.yaml not found: ${configPath}` })); process.exit(1); } logger.info(`Config: ${configPath}`); // Read agent.mode from autotest.yaml and forward it to batch_runner via // --mode. Without this, batch_runner (<= 0.0.2) defaults to "single" even // when the yaml says "layered", so Planner+Executor never activates. const agentMode = readAgentModeFromYaml(configPath); if (agentMode) logger.info(`autotest.yaml agent.mode = "${agentMode}" → --mode ${agentMode}`); else logger.info('autotest.yaml agent.mode not set → batch_runner defaults to "single"'); // HAP install/uninstall const hdc = getHdc(); // Read device_sn from autotest.yaml so hdc install/uninstall can target a // specific device (-t ). Without it, bare `hdc install` fails with // "need connect-key" when ≥2 devices are connected — and worse, install could // land on device A while batch_runner auto-detects device B, testing the // wrong (stale) app. So when device_sn is unset AND ≥2 devices are connected, // fail fast and tell the user to pin a device in autotest.yaml. With exactly // one device, bare hdc auto-detects it correctly. const deviceSn = readDeviceSnFromYaml(configPath); let hdcCmd: string[]; if (deviceSn) { hdcCmd = [hdc, '-t', deviceSn]; logger.info(`autotest.yaml device.device_sn = "${deviceSn}" → hdc -t ${deviceSn}`); } else { const targets = runCmd([hdc, 'list', 'targets'], { check: false, timeout: 15 }).stdout .split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !/^\[|empty/i.test(s)); if (targets.length > 1) { logger.error( `autotest.yaml 未设 device.device_sn,但连了 ${targets.length} 台设备(${targets.join(', ')})。` + `请在 ~/.hometrans/autotest.yaml 的 device.device_sn 指定目标设备,` + `避免 install 与 batch_runner 跑在不同设备上。`, ); console.log(JSON.stringify({ status: 'FAILED', error: `device.device_sn not set in autotest.yaml; ${targets.length} devices connected (${targets.join(', ')}); set device.device_sn to pin the target`, })); process.exit(1); } hdcCmd = [hdc]; logger.info(`autotest.yaml device.device_sn not set; ${targets.length} device(s) connected → hdc auto-detect`); } logger.info(`=== Step 2a: uninstall ${args.bundleName} ===`); runCmd([...hdcCmd, 'uninstall', args.bundleName], { timeout: INSTALL_TIMEOUT, check: false }); logger.info(`=== Step 2b: install ${args.hap} ===`); installHaps(args.hap, hdcCmd); // Prepare testcases const testcasesJsonl = prepareTestcasesJsonl(path.resolve(args.testcases), taskSubdir); // Resolve timeout let effectiveTimeout: number | undefined; if (args.timeout === 'auto') { const caseCount = fs.readFileSync(testcasesJsonl, 'utf-8').split(/\r?\n/).filter((l) => l.trim()).length; effectiveTimeout = Math.max(caseCount, 1) * PER_CASE_TIMEOUT_SEC; logger.info(`--timeout auto: ${caseCount} 条用例 × ${PER_CASE_TIMEOUT_SEC}s = ${effectiveTimeout}s`); } else if (args.timeout && typeof args.timeout === 'number') { effectiveTimeout = args.timeout; } // Refresh model.unified.api_key from HOMETRANS_MODEL_API_KEY env if it has // been rotated since the yaml was last generated (Step 0a skips regeneration // when the yaml exists, so without this the env var rotation would be silently // ignored). refreshApiKeyFromEnv(configPath); // Build batch command — ensure @autotest/agent is installed (auto-install if missing) const runnerJs = ensureAutotestAgent(); if (!runnerJs) { logger.error('batch_runner.js not found and auto-install failed (see warnings above)'); console.log(JSON.stringify({ status: 'FAILED', error: 'batch_runner.js not found; @autotest/agent auto-install failed' })); process.exit(1); } const cmd = ['node', runnerJs, '--task-dir', taskSubdir, '--testcases', testcasesJsonl, '--category', args.category, '--config', configPath]; if (agentMode) cmd.push('--mode', agentMode); logger.info('=== Step 3: 启动 AutoTest batch_runner ==='); // Spawn detached and poll const logFd = fs.openSync(stdoutLog, 'w'); const proc = spawn(cmd[0], cmd.slice(1), { cwd: taskSubdir, stdio: ['ignore', logFd, logFd], env: process.env, detached: true, windowsHide: true, }); proc.unref(); logger.info(`后台进程 PID=${proc.pid}`); fs.closeSync(logFd); // No timeout: return immediately if (!effectiveTimeout) { console.log(JSON.stringify({ status: 'RUNNING', pid: proc.pid, task_dir: taskDir, task_subdir: taskSubdir })); return; } // Poll until terminal const start = Date.now(); while (true) { const r = probeStatus(taskDir, outputDir); if (r.status === 'COMPLETED') { console.log(JSON.stringify({ status: 'COMPLETED', task_subdir: r.taskSubdir, cases_done: r.casesDone, pass_count: r.passCount ?? 0, fail_count: r.failCount ?? 0, unknown_count: r.unknownCount ?? 0, pass_rate: r.passRate ?? 0, log_tail: r.logTail, })); process.exit(0); } // CRASHED detection: batch process died without producing summary.json. // Without this, a spawn-time crash (ConfigManager error, missing dep, JS // exception) would be misreported as TIMEOUT after waiting the full budget. const procAlive = isProcessAlive(proc.pid); if (!procAlive) { logger.error(`batch_runner 进程 PID=${proc.pid} 已退出但无 summary.json → CRASHED`); console.log(JSON.stringify({ status: 'CRASHED', task_subdir: r.taskSubdir, cases_done: r.casesDone, last_case: r.lastCase, log_tail: r.logTail, })); process.exit(3); } const elapsed = (Date.now() - start) / 1000; if (elapsed >= effectiveTimeout) { killProcessTree(proc.pid); console.log(JSON.stringify({ status: 'TIMEOUT', task_subdir: r.taskSubdir, cases_done: r.casesDone, last_case: r.lastCase, log_tail: r.logTail, })); process.exit(5); } sleep(60); } } // ---- status command ---- function cmdStatus(taskDir: string, outputDir?: string): void { const r = probeStatus(path.resolve(taskDir), outputDir ? path.resolve(outputDir) : path.resolve(taskDir)); if (r.status === 'NOT_STARTED') { console.log(JSON.stringify({ status: 'NOT_STARTED' })); process.exit(4); } if (r.status === 'COMPLETED') { console.log(JSON.stringify({ status: 'COMPLETED', task_subdir: r.taskSubdir, cases_done: r.casesDone, pass_count: r.passCount ?? 0, fail_count: r.failCount ?? 0, unknown_count: r.unknownCount ?? 0, pass_rate: r.passRate ?? 0, log_tail: r.logTail, })); process.exit(0); } console.log(JSON.stringify({ status: r.status, cases_done: r.casesDone, last_case: r.lastCase, task_subdir: r.taskSubdir, log_tail: r.logTail })); process.exit(r.status === 'RUNNING' ? 2 : 3); } // ---- entry ---- const args = process.argv.slice(2); // Parse args: --status flag switches to status mode, otherwise run mode const isStatus = args.includes('--status'); const filtered = args.filter((a) => a !== '--status'); if (filtered.includes('-h') || filtered.includes('--help')) { if (isStatus) { console.log('Usage: self-test-runner --status --task-dir [--output-dir ]'); } else { console.log(`Usage: self-test-runner [options] Options: --testcases 测试用例文件,JSON 数组或 JSONL --hap 签名包:单个 .hap/.hsp 文件、目录,或逗号分隔 --bundle-name 包名 --task-dir 任务目录 (default: "task") --output-dir 日志目录 --category 测试分类名称 (default: "self_test") --config autotest.yaml 路径(默认 ~/.hometrans/autotest.yaml) --timeout 阻塞直到终态或超时(秒),或 auto --status 查询执行状态(而非运行) -h, --help display this help `); } process.exit(0); } if (isStatus) { // Status mode: only need --task-dir and --output-dir let taskDir = ''; let outputDir: string | undefined; for (let i = 0; i < filtered.length; i++) { if (filtered[i] === '--task-dir') taskDir = filtered[++i]; else if (filtered[i] === '--output-dir') outputDir = filtered[++i]; } if (!taskDir) { console.error('Missing required option: --task-dir'); process.exit(1); } cmdStatus(taskDir, outputDir); } else { // Run mode (default) cmdRun(parseRunArgs(filtered)); }