android2harmony 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/self-tester.md +33 -354
- package/dist/index.js +172 -76
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/skills/a2h-resource-convert/SKILL.md +36 -7
- package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +20 -0
- package/skills/a2h-resource-convert/scripts/app_identity.js +741 -0
- package/skills/a2h-ui-transfer/SKILL.md +14 -3
- package/skills/a2h-ui-transfer/references/conversion-procedure.md +5 -30
- package/skills/a2h-ui-transfer/scripts/android_parse_fast.js +137 -20
- package/skills/hmos-fix-build-errors/SKILL.md +1 -1
- package/skills/hmos-incremental-ui-align/README.md +251 -251
- package/skills/hmos-incremental-ui-align/SKILL.md +364 -364
- package/skills/hmos-integration-test/README.md +341 -0
- package/skills/hmos-integration-test/SKILL.md +446 -0
- package/skills/hmos-integration-test/scripts/report-tool.mjs +646 -0
- package/skills/hmos-integration-test/scripts/resolve-metadata-tool.mjs +147 -0
- package/skills/hmos-integration-test/scripts/self-test-runner.mjs +1006 -0
- package/skills/hmos-integration-test/scripts/testcases-tool.mjs +189 -0
- package/skills/hmos-spec-generate/SKILL.md +26 -24
- package/skills/hmos-spec-generate/scripts/parse_requirements.ts +515 -0
- package/skills/hmos-spec-generate/template/REQ.txt +22 -0
- package/skills/hmos-spec-generate/template/REQ.xlsx +0 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Self-Test Runner — orchestrates HAP install/uninstall + batch test execution.
|
|
4
|
+
*
|
|
5
|
+
* Mode (selected by flags, NOT subcommands):
|
|
6
|
+
* default (run) — hdc uninstall + hdc install + spawn batch_runner + poll until done.
|
|
7
|
+
* --status — report state from output files (summary.json / task_results.jsonl).
|
|
8
|
+
*
|
|
9
|
+
* STDOUT carries strict JSON; all logging goes to stderr / the log file.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
const HAP_INSTALL_WAIT = 3;
|
|
18
|
+
const INSTALL_TIMEOUT = 120;
|
|
19
|
+
const DEFAULT_CATEGORY = 'self_test';
|
|
20
|
+
const PER_CASE_TIMEOUT_SEC = 720;
|
|
21
|
+
// ---- logging ----
|
|
22
|
+
let logStream = null;
|
|
23
|
+
function log(level, msg) {
|
|
24
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
25
|
+
const d = new Date();
|
|
26
|
+
const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
27
|
+
const line = `[${ts}] ${level} ${msg}`;
|
|
28
|
+
if (logStream)
|
|
29
|
+
logStream.write(line + '\n');
|
|
30
|
+
process.stderr.write(line + '\n');
|
|
31
|
+
}
|
|
32
|
+
const logger = {
|
|
33
|
+
info: (m) => log('INFO', m),
|
|
34
|
+
warning: (m) => log('WARNING', m),
|
|
35
|
+
error: (m) => log('ERROR', m),
|
|
36
|
+
};
|
|
37
|
+
// Emit a terminal {status:'FAILED',error} JSON to stdout (the SKILL.md contract:
|
|
38
|
+
// the caller parses the last stdout line as terminal status JSON), then exit 1.
|
|
39
|
+
// Use for every fatal error path so the caller always gets a parseable status.
|
|
40
|
+
function failJson(msg) {
|
|
41
|
+
logger.error(msg);
|
|
42
|
+
console.log(JSON.stringify({ status: 'FAILED', error: msg }));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
function setupLogging(outputDir) {
|
|
46
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
47
|
+
const d = new Date();
|
|
48
|
+
const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
49
|
+
const logPath = path.join(outputDir, `self_test_${ts}.log`);
|
|
50
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
51
|
+
logStream = fs.createWriteStream(logPath, { encoding: 'utf-8' });
|
|
52
|
+
logger.info(`日志文件: ${logPath}`);
|
|
53
|
+
}
|
|
54
|
+
// ---- helpers ----
|
|
55
|
+
function timestampForFile(d) {
|
|
56
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
57
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
58
|
+
}
|
|
59
|
+
function sleep(sec) {
|
|
60
|
+
if (sec <= 0)
|
|
61
|
+
return;
|
|
62
|
+
if (process.platform === 'win32') {
|
|
63
|
+
spawnSync('powershell.exe', ['-NoProfile', '-Command', `Start-Sleep -Seconds ${sec}`], { stdio: 'ignore', timeout: sec * 1000 + 5000, windowsHide: true });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
spawnSync('sleep', [String(sec)], { stdio: 'ignore', timeout: sec * 1000 + 5000 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function sleepAsync(sec) {
|
|
70
|
+
return new Promise((resolve) => setTimeout(resolve, sec * 1000));
|
|
71
|
+
}
|
|
72
|
+
function findOnPath(name) {
|
|
73
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
74
|
+
try {
|
|
75
|
+
const out = execFileSync(finder, [name], { encoding: 'utf-8', windowsHide: true, timeout: 5000 });
|
|
76
|
+
const first = out.split(/\r?\n/).find((l) => l.trim());
|
|
77
|
+
return first ? first.trim() : null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isProcessAlive(pid) {
|
|
84
|
+
if (pid === undefined)
|
|
85
|
+
return false;
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function killProcessTree(pid) {
|
|
95
|
+
if (!pid)
|
|
96
|
+
return;
|
|
97
|
+
if (process.platform === 'win32') {
|
|
98
|
+
// /T kills the whole descendant tree, /F forces termination
|
|
99
|
+
try {
|
|
100
|
+
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
101
|
+
}
|
|
102
|
+
catch { /* already dead */ }
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// POSIX: a detached child is a process-group leader, so -pid targets the whole group
|
|
106
|
+
try {
|
|
107
|
+
process.kill(-pid, 'SIGKILL');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
catch { /* group gone */ }
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 'SIGKILL');
|
|
113
|
+
}
|
|
114
|
+
catch { /* already dead */ }
|
|
115
|
+
}
|
|
116
|
+
function getHdc() {
|
|
117
|
+
const found = findOnPath('hdc');
|
|
118
|
+
if (!found)
|
|
119
|
+
failJson('hdc 未找到 (不在 PATH;装 DevEco 或设 DEVECO_SDK_HOME)');
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
// Dependency-free scan of a top-level YAML block (e.g. `agent:` / `device:`)
|
|
123
|
+
// for a direct-child scalar key. Only matches the key at the block's
|
|
124
|
+
// direct-child indent, so a deeper-nested same-named key (e.g. agent.unified.mode)
|
|
125
|
+
// is never misread as the top-level value. Best-effort — batch_runner re-reads
|
|
126
|
+
// the yaml itself, so this only needs to forward the common flat form.
|
|
127
|
+
function readYamlDirectChild(yamlPath, block, key) {
|
|
128
|
+
try {
|
|
129
|
+
let text = fs.readFileSync(yamlPath, 'utf-8');
|
|
130
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
131
|
+
text = text.slice(1);
|
|
132
|
+
const lines = text.split(/\r?\n/);
|
|
133
|
+
let i = 0;
|
|
134
|
+
const blockRe = new RegExp(`^${block}:\\s*(#.*)?$`);
|
|
135
|
+
for (; i < lines.length; i++)
|
|
136
|
+
if (blockRe.test(lines[i]))
|
|
137
|
+
break;
|
|
138
|
+
if (i >= lines.length)
|
|
139
|
+
return '';
|
|
140
|
+
// The first non-blank/non-comment line after `block:` defines the direct-child indent.
|
|
141
|
+
let childIndent = -1;
|
|
142
|
+
for (i++; i < lines.length; i++) {
|
|
143
|
+
const ln = lines[i];
|
|
144
|
+
if (ln.trim() === '' || /^\s*#/.test(ln))
|
|
145
|
+
continue;
|
|
146
|
+
childIndent = ln.match(/^(\s+)/)?.[1].length ?? 0;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (childIndent <= 0)
|
|
150
|
+
return '';
|
|
151
|
+
const keyRe = new RegExp(`^ {${childIndent}}${key}:\\s*["']?([A-Za-z0-9_-]+)["']?\\s*(#.*)?$`);
|
|
152
|
+
for (; i < lines.length; i++) {
|
|
153
|
+
const ln = lines[i];
|
|
154
|
+
if (ln.trim() === '' || /^\s*#/.test(ln))
|
|
155
|
+
continue;
|
|
156
|
+
const indent = ln.match(/^(\s+)/)?.[1].length ?? 0;
|
|
157
|
+
if (indent < childIndent)
|
|
158
|
+
break; // dedented → block ended
|
|
159
|
+
if (indent === childIndent) {
|
|
160
|
+
const m = ln.match(keyRe);
|
|
161
|
+
if (m)
|
|
162
|
+
return m[1];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Read `agent.mode` to forward to batch_runner via --mode. batch_runner 0.0.3+
|
|
172
|
+
// also reads the nested `agent.unified.mode` itself; this forwarding is
|
|
173
|
+
// belt-and-suspenders so the flat `agent.mode` form is honored even if that read regresses.
|
|
174
|
+
function readAgentModeFromYaml(yamlPath) {
|
|
175
|
+
return readYamlDirectChild(yamlPath, 'agent', 'mode');
|
|
176
|
+
}
|
|
177
|
+
// Read `device.device_sn` so `hdc install`/`uninstall` can target a specific device
|
|
178
|
+
// (-t <sn>). Without it, bare `hdc install` fails with "need connect-key" when
|
|
179
|
+
// more than one device is connected.
|
|
180
|
+
function readDeviceSnFromYaml(yamlPath) {
|
|
181
|
+
return readYamlDirectChild(yamlPath, 'device', 'device_sn');
|
|
182
|
+
}
|
|
183
|
+
// Refresh model.unified.api_key in autotest.yaml from the HOMETRANS_MODEL_API_KEY
|
|
184
|
+
// env var if it's set and differs — so api_key rotation takes effect immediately
|
|
185
|
+
// without regenerating the yaml. Only touches the unified block (not
|
|
186
|
+
// execute/decision slots, which may carry different keys for layered mode).
|
|
187
|
+
function refreshApiKeyFromEnv(yamlPath) {
|
|
188
|
+
const envKey = process.env.HOMETRANS_MODEL_API_KEY;
|
|
189
|
+
if (!envKey || envKey.includes('placeholder'))
|
|
190
|
+
return;
|
|
191
|
+
try {
|
|
192
|
+
let text = fs.readFileSync(yamlPath, 'utf-8');
|
|
193
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
194
|
+
text = text.slice(1);
|
|
195
|
+
const lines = text.split(/\r?\n/);
|
|
196
|
+
let inModel = false, inUnified = false, unifiedIndent = -1, replaced = false;
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
const ln = lines[i];
|
|
199
|
+
if (/^model:\s*(#.*)?$/.test(ln)) {
|
|
200
|
+
inModel = true;
|
|
201
|
+
inUnified = false;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (inModel && !/^\s/.test(ln) && ln.trim() && !/^#/.test(ln)) {
|
|
205
|
+
inModel = false;
|
|
206
|
+
inUnified = false;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (inModel && /^\s+unified:\s*(#.*)?$/.test(ln)) {
|
|
210
|
+
inUnified = true;
|
|
211
|
+
unifiedIndent = (ln.match(/^(\s+)/)?.[1]?.length ?? 2) + 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (inUnified) {
|
|
215
|
+
const indent = (ln.match(/^(\s+)/)?.[1]?.length ?? 0);
|
|
216
|
+
if (indent <= unifiedIndent - 1 && ln.trim() && !/^#/.test(ln)) {
|
|
217
|
+
inUnified = false;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const m = ln.match(/^(\s+)api_key:\s*["']?([^"'\s#]+)["']?\s*(#.*)?$/);
|
|
221
|
+
if (m && m[2] !== envKey) {
|
|
222
|
+
lines[i] = `${m[1]}api_key: "${envKey}"`;
|
|
223
|
+
replaced = true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (replaced) {
|
|
228
|
+
fs.writeFileSync(yamlPath, lines.join('\n'), 'utf-8');
|
|
229
|
+
logger.info('autotest.yaml model.unified.api_key 已从 HOMETRANS_MODEL_API_KEY 环境变量刷新');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch { /* yaml read/parse failure is non-fatal — batch_runner will report config errors */ }
|
|
233
|
+
}
|
|
234
|
+
function parseRunArgs(argv) {
|
|
235
|
+
const args = { category: DEFAULT_CATEGORY, taskDir: 'task' };
|
|
236
|
+
for (let i = 0; i < argv.length; i++) {
|
|
237
|
+
const a = argv[i];
|
|
238
|
+
const next = () => { if (i + 1 >= argv.length)
|
|
239
|
+
failJson(`Missing value for ${a}`); return argv[++i]; };
|
|
240
|
+
switch (a) {
|
|
241
|
+
case '--testcases':
|
|
242
|
+
args.testcases = next();
|
|
243
|
+
break;
|
|
244
|
+
case '--hap':
|
|
245
|
+
args.hap = next();
|
|
246
|
+
break;
|
|
247
|
+
case '--bundle-name':
|
|
248
|
+
args.bundleName = next();
|
|
249
|
+
break;
|
|
250
|
+
case '--task-dir':
|
|
251
|
+
args.taskDir = next();
|
|
252
|
+
break;
|
|
253
|
+
case '--output-dir':
|
|
254
|
+
args.outputDir = next();
|
|
255
|
+
break;
|
|
256
|
+
case '--category':
|
|
257
|
+
args.category = next();
|
|
258
|
+
break;
|
|
259
|
+
case '--config':
|
|
260
|
+
args.config = next();
|
|
261
|
+
break;
|
|
262
|
+
case '--api-key':
|
|
263
|
+
args.apiKey = next();
|
|
264
|
+
break;
|
|
265
|
+
case '--model-name':
|
|
266
|
+
args.modelName = next();
|
|
267
|
+
break;
|
|
268
|
+
case '--model-base-url':
|
|
269
|
+
args.modelBaseUrl = next();
|
|
270
|
+
break;
|
|
271
|
+
case '--provider':
|
|
272
|
+
args.provider = next();
|
|
273
|
+
break;
|
|
274
|
+
case '--mode':
|
|
275
|
+
args.agentMode = next();
|
|
276
|
+
break;
|
|
277
|
+
case '--timeout': {
|
|
278
|
+
const v = next();
|
|
279
|
+
args.timeout = v === 'auto' ? 'auto' : Number(v);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case '--model-stdin':
|
|
283
|
+
args.modelStdin = true;
|
|
284
|
+
break;
|
|
285
|
+
case '-h':
|
|
286
|
+
case '--help':
|
|
287
|
+
console.log(`Usage: self-test-runner [options]
|
|
288
|
+
|
|
289
|
+
Options:
|
|
290
|
+
--testcases <path> 测试用例文件,JSON 数组或 JSONL
|
|
291
|
+
--hap <path> 签名包:单个 .hap/.hsp 文件、目录,或逗号分隔
|
|
292
|
+
--bundle-name <name> 包名
|
|
293
|
+
--task-dir <dir> 任务目录 (default: "task")
|
|
294
|
+
--output-dir <dir> 日志目录
|
|
295
|
+
--category <name> 测试分类名称 (default: "self_test")
|
|
296
|
+
--config <path> autotest.yaml 路径(有 yaml 文件时用)
|
|
297
|
+
--api-key <key> 模型 API Key(无 yaml 时从 CLI/env 生成临时 yaml)
|
|
298
|
+
--model-name <name> 模型名称(如 qwen3.7-plus)
|
|
299
|
+
--model-base-url <url> 模型 API base URL
|
|
300
|
+
--provider <name> 模型 provider (default: "openai")
|
|
301
|
+
--mode <single|layered> agent 模式(default: single;layered 时从同一组参数生成 execute/decision 槽位)
|
|
302
|
+
--timeout <sec> 阻塞直到终态或超时(秒),或 auto
|
|
303
|
+
--model-stdin 从 stdin 读取模型配置(a2h 工具注入;优先级最高)
|
|
304
|
+
-h, --help display this help
|
|
305
|
+
|
|
306
|
+
Model config priority: --model-stdin (a2h 工具, 最高) > --config (yaml file) > --api-key/--model-name/--model-base-url (CLI) > HOMETRANS_MODEL_* (env from ht init).
|
|
307
|
+
When --config is absent, the runner auto-generates a temp autotest.yaml from CLI params or env vars. --model-stdin forces a temp yaml from stdin, ignoring --config / default yaml.
|
|
308
|
+
`);
|
|
309
|
+
process.exit(0);
|
|
310
|
+
default:
|
|
311
|
+
failJson(`Unknown option: ${a}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const req of ['testcases', 'hap', 'bundleName']) {
|
|
315
|
+
if (!args[req])
|
|
316
|
+
failJson(`Missing required option: --${req.replace(/([A-Z])/g, '-$1').toLowerCase()}`);
|
|
317
|
+
}
|
|
318
|
+
return args;
|
|
319
|
+
}
|
|
320
|
+
// ---- HAP install ----
|
|
321
|
+
function runCmd(cmd, opts = {}) {
|
|
322
|
+
const { check = true, timeout } = opts;
|
|
323
|
+
logger.info(`>>> ${cmd.join(' ')}`);
|
|
324
|
+
const result = spawnSync(cmd[0], cmd.slice(1), {
|
|
325
|
+
encoding: 'utf-8',
|
|
326
|
+
timeout: timeout ? timeout * 1000 : undefined,
|
|
327
|
+
maxBuffer: 1024 * 1024 * 1024,
|
|
328
|
+
windowsHide: true,
|
|
329
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
330
|
+
});
|
|
331
|
+
if (result.error) {
|
|
332
|
+
logger.error(`命令异常: ${result.error.message}`);
|
|
333
|
+
throw result.error;
|
|
334
|
+
}
|
|
335
|
+
const status = result.status ?? 0;
|
|
336
|
+
if (status !== 0) {
|
|
337
|
+
logger.warning(`退出码=${status}`);
|
|
338
|
+
if (result.stdout)
|
|
339
|
+
logger.warning(` stdout: ${result.stdout.trim()}`);
|
|
340
|
+
if (result.stderr)
|
|
341
|
+
logger.warning(` stderr: ${result.stderr.trim()}`);
|
|
342
|
+
if (check)
|
|
343
|
+
failJson(`命令退出码非零 (${status}): ${cmd.join(' ')}`);
|
|
344
|
+
}
|
|
345
|
+
return { returncode: status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
346
|
+
}
|
|
347
|
+
function installHaps(hapArg, hdcCmd) {
|
|
348
|
+
const entries = hapArg.split(',').map((s) => s.trim()).filter(Boolean);
|
|
349
|
+
if (entries.length === 0)
|
|
350
|
+
failJson('hap-path 为空');
|
|
351
|
+
const pkgs = [];
|
|
352
|
+
for (const e of entries) {
|
|
353
|
+
if (!fs.existsSync(e))
|
|
354
|
+
failJson(`hap 路径不存在: ${e}`);
|
|
355
|
+
const stat = fs.statSync(e);
|
|
356
|
+
const lower = e.toLowerCase();
|
|
357
|
+
if (stat.isDirectory()) {
|
|
358
|
+
const found = fs.readdirSync(e).filter((f) => f.toLowerCase().endsWith('.hap') || f.toLowerCase().endsWith('.hsp')).sort().map((f) => path.join(e, f));
|
|
359
|
+
if (found.length === 0)
|
|
360
|
+
failJson(`目录内无 .hap/.hsp 包: ${e}`);
|
|
361
|
+
pkgs.push(...found);
|
|
362
|
+
}
|
|
363
|
+
else if (lower.endsWith('.hap') || lower.endsWith('.hsp')) {
|
|
364
|
+
pkgs.push(e);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
failJson(`既不是目录也不是 .hap/.hsp 文件: ${e}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
const deduped = pkgs.map((p) => path.resolve(p)).filter((p) => (seen.has(p) ? false : (seen.add(p), true)));
|
|
372
|
+
if (!deduped.some((p) => p.toLowerCase().endsWith('.hap')))
|
|
373
|
+
failJson(`缺少 entry HAP: ${hapArg}`);
|
|
374
|
+
logger.info(`安装 ${deduped.length} 个包: ${deduped.map((p) => path.basename(p)).join(', ')}`);
|
|
375
|
+
const result = runCmd([...hdcCmd, 'install', '-r', ...deduped], { timeout: INSTALL_TIMEOUT, check: false });
|
|
376
|
+
const combined = `${result.stdout.trim()}\n${result.stderr.trim()}`;
|
|
377
|
+
const combinedLower = combined.toLowerCase();
|
|
378
|
+
const hasError = combined.includes('msg:error') || combinedLower.includes('failed to install') || (combined.includes('code:') && combinedLower.includes('error:'));
|
|
379
|
+
const hasSuccess = combinedLower.includes('successfully');
|
|
380
|
+
if (result.returncode === 0 && !hasError && hasSuccess) {
|
|
381
|
+
logger.info('安装成功');
|
|
382
|
+
sleep(HAP_INSTALL_WAIT);
|
|
383
|
+
}
|
|
384
|
+
else if (result.returncode === 0 && !hasError) {
|
|
385
|
+
logger.warning(`安装结果不确定: ${combined}`);
|
|
386
|
+
sleep(HAP_INSTALL_WAIT);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
failJson(`安装失败: rc=${result.returncode}, output=${combined}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// ---- testcases JSONL normalizer ----
|
|
393
|
+
function prepareTestcasesJsonl(inputPath, taskSubdir) {
|
|
394
|
+
if (!fs.existsSync(inputPath))
|
|
395
|
+
failJson(`测试用例文件不存在: ${inputPath}`);
|
|
396
|
+
let text = fs.readFileSync(inputPath, 'utf-8');
|
|
397
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
398
|
+
text = text.slice(1);
|
|
399
|
+
const stripped = text.trimStart();
|
|
400
|
+
if (!stripped)
|
|
401
|
+
failJson(`测试用例文件为空: ${inputPath}`);
|
|
402
|
+
if (stripped[0] === '[') {
|
|
403
|
+
let arr;
|
|
404
|
+
try {
|
|
405
|
+
arr = JSON.parse(text);
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
failJson(`JSON 解析失败: ${e instanceof Error ? e.message : e}`);
|
|
409
|
+
}
|
|
410
|
+
if (!Array.isArray(arr))
|
|
411
|
+
failJson('testcases 根不是数组');
|
|
412
|
+
const outPath = path.join(taskSubdir, 'testcases.jsonl');
|
|
413
|
+
const { createHash } = crypto;
|
|
414
|
+
const lines = arr.map((entry, idx) => {
|
|
415
|
+
if (!entry || typeof entry !== 'object')
|
|
416
|
+
failJson(`用例 #${idx + 1} 不是 JSON 对象`);
|
|
417
|
+
if (!entry.uuid) {
|
|
418
|
+
const seed = typeof entry.case_name === 'string' && entry.case_name ? entry.case_name : `case_${idx + 1}`;
|
|
419
|
+
entry.uuid = createHash('md5').update(seed, 'utf-8').digest('hex').slice(0, 8);
|
|
420
|
+
}
|
|
421
|
+
if (!('spec' in entry))
|
|
422
|
+
entry.spec = '';
|
|
423
|
+
return JSON.stringify(entry);
|
|
424
|
+
});
|
|
425
|
+
fs.writeFileSync(outPath, lines.join('\n') + '\n', 'utf-8');
|
|
426
|
+
logger.info(`已将 JSON 数组转换为 JSONL: ${outPath}(${arr.length} 条用例)`);
|
|
427
|
+
return outPath;
|
|
428
|
+
}
|
|
429
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim());
|
|
430
|
+
lines.forEach((line, i) => {
|
|
431
|
+
try {
|
|
432
|
+
JSON.parse(line);
|
|
433
|
+
}
|
|
434
|
+
catch (e) {
|
|
435
|
+
failJson(`JSONL 第 ${i + 1} 行解析失败: ${e instanceof Error ? e.message : e}`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
logger.info(`测试用例文件已为 JSONL: ${inputPath}(${lines.length} 条用例)`);
|
|
439
|
+
return inputPath;
|
|
440
|
+
}
|
|
441
|
+
// ---- batch runner path ----
|
|
442
|
+
// Resolve the global npm root(s) where `npm install -g` places packages.
|
|
443
|
+
// Used as a fallback when the script runs from a location with no
|
|
444
|
+
// node_modules ancestor (e.g. the opencode skill dir), so the skill's own
|
|
445
|
+
// self-test-runner.mjs can still locate @autotest/agent installed globally
|
|
446
|
+
// as a dependency of @buaa_smat/hometrans.
|
|
447
|
+
let _globalRootsCache = null;
|
|
448
|
+
function globalNodeModulesRoots() {
|
|
449
|
+
if (_globalRootsCache)
|
|
450
|
+
return _globalRootsCache;
|
|
451
|
+
const roots = new Set();
|
|
452
|
+
// `npm root -g` is the most reliable source.
|
|
453
|
+
try {
|
|
454
|
+
const out = execFileSync('npm', ['root', '-g'], { encoding: 'utf-8', timeout: 15000, windowsHide: true, shell: true });
|
|
455
|
+
const p = out.trim();
|
|
456
|
+
if (p)
|
|
457
|
+
roots.add(p);
|
|
458
|
+
}
|
|
459
|
+
catch { /* npm not on PATH or timed out */ }
|
|
460
|
+
// Heuristic fallbacks (cross-platform) in case `npm root -g` is unavailable.
|
|
461
|
+
const home = os.homedir();
|
|
462
|
+
if (process.platform === 'win32') {
|
|
463
|
+
if (process.env.APPDATA)
|
|
464
|
+
roots.add(path.join(process.env.APPDATA, 'npm', 'node_modules'));
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
roots.add('/usr/local/lib/node_modules');
|
|
468
|
+
roots.add('/usr/lib/node_modules');
|
|
469
|
+
roots.add(path.join(home, '.npm-global', 'lib', 'node_modules'));
|
|
470
|
+
roots.add(path.join(home, '.npm', 'lib', 'node_modules'));
|
|
471
|
+
}
|
|
472
|
+
_globalRootsCache = [...roots].filter((r) => r);
|
|
473
|
+
return _globalRootsCache;
|
|
474
|
+
}
|
|
475
|
+
function batchRunnerPath() {
|
|
476
|
+
const rel = path.join('@autotest', 'agent', 'dist', 'tools', 'batch_runner.js');
|
|
477
|
+
// 1. Walk up from the script's own location (works when the script lives
|
|
478
|
+
// inside a project that has @autotest/agent in its node_modules tree,
|
|
479
|
+
// e.g. the HomeTrans repo during development).
|
|
480
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
481
|
+
let dir = here;
|
|
482
|
+
for (let i = 0; i < 20 && dir; i++) {
|
|
483
|
+
const candidate = path.join(dir, 'node_modules', rel);
|
|
484
|
+
if (fs.existsSync(candidate))
|
|
485
|
+
return candidate;
|
|
486
|
+
const parent = path.dirname(dir);
|
|
487
|
+
if (parent === dir)
|
|
488
|
+
break;
|
|
489
|
+
dir = parent;
|
|
490
|
+
}
|
|
491
|
+
// 2. Fallback: global npm root. @autotest/agent is either hoisted to the
|
|
492
|
+
// global root itself, or nested under the globally-installed
|
|
493
|
+
// @buaa_smat/hometrans package.
|
|
494
|
+
for (const root of globalNodeModulesRoots()) {
|
|
495
|
+
const hoisted = path.join(root, rel);
|
|
496
|
+
if (fs.existsSync(hoisted))
|
|
497
|
+
return hoisted;
|
|
498
|
+
const nested = path.join(root, '@buaa_smat', 'hometrans', 'node_modules', rel);
|
|
499
|
+
if (fs.existsSync(nested))
|
|
500
|
+
return nested;
|
|
501
|
+
}
|
|
502
|
+
return '';
|
|
503
|
+
}
|
|
504
|
+
// Locate the @buaa_smat/hometrans package.json (repo dev copy or installed
|
|
505
|
+
// copy, local or global) so we can read the exact @autotest/agent version
|
|
506
|
+
// hometrans depends on — auto-install then pins that version instead of
|
|
507
|
+
// "latest" (which could pull a version without the required fixes).
|
|
508
|
+
function findHometransPackageJson() {
|
|
509
|
+
const tryRead = (p) => {
|
|
510
|
+
if (!fs.existsSync(p))
|
|
511
|
+
return null;
|
|
512
|
+
try {
|
|
513
|
+
const pkg = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
514
|
+
if (pkg && pkg.name === '@buaa_smat/hometrans')
|
|
515
|
+
return p;
|
|
516
|
+
}
|
|
517
|
+
catch { /* not a valid package.json */ }
|
|
518
|
+
return null;
|
|
519
|
+
};
|
|
520
|
+
// 1. repo dev context: walk up from the script dir; the repo root itself is
|
|
521
|
+
// the hometrans package.
|
|
522
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
523
|
+
let dir = here;
|
|
524
|
+
for (let i = 0; i < 20 && dir; i++) {
|
|
525
|
+
const self = tryRead(path.join(dir, 'package.json'));
|
|
526
|
+
if (self)
|
|
527
|
+
return self;
|
|
528
|
+
const nested = tryRead(path.join(dir, 'node_modules', '@buaa_smat', 'hometrans', 'package.json'));
|
|
529
|
+
if (nested)
|
|
530
|
+
return nested;
|
|
531
|
+
const parent = path.dirname(dir);
|
|
532
|
+
if (parent === dir)
|
|
533
|
+
break;
|
|
534
|
+
dir = parent;
|
|
535
|
+
}
|
|
536
|
+
// 2. global npm root: <root>/@buaa_smat/hometrans/package.json
|
|
537
|
+
for (const root of globalNodeModulesRoots()) {
|
|
538
|
+
const g = tryRead(path.join(root, '@buaa_smat', 'hometrans', 'package.json'));
|
|
539
|
+
if (g)
|
|
540
|
+
return g;
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
// Ensure @autotest/agent's batch_runner.js is available and up-to-date.
|
|
545
|
+
// Runs `npm install -g @autotest/agent` (latest) every time — npm install only
|
|
546
|
+
// touches the package files (dist/), NEVER the user's ~/.hometrans/autotest.yaml.
|
|
547
|
+
// The yaml is read at runtime via --config, not modified by the install.
|
|
548
|
+
function ensureAutotestAgent() {
|
|
549
|
+
logger.info('确保 @autotest/agent 最新(npm install -g @autotest/agent)...');
|
|
550
|
+
let installOk = false;
|
|
551
|
+
try {
|
|
552
|
+
const result = spawnSync('npm', ['install', '-g', '@autotest/agent'], {
|
|
553
|
+
encoding: 'utf-8', timeout: 180000, windowsHide: true, shell: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
554
|
+
});
|
|
555
|
+
installOk = result.status === 0;
|
|
556
|
+
if (!installOk) {
|
|
557
|
+
const tail = ((result.stderr || '') + (result.stdout || '')).trim().slice(-400);
|
|
558
|
+
logger.warning(`npm install 退出码=${result.status}: ${tail}`);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
logger.info('@autotest/agent 已安装/更新到最新');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
logger.warning(`npm install 异常: ${e instanceof Error ? e.message : String(e)}`);
|
|
566
|
+
}
|
|
567
|
+
// Drop the global-roots cache (install may have changed the layout) and locate batch_runner.js.
|
|
568
|
+
_globalRootsCache = null;
|
|
569
|
+
const found = batchRunnerPath();
|
|
570
|
+
if (found) {
|
|
571
|
+
logger.info(`batch_runner.js → ${found}`);
|
|
572
|
+
return found;
|
|
573
|
+
}
|
|
574
|
+
if (!installOk) {
|
|
575
|
+
logger.error('@autotest/agent 安装失败(可能网络问题)。' +
|
|
576
|
+
'请手动安装:`npm install -g @autotest/agent`。');
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
logger.error('npm install 报告成功但 batch_runner.js 仍未找到;请检查 npm 全局目录权限或路径。');
|
|
580
|
+
}
|
|
581
|
+
return '';
|
|
582
|
+
}
|
|
583
|
+
// ---- status probe ----
|
|
584
|
+
function findTaskSubdir(taskDir) {
|
|
585
|
+
if (!fs.existsSync(taskDir))
|
|
586
|
+
return null;
|
|
587
|
+
try {
|
|
588
|
+
const candidates = fs.readdirSync(taskDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith('task_')).map((e) => e.name).sort().reverse();
|
|
589
|
+
return candidates.length ? path.join(taskDir, candidates[0]) : null;
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function findLatestLog(outputDir, taskDir) {
|
|
596
|
+
if (!fs.existsSync(outputDir))
|
|
597
|
+
return null;
|
|
598
|
+
try {
|
|
599
|
+
const entries = fs.readdirSync(outputDir, { withFileTypes: true });
|
|
600
|
+
const selfTest = entries.filter((e) => e.isFile() && e.name.startsWith('self_test_') && e.name.endsWith('.log')).map((e) => e.name).sort().reverse();
|
|
601
|
+
if (selfTest.length)
|
|
602
|
+
return path.join(outputDir, selfTest[0]);
|
|
603
|
+
// batch_stdout.log lives in taskDir (where the runner writes it), not outputDir
|
|
604
|
+
const batchLogDir = taskDir && fs.existsSync(taskDir) ? taskDir : outputDir;
|
|
605
|
+
const batchLogPath = path.join(batchLogDir, 'batch_stdout.log');
|
|
606
|
+
if (fs.existsSync(batchLogPath))
|
|
607
|
+
return batchLogPath;
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Escape a string for a double-quoted YAML scalar (model name / base URL /
|
|
615
|
+
// api key). Without this, a value containing `"`, `\`, or a newline produces a
|
|
616
|
+
// malformed autotest.yaml → batch_runner ConfigManager parse failure (CRASHED)
|
|
617
|
+
// while the key has already landed on disk.
|
|
618
|
+
function yamlScalar(s) {
|
|
619
|
+
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Best-effort delete of the temp autotest.yaml (the only place the apiKey sits
|
|
623
|
+
// on disk) from taskSubdir. Only ever touches task_subdir/autotest.yaml -- never
|
|
624
|
+
// the user's persistent ~/.hometrans/autotest.yaml passed via --config. Called on
|
|
625
|
+
// every terminal state (COMPLETED/CRASHED/TIMEOUT/NOT_STARTED) so the key never
|
|
626
|
+
// lingers even when the run is killed externally.
|
|
627
|
+
function deleteTempYaml(taskSubdir) {
|
|
628
|
+
if (!taskSubdir) return;
|
|
629
|
+
try { fs.unlinkSync(path.join(taskSubdir, 'autotest.yaml')); } catch { /* not generated / already removed */ }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function probeStatus(taskDir, outputDir) {
|
|
633
|
+
const taskSubdir = findTaskSubdir(taskDir);
|
|
634
|
+
if (!taskSubdir)
|
|
635
|
+
return { status: 'NOT_STARTED', taskSubdir: null, casesDone: 0, lastCase: '', logTail: '' };
|
|
636
|
+
let casesDone = 0;
|
|
637
|
+
let lastCase = '';
|
|
638
|
+
const jsonlFile = path.join(taskSubdir, 'task_results.jsonl');
|
|
639
|
+
if (fs.existsSync(jsonlFile)) {
|
|
640
|
+
const text = fs.readFileSync(jsonlFile, 'utf-8').trim();
|
|
641
|
+
const lines = text ? text.split(/\r?\n/) : [];
|
|
642
|
+
casesDone = lines.length;
|
|
643
|
+
if (lines.length) {
|
|
644
|
+
try {
|
|
645
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
646
|
+
if (typeof last.case_name === 'string')
|
|
647
|
+
lastCase = last.case_name;
|
|
648
|
+
}
|
|
649
|
+
catch { }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
let logTail = '';
|
|
653
|
+
const logFile = findLatestLog(outputDir, taskDir);
|
|
654
|
+
if (logFile && fs.existsSync(logFile)) {
|
|
655
|
+
const text = fs.readFileSync(logFile, 'utf-8').trim();
|
|
656
|
+
logTail = (text ? text.split(/\r?\n/) : []).slice(-5).join('\n');
|
|
657
|
+
}
|
|
658
|
+
const summaryFile = path.join(taskSubdir, 'summary.json');
|
|
659
|
+
if (fs.existsSync(summaryFile)) {
|
|
660
|
+
let summary = {};
|
|
661
|
+
try {
|
|
662
|
+
summary = JSON.parse(fs.readFileSync(summaryFile, 'utf-8'));
|
|
663
|
+
}
|
|
664
|
+
catch { }
|
|
665
|
+
const num = (v, f) => (typeof v === 'number' ? v : f);
|
|
666
|
+
return {
|
|
667
|
+
status: 'COMPLETED', taskSubdir, casesDone: num(summary.total_cases, casesDone), lastCase,
|
|
668
|
+
passCount: num(summary.pass_count, 0), failCount: num(summary.fail_count, 0),
|
|
669
|
+
unknownCount: num(summary.unknown_count, 0), passRate: summary.pass_rate ?? 0, logTail,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
// Immediate CRASHED detection via the batch pid file: if batch.pid exists
|
|
673
|
+
// and the process is no longer alive (crashed, or externally killed -- e.g.
|
|
674
|
+
// the agent's timeout-kill), return CRASHED at once instead of waiting 3 min
|
|
675
|
+
// for stale-log detection. This also lets cmdStatus delete the temp
|
|
676
|
+
// autotest.yaml (apiKey) right after a timeout-kill -- otherwise the
|
|
677
|
+
// post-kill poll still sees RUNNING (fresh log mtime) and the key lingers.
|
|
678
|
+
const pidFile = path.join(taskSubdir, 'batch.pid');
|
|
679
|
+
if (fs.existsSync(pidFile)) {
|
|
680
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
681
|
+
if (pid && !isProcessAlive(pid)) {
|
|
682
|
+
return { status: 'CRASHED', taskSubdir, casesDone, lastCase, logTail };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// CRASHED detection via log mtime: if no summary.json and the latest log
|
|
686
|
+
// hasn't been written to in 3+ minutes, the batch process likely crashed.
|
|
687
|
+
// This catches crashes even when --status is called (no PID context).
|
|
688
|
+
if (logFile && fs.existsSync(logFile)) {
|
|
689
|
+
const mtime = fs.statSync(logFile).mtime.getTime();
|
|
690
|
+
if (Date.now() - mtime > 3 * 60 * 1000) {
|
|
691
|
+
return { status: 'CRASHED', taskSubdir, casesDone, lastCase, logTail };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { status: 'RUNNING', taskSubdir, casesDone, lastCase, logTail };
|
|
695
|
+
}
|
|
696
|
+
// ---- run command ----
|
|
697
|
+
async function cmdRun(args) {
|
|
698
|
+
const taskDir = path.resolve(args.taskDir);
|
|
699
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
700
|
+
const outputDir = args.outputDir ? path.resolve(args.outputDir) : taskDir;
|
|
701
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
702
|
+
setupLogging(outputDir);
|
|
703
|
+
logger.info(`参数: testcases=${args.testcases}, hap=${args.hap}, bundle=${args.bundleName}, config=${args.config ?? '(default)'}`);
|
|
704
|
+
const taskSubdir = path.join(taskDir, `task_${timestampForFile(new Date())}`);
|
|
705
|
+
fs.mkdirSync(taskSubdir, { recursive: true });
|
|
706
|
+
// Read model config from stdin (--model-stdin, injected by the a2h_self_test
|
|
707
|
+
// tool). Must happen before config resolution and before batch_runner spawn
|
|
708
|
+
// (batch_runner stdio is ['ignore', logFd, logFd], so fd0 is free to read here).
|
|
709
|
+
let stdinModel = null;
|
|
710
|
+
if (args.modelStdin) {
|
|
711
|
+
try {
|
|
712
|
+
stdinModel = JSON.parse(fs.readFileSync(0, 'utf-8'));
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
failJson('--model-stdin set but failed to read/parse stdin JSON');
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Resolve autotest.yaml config.
|
|
719
|
+
// Priority: --model-stdin (a2h tool, highest) > --config (explicit yaml) > CLI params (--api-key etc.) > env vars (HOMETRANS_MODEL_*) > default yaml.
|
|
720
|
+
// --model-stdin forces a temp yaml from stdin, ignoring --config / default yaml.
|
|
721
|
+
let configPath;
|
|
722
|
+
if (args.modelStdin && stdinModel?.apiKey) {
|
|
723
|
+
// Stdin path (a2h_self_test tool): always generate a temp yaml from the
|
|
724
|
+
// stdin-injected model config. Ignore --config and the default
|
|
725
|
+
// ~/.hometrans/autotest.yaml so the host-provided key is authoritative
|
|
726
|
+
// (the on-disk default may carry a stale key or none). Written 0o600 and
|
|
727
|
+
// deleted by --status once batch_runner reaches a terminal state.
|
|
728
|
+
configPath = path.join(taskSubdir, 'autotest.yaml');
|
|
729
|
+
const apiKey = stdinModel.apiKey;
|
|
730
|
+
const modelName = stdinModel.modelName || '';
|
|
731
|
+
const modelBaseUrl = stdinModel.baseURL || '';
|
|
732
|
+
const provider = args.provider || 'openai';
|
|
733
|
+
const mode = args.agentMode || 'single';
|
|
734
|
+
const slot = ` name: "${yamlScalar(modelName)}"\n base_url: "${yamlScalar(modelBaseUrl)}"\n api_key: "${yamlScalar(apiKey)}"\n provider: "${yamlScalar(provider)}"`;
|
|
735
|
+
let yaml;
|
|
736
|
+
if (mode === 'layered') {
|
|
737
|
+
yaml = `model:\n unified:\n${slot}\n execute:\n${slot}\n decision:\n${slot}\nagent:\n mode: "layered"\n`;
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
yaml = `model:\n unified:\n${slot}\nagent:\n mode: "single"\n`;
|
|
741
|
+
}
|
|
742
|
+
fs.writeFileSync(configPath, yaml, { encoding: 'utf-8', mode: 0o600 });
|
|
743
|
+
logger.info(`Generated autotest.yaml (mode=${mode}) from stdin model config → ${configPath}`);
|
|
744
|
+
}
|
|
745
|
+
else if (args.config) {
|
|
746
|
+
configPath = args.config;
|
|
747
|
+
if (!fs.existsSync(configPath))
|
|
748
|
+
failJson(`autotest.yaml not found: ${configPath}`);
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
const defaultYaml = path.join(os.homedir(), '.hometrans', 'autotest.yaml');
|
|
752
|
+
if (fs.existsSync(defaultYaml)) {
|
|
753
|
+
configPath = defaultYaml;
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
// Auto-generate from CLI params or env vars
|
|
757
|
+
const apiKey = args.apiKey || process.env.HOMETRANS_MODEL_API_KEY || '';
|
|
758
|
+
const modelName = args.modelName || process.env.HOMETRANS_MODEL_NAME || '';
|
|
759
|
+
const modelBaseUrl = args.modelBaseUrl || process.env.HOMETRANS_MODEL_BASE_URL || '';
|
|
760
|
+
const provider = args.provider || 'openai';
|
|
761
|
+
if (apiKey && modelName && modelBaseUrl) {
|
|
762
|
+
configPath = path.join(taskSubdir, 'autotest.yaml');
|
|
763
|
+
const mode = args.agentMode || 'single';
|
|
764
|
+
const slot = ` name: "${yamlScalar(modelName)}"\n base_url: "${yamlScalar(modelBaseUrl)}"\n api_key: "${yamlScalar(apiKey)}"\n provider: "${yamlScalar(provider)}"`;
|
|
765
|
+
let yaml;
|
|
766
|
+
if (mode === 'layered') {
|
|
767
|
+
yaml = `model:
|
|
768
|
+
unified:
|
|
769
|
+
${slot}
|
|
770
|
+
execute:
|
|
771
|
+
${slot}
|
|
772
|
+
decision:
|
|
773
|
+
${slot}
|
|
774
|
+
agent:
|
|
775
|
+
mode: "layered"
|
|
776
|
+
`;
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
yaml = `model:
|
|
780
|
+
unified:
|
|
781
|
+
${slot}
|
|
782
|
+
agent:
|
|
783
|
+
mode: "single"
|
|
784
|
+
`;
|
|
785
|
+
}
|
|
786
|
+
fs.writeFileSync(configPath, yaml, 'utf-8');
|
|
787
|
+
logger.info(`Generated autotest.yaml (mode=${mode}) from ${args.apiKey ? 'CLI params' : 'HOMETRANS_MODEL_* env'} → ${configPath}`);
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
failJson(`No model config: pass --config <yaml>, or --api-key + --model-name + --model-base-url, or run ht init to set HOMETRANS_MODEL_* env vars`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
logger.info(`Config: ${configPath}`);
|
|
795
|
+
// Read agent.mode from autotest.yaml and forward it to batch_runner via
|
|
796
|
+
// --mode. Without this, batch_runner (<= 0.0.2) defaults to "single" even
|
|
797
|
+
// when the yaml says "layered", so Planner+Executor never activates.
|
|
798
|
+
const agentMode = args.agentMode || readAgentModeFromYaml(configPath);
|
|
799
|
+
if (agentMode)
|
|
800
|
+
logger.info(`autotest.yaml agent.mode = "${agentMode}" → --mode ${agentMode}`);
|
|
801
|
+
else
|
|
802
|
+
logger.info('autotest.yaml agent.mode not set → batch_runner defaults to "single"');
|
|
803
|
+
// HAP install/uninstall
|
|
804
|
+
const hdc = getHdc();
|
|
805
|
+
// Read device_sn from autotest.yaml so hdc install/uninstall can target a
|
|
806
|
+
// specific device (-t <sn>). Without it, bare `hdc install` fails with
|
|
807
|
+
// "need connect-key" when ≥2 devices are connected — and worse, install could
|
|
808
|
+
// land on device A while batch_runner auto-detects device B, testing the
|
|
809
|
+
// wrong (stale) app. So when device_sn is unset AND ≥2 devices are connected,
|
|
810
|
+
// fail fast and tell the user to pin a device in autotest.yaml. With exactly
|
|
811
|
+
// one device, bare hdc auto-detects it correctly.
|
|
812
|
+
const deviceSn = readDeviceSnFromYaml(configPath);
|
|
813
|
+
let hdcCmd;
|
|
814
|
+
if (deviceSn) {
|
|
815
|
+
hdcCmd = [hdc, '-t', deviceSn];
|
|
816
|
+
logger.info(`autotest.yaml device.device_sn = "${deviceSn}" → hdc -t ${deviceSn}`);
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
const targets = runCmd([hdc, 'list', 'targets'], { check: false, timeout: 15 }).stdout
|
|
820
|
+
.split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !/^\[|empty/i.test(s));
|
|
821
|
+
if (targets.length > 1) {
|
|
822
|
+
logger.error(`autotest.yaml 未设 device.device_sn,但连了 ${targets.length} 台设备(${targets.join(', ')})。` +
|
|
823
|
+
`请在 ~/.hometrans/autotest.yaml 的 device.device_sn 指定目标设备,` +
|
|
824
|
+
`避免 install 与 batch_runner 跑在不同设备上。`);
|
|
825
|
+
failJson(`device.device_sn not set in autotest.yaml; ${targets.length} devices connected (${targets.join(', ')}); set device.device_sn to pin the target`);
|
|
826
|
+
}
|
|
827
|
+
hdcCmd = [hdc];
|
|
828
|
+
logger.info(`autotest.yaml device.device_sn not set; ${targets.length} device(s) connected → hdc auto-detect`);
|
|
829
|
+
}
|
|
830
|
+
logger.info(`=== Step 2a: uninstall ${args.bundleName} ===`);
|
|
831
|
+
runCmd([...hdcCmd, 'uninstall', args.bundleName], { timeout: INSTALL_TIMEOUT, check: false });
|
|
832
|
+
logger.info(`=== Step 2b: install ${args.hap} ===`);
|
|
833
|
+
installHaps(args.hap, hdcCmd);
|
|
834
|
+
// Prepare testcases
|
|
835
|
+
const testcasesJsonl = prepareTestcasesJsonl(path.resolve(args.testcases), taskSubdir);
|
|
836
|
+
// Resolve timeout
|
|
837
|
+
let effectiveTimeout;
|
|
838
|
+
if (args.timeout === 'auto') {
|
|
839
|
+
const caseCount = fs.readFileSync(testcasesJsonl, 'utf-8').split(/\r?\n/).filter((l) => l.trim()).length;
|
|
840
|
+
effectiveTimeout = Math.max(caseCount, 1) * PER_CASE_TIMEOUT_SEC;
|
|
841
|
+
logger.info(`--timeout auto: ${caseCount} 条用例 × ${PER_CASE_TIMEOUT_SEC}s = ${effectiveTimeout}s`);
|
|
842
|
+
}
|
|
843
|
+
else if (args.timeout && typeof args.timeout === 'number') {
|
|
844
|
+
effectiveTimeout = args.timeout;
|
|
845
|
+
}
|
|
846
|
+
// Refresh model.unified.api_key from HOMETRANS_MODEL_API_KEY env if it has
|
|
847
|
+
// been rotated since the yaml was last generated. Skipped on the stdin path
|
|
848
|
+
// (a2h_self_test tool): the key came from the host via stdin, not env, so a
|
|
849
|
+
// possibly-stale HOMETRANS_MODEL_API_KEY (from a prior `ht init`) must not
|
|
850
|
+
// overwrite the stdin-injected key.
|
|
851
|
+
if (!args.modelStdin)
|
|
852
|
+
refreshApiKeyFromEnv(configPath);
|
|
853
|
+
// Build batch command — ensure @autotest/agent is installed (auto-install if missing)
|
|
854
|
+
const runnerJs = ensureAutotestAgent();
|
|
855
|
+
if (!runnerJs) {
|
|
856
|
+
failJson('batch_runner.js not found; @autotest/agent auto-install failed');
|
|
857
|
+
}
|
|
858
|
+
const cmd = ['node', runnerJs, '--task-dir', taskSubdir, '--testcases', testcasesJsonl, '--category', args.category, '--config', configPath];
|
|
859
|
+
if (agentMode)
|
|
860
|
+
cmd.push('--mode', agentMode);
|
|
861
|
+
logger.info('=== Step 3: 启动 AutoTest batch_runner ===');
|
|
862
|
+
// Spawn detached; write stdout/stderr to batch_stdout.log (file-based, not
|
|
863
|
+
// pipe-forward). This ensures batch_runner survives a parent-process kill
|
|
864
|
+
// (no EPIPE from broken pipe), and --status can detect CRASHED via log mtime.
|
|
865
|
+
const stdoutLog = path.join(taskDir, 'batch_stdout.log');
|
|
866
|
+
const logFd = fs.openSync(stdoutLog, 'w');
|
|
867
|
+
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
868
|
+
cwd: taskSubdir,
|
|
869
|
+
stdio: ['ignore', logFd, logFd],
|
|
870
|
+
env: process.env,
|
|
871
|
+
detached: true,
|
|
872
|
+
windowsHide: true,
|
|
873
|
+
});
|
|
874
|
+
proc.unref();
|
|
875
|
+
logger.info(`后台进程 PID=${proc.pid}`);
|
|
876
|
+
fs.closeSync(logFd);
|
|
877
|
+
// Record the batch pid in task_subdir so --status (which has no spawn
|
|
878
|
+
// context) can detect a dead/killed process immediately (see probeStatus).
|
|
879
|
+
// Without it, an agent timeout-kill leaves the temp autotest.yaml (apiKey) on
|
|
880
|
+
// disk: the post-kill poll still sees RUNNING (fresh log mtime).
|
|
881
|
+
try { fs.writeFileSync(path.join(taskSubdir, 'batch.pid'), String(proc.pid), 'utf-8'); } catch { /* best-effort */ }
|
|
882
|
+
// No timeout: return immediately
|
|
883
|
+
if (!effectiveTimeout) {
|
|
884
|
+
console.log(JSON.stringify({ status: 'RUNNING', pid: proc.pid, task_dir: taskDir, task_subdir: taskSubdir }));
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
// Poll until terminal
|
|
888
|
+
const start = Date.now();
|
|
889
|
+
while (true) {
|
|
890
|
+
const r = probeStatus(taskDir, outputDir);
|
|
891
|
+
if (r.status === 'COMPLETED') {
|
|
892
|
+
console.log(JSON.stringify({
|
|
893
|
+
status: 'COMPLETED', task_subdir: r.taskSubdir, cases_done: r.casesDone,
|
|
894
|
+
pass_count: r.passCount ?? 0, fail_count: r.failCount ?? 0, unknown_count: r.unknownCount ?? 0,
|
|
895
|
+
pass_rate: r.passRate ?? 0, log_tail: r.logTail,
|
|
896
|
+
}));
|
|
897
|
+
process.exit(0);
|
|
898
|
+
}
|
|
899
|
+
// CRASHED detection: batch process died without producing summary.json.
|
|
900
|
+
// Without this, a spawn-time crash (ConfigManager error, missing dep, JS
|
|
901
|
+
// exception) would be misreported as TIMEOUT after waiting the full budget.
|
|
902
|
+
const procAlive = isProcessAlive(proc.pid);
|
|
903
|
+
if (!procAlive) {
|
|
904
|
+
logger.error(`batch_runner 进程 PID=${proc.pid} 已退出但无 summary.json → CRASHED`);
|
|
905
|
+
deleteTempYaml(r.taskSubdir);
|
|
906
|
+
console.log(JSON.stringify({
|
|
907
|
+
status: 'CRASHED', task_subdir: r.taskSubdir, cases_done: r.casesDone,
|
|
908
|
+
last_case: r.lastCase, log_tail: r.logTail,
|
|
909
|
+
}));
|
|
910
|
+
process.exit(3);
|
|
911
|
+
}
|
|
912
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
913
|
+
if (elapsed >= effectiveTimeout) {
|
|
914
|
+
killProcessTree(proc.pid);
|
|
915
|
+
deleteTempYaml(r.taskSubdir);
|
|
916
|
+
console.log(JSON.stringify({
|
|
917
|
+
status: 'TIMEOUT', task_subdir: r.taskSubdir, cases_done: r.casesDone,
|
|
918
|
+
last_case: r.lastCase, log_tail: r.logTail,
|
|
919
|
+
}));
|
|
920
|
+
process.exit(5);
|
|
921
|
+
}
|
|
922
|
+
await sleepAsync(60);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
// ---- status command ----
|
|
926
|
+
function cmdStatus(taskDir, outputDir) {
|
|
927
|
+
const r = probeStatus(path.resolve(taskDir), outputDir ? path.resolve(outputDir) : path.resolve(taskDir));
|
|
928
|
+
// Delete the temp autotest.yaml (apiKey) on ANY terminal state -- not just
|
|
929
|
+
// COMPLETED/CRASHED. NOT_STARTED has taskSubdir null (no-op). CRASHED here
|
|
930
|
+
// also covers the post-timeout-kill case: probeStatus returns CRASHED once
|
|
931
|
+
// batch.pid shows the process is dead, so this deletes the key right after
|
|
932
|
+
// the agent kills the run and polls once more.
|
|
933
|
+
if (r.status !== 'RUNNING') {
|
|
934
|
+
deleteTempYaml(r.taskSubdir);
|
|
935
|
+
}
|
|
936
|
+
if (r.status === 'NOT_STARTED') {
|
|
937
|
+
console.log(JSON.stringify({ status: 'NOT_STARTED' }));
|
|
938
|
+
process.exit(4);
|
|
939
|
+
}
|
|
940
|
+
if (r.status === 'COMPLETED') {
|
|
941
|
+
console.log(JSON.stringify({
|
|
942
|
+
status: 'COMPLETED', task_subdir: r.taskSubdir, cases_done: r.casesDone,
|
|
943
|
+
pass_count: r.passCount ?? 0, fail_count: r.failCount ?? 0, unknown_count: r.unknownCount ?? 0,
|
|
944
|
+
pass_rate: r.passRate ?? 0, log_tail: r.logTail,
|
|
945
|
+
}));
|
|
946
|
+
process.exit(0);
|
|
947
|
+
}
|
|
948
|
+
console.log(JSON.stringify({ status: r.status, cases_done: r.casesDone, last_case: r.lastCase, task_subdir: r.taskSubdir, log_tail: r.logTail }));
|
|
949
|
+
process.exit(r.status === 'RUNNING' ? 2 : 3);
|
|
950
|
+
}
|
|
951
|
+
// ---- entry ----
|
|
952
|
+
const args = process.argv.slice(2);
|
|
953
|
+
// Parse args: --status flag switches to status mode, otherwise run mode
|
|
954
|
+
const isStatus = args.includes('--status');
|
|
955
|
+
const filtered = args.filter((a) => a !== '--status');
|
|
956
|
+
if (filtered.includes('-h') || filtered.includes('--help')) {
|
|
957
|
+
if (isStatus) {
|
|
958
|
+
console.log('Usage: self-test-runner --status --task-dir <dir> [--output-dir <dir>]');
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
console.log(`Usage: self-test-runner [options]
|
|
962
|
+
|
|
963
|
+
Options:
|
|
964
|
+
--testcases <path> 测试用例文件,JSON 数组或 JSONL
|
|
965
|
+
--hap <path> 签名包:单个 .hap/.hsp 文件、目录,或逗号分隔
|
|
966
|
+
--bundle-name <name> 包名
|
|
967
|
+
--task-dir <dir> 任务目录 (default: "task")
|
|
968
|
+
--output-dir <dir> 日志目录
|
|
969
|
+
--category <name> 测试分类名称 (default: "self_test")
|
|
970
|
+
--config <path> autotest.yaml 路径(有 yaml 文件时用)
|
|
971
|
+
--api-key <key> 模型 API Key(无 yaml 时从 CLI/env 生成临时 yaml)
|
|
972
|
+
--model-name <name> 模型名称(如 qwen3.7-plus)
|
|
973
|
+
--model-base-url <url> 模型 API base URL
|
|
974
|
+
--provider <name> 模型 provider (default: "openai")
|
|
975
|
+
--mode <single|layered> agent 模式(default: single;layered 时从同一组参数生成 execute/decision 槽位)
|
|
976
|
+
--timeout <sec> 阻塞直到终态或超时(秒),或 auto
|
|
977
|
+
--model-stdin 从 stdin 读取模型配置(a2h 工具注入;优先级最高)
|
|
978
|
+
--status 查询执行状态(而非运行)
|
|
979
|
+
-h, --help display this help
|
|
980
|
+
|
|
981
|
+
Model config priority: --model-stdin (a2h 工具, 最高) > --config (yaml file) > --api-key/--model-name/--model-base-url (CLI) > HOMETRANS_MODEL_* (env from ht init).
|
|
982
|
+
When --config is absent, the runner auto-generates a temp autotest.yaml from CLI params or env vars. --model-stdin forces a temp yaml from stdin, ignoring --config / default yaml.
|
|
983
|
+
`);
|
|
984
|
+
}
|
|
985
|
+
process.exit(0);
|
|
986
|
+
}
|
|
987
|
+
if (isStatus) {
|
|
988
|
+
// Status mode: only need --task-dir and --output-dir
|
|
989
|
+
let taskDir = '';
|
|
990
|
+
let outputDir;
|
|
991
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
992
|
+
if (filtered[i] === '--task-dir')
|
|
993
|
+
taskDir = filtered[++i];
|
|
994
|
+
else if (filtered[i] === '--output-dir')
|
|
995
|
+
outputDir = filtered[++i];
|
|
996
|
+
}
|
|
997
|
+
if (!taskDir)
|
|
998
|
+
failJson('Missing required option: --task-dir');
|
|
999
|
+
cmdStatus(taskDir, outputDir);
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
// Run mode (default)
|
|
1003
|
+
cmdRun(parseRunArgs(filtered)).catch((e) => {
|
|
1004
|
+
failJson(`cmdRun 未捕获异常: ${e instanceof Error ? e.message : e}`);
|
|
1005
|
+
});
|
|
1006
|
+
}
|