android2harmony 0.1.3 → 0.1.5

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.
Files changed (113) hide show
  1. package/README.md +1 -407
  2. package/agents/self-tester.md +376 -339
  3. package/dist/index.js +131 -586
  4. package/dist/index.js.map +4 -4
  5. package/package.json +36 -37
  6. package/skills/a2h-resource-convert/SKILL.md +902 -0
  7. package/skills/a2h-resource-convert/references/code-vector-icon-rules.md +335 -0
  8. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/conversion-rules.md +15 -2
  9. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/dependency-analysis-rules.md +24 -2
  10. package/skills/a2h-resource-convert/references/lottie-conversion-rules.md +219 -0
  11. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/resource-mapping-rules.md +27 -2
  12. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/xml-drawable-to-svg-rules.md +118 -1
  13. package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +2166 -0
  14. package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
  15. package/skills/a2h-resource-convert/scripts/package.json +3 -0
  16. package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
  17. package/skills/a2h-ui-transfer/SKILL.md +420 -0
  18. package/skills/a2h-ui-transfer/references/conversion-procedure.md +572 -0
  19. package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/_/350/214/203/345/274/217/351/200/211/346/213/251/350/257/264/346/230/216.md +1 -1
  20. package/skills/a2h-ui-transfer/references/mvvm-v2/_/350/243/205/351/245/260/345/231/250/351/200/237/346/237/245.md +133 -0
  21. package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +397 -283
  22. package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
  23. package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
  24. package/skills/a2h-ui-transfer/scripts/package.json +3 -0
  25. package/skills/hmos-incremental-ui-align/README.md +251 -251
  26. package/skills/hmos-incremental-ui-align/SKILL.md +364 -364
  27. package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
  28. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
  29. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
  30. package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
  31. package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
  32. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
  33. package/skills/hmos-batch-ui-align/SKILL.md +0 -141
  34. package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
  35. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
  36. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
  37. package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
  38. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
  39. package/skills/hmos-resources-convert/SKILL.md +0 -654
  40. package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
  41. package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
  42. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
  43. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
  44. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
  45. package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
  46. package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
  47. package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
  48. package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
  49. package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
  50. package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
  51. package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
  52. package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
  53. package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
  54. package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
  55. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
  56. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
  57. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
  58. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
  59. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
  60. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
  61. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
  62. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
  63. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
  64. package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
  65. package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
  66. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
  67. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
  68. package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
  69. package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
  70. package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
  71. package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
  72. package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
  73. package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
  74. package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
  75. package/tools/autotest/deps/autotest-agent-0.1.1.tgz +0 -0
  76. package/tools/autotest/engine/batch-launcher.ts +0 -326
  77. package/tools/autotest/engine/report-tool.ts +0 -773
  78. package/tools/autotest/engine/self-test-runner.ts +0 -1024
  79. package/tools/autotest/engine/testcases-tool.ts +0 -246
  80. package/tools/autotest/resolve-metadata-tool.ts +0 -143
  81. package/tools/autotest/runner/logger.ts +0 -45
  82. package/tools/autotest/runner/process-utils.ts +0 -41
  83. package/tools/autotest/validate.ts +0 -115
  84. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
  85. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
  86. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
  87. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  88. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md" +0 -0
  89. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md" +0 -0
  90. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  91. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md" +0 -0
  92. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md" +0 -0
  93. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md" +0 -0
  94. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  95. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md" +0 -0
  96. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  97. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md" +0 -0
  98. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
  99. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md" +0 -0
  100. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/!!/350/257/255/346/263/225/357/274/232/345/217/214/345/220/221/347/273/221/345/256/232.md" +0 -0
  101. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Computed/350/243/205/351/245/260/345/231/250/357/274/232/350/256/241/347/256/227/345/261/236/346/200/247.md" +0 -0
  102. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Event/350/243/205/351/245/260/345/231/250/357/274/232/350/247/204/350/214/203/347/273/204/344/273/266/350/276/223/345/207/272.md" +0 -0
  103. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Local/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/351/203/250/347/212/266/346/200/201.md" +0 -0
  104. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Monitor/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/344/277/256/346/224/271/345/274/202/346/255/245/347/233/221/345/220/254.md" +0 -0
  105. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@ObservedV2/350/243/205/351/245/260/345/231/250/345/222/214@Trace/350/243/205/351/245/260/345/231/250/357/274/232/347/261/273/345/261/236/346/200/247/345/217/230/345/214/226/350/247/202/346/265/213.md" +0 -0
  106. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Once/350/243/205/351/245/260/345/231/250/357/274/232/345/210/235/345/247/213/345/214/226/345/220/214/346/255/245/344/270/200/346/254/241.md" +0 -0
  107. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Param/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/244/226/351/203/250/350/276/223/345/205/245.md" +0 -0
  108. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Provider/350/243/205/351/245/260/345/231/250/345/222/214@Consumer/350/243/205/351/245/260/345/231/250/357/274/232/350/267/250/347/273/204/344/273/266/345/261/202/347/272/247/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  109. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Type/350/243/205/351/245/260/345/231/250/357/274/232/346/240/207/350/256/260/347/261/273/345/261/236/346/200/247/347/232/204/347/261/273/345/236/213.md" +0 -0
  110. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/AppStorageV2/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  111. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/MVVM/346/250/241/345/274/217/357/274/210V2/357/274/211.md" +0 -0
  112. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/PersistenceV2/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
  113. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2//347/212/266/346/200/201/347/256/241/347/220/206V1/345/220/221V2/350/277/201/347/247/273/344/270/216/346/267/267/347/224/250/346/214/207/345/257/274.md" +0 -0
@@ -1,1024 +0,0 @@
1
- /**
2
- * Self-Test Runner — launches HarmonyOS on-device self-tests via the Node.js
3
- * `@autotest/agent` engine (batch_runner), then reports status from output files.
4
- *
5
- * run — prepare env (npm install + hdc install), spawn batch detached, return PID.
6
- * status — report state from output files (summary.json / task_results.jsonl) + native liveness.
7
- * kill — terminate the background batch.
8
- *
9
- * STDOUT carries strict JSON parsed by the caller; all logging goes to stderr /
10
- * the log file. JSON shapes + exit codes are a frozen contract (see agents/self-tester.md).
11
- */
12
- import crypto from 'node:crypto';
13
- import { execFileSync, spawn, spawnSync } from 'node:child_process';
14
- import fs from 'node:fs';
15
- import path from 'node:path';
16
- import { fileURLToPath } from 'node:url';
17
- import { Command } from 'commander';
18
- import { createLogger, type Logger } from '../runner/logger.ts';
19
- import { killProcessTree, pidAlive } from '../runner/process-utils.ts';
20
- import {
21
- type CheckResult,
22
- checkHap,
23
- checkInputs,
24
- checkPre,
25
- } from '../validate.ts';
26
-
27
- const HAP_INSTALL_WAIT = 3; // post-install settle, in seconds (reduced from the original 10s).
28
- const INSTALL_TIMEOUT = 120;
29
- const DEFAULT_CATEGORY = 'self_test';
30
-
31
- let logger: Logger = createLogger(null, 'INFO');
32
-
33
- function timestampForFile(d: Date): string {
34
- const pad = (n: number) => String(n).padStart(2, '0');
35
- return (
36
- `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_` +
37
- `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
38
- );
39
- }
40
-
41
- /** Synchronous sleep (PowerShell on Windows, `sleep` on POSIX). Best-effort. */
42
- function sleep(sec: number): void {
43
- if (sec <= 0) return;
44
- const ms = sec * 1000;
45
- try {
46
- if (process.platform === 'win32') {
47
- spawnSync('powershell.exe', ['-NoProfile', '-Command', `Start-Sleep -Seconds ${sec}`], {
48
- stdio: 'ignore',
49
- timeout: ms + 5000,
50
- windowsHide: true,
51
- });
52
- } else {
53
- spawnSync('sleep', [String(sec)], { stdio: 'ignore', timeout: ms + 5000 });
54
- }
55
- } catch {
56
- // best effort
57
- }
58
- }
59
-
60
- function setupLogging(outputDir: string): void {
61
- const logPath = path.join(outputDir, `self_test_${timestampForFile(new Date())}.log`);
62
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
63
- logger = createLogger(fs.createWriteStream(logPath, { encoding: 'utf-8' }), 'DEBUG');
64
- logger.info(`日志文件: ${logPath}`);
65
- }
66
-
67
- // ---- external tool discovery ----------------------------------------------
68
-
69
- /** Find `name` on PATH via `where` (Windows) or `which` (POSIX). Null on failure. */
70
- function findOnPath(name: string): string | null {
71
- const finder = process.platform === 'win32' ? 'where' : 'which';
72
- try {
73
- const out = execFileSync(finder, [name], {
74
- encoding: 'utf-8',
75
- windowsHide: true,
76
- timeout: 5000,
77
- });
78
- const first = out.split(/\r?\n/).find((l) => l.trim());
79
- return first ? first.trim() : null;
80
- } catch {
81
- return null;
82
- }
83
- }
84
-
85
- function getHdc(): string {
86
- const found = findOnPath('hdc');
87
- if (!found) {
88
- logger.error('hdc 未找到');
89
- process.exit(1);
90
- }
91
- return found;
92
- }
93
-
94
- /**
95
- * Absolute path to the compiled `batch-launcher.js` — the detached background
96
- * process that drives `new AutoTestAgent({...})` programmatically. Sits next to
97
- * this module under `dist/test-tools/autotest/` (and `src/...` in dev mode via
98
- * `HOMETRANS_AUTOTEST_DIR`).
99
- */
100
- function batchLauncherPath(): string {
101
- const here = path.dirname(fileURLToPath(import.meta.url));
102
- const p = path.join(here, 'batch-launcher.ts');
103
- if (!fs.existsSync(p)) {
104
- logger.error(
105
- `未找到 batch-launcher: ${p}\n` +
106
- '请先在 HomeTrans 根目录执行 `npm run build` 重新构建 dist/。',
107
- );
108
- process.exit(1);
109
- }
110
- return p;
111
- }
112
-
113
- /**
114
- * Model params injected via stdin by the a2h plugin tool (which resolved them
115
- * via `deps.resolveModelParams`). `provider` is not surfaced by that contract,
116
- * so it defaults to `openai` (OpenAI-compatible — works with any baseURL the
117
- * `@ai-sdk/openai` client can target). No `~/.hometrans/config.json` involved.
118
- */
119
- interface ModelConfig {
120
- apiKey: string;
121
- modelName: string;
122
- baseURL: string;
123
- }
124
-
125
- /** Optional device/agent overrides passed through from CLI args. */
126
- interface DeviceOpts {
127
- deviceSn?: string;
128
- ip?: string;
129
- port?: string;
130
- agentMode?: string;
131
- maxSteps?: string;
132
- promptVersion?: string;
133
- }
134
-
135
- /**
136
- * Read `{ apiKey, modelName, baseURL }` JSON from stdin (fd 0). The plugin
137
- * tool writes the blob and closes the write end; `readFileSync(0)` returns
138
- * once EOF is seen. Throws on missing/invalid/empty apiKey.
139
- */
140
- function readModelFromStdin(): ModelConfig {
141
- let raw = '';
142
- try {
143
- raw = fs.readFileSync(0, 'utf-8');
144
- } catch (e) {
145
- throw new Error(`--model-stdin: failed to read stdin: ${(e as Error).message}`);
146
- }
147
- let parsed: unknown;
148
- try {
149
- parsed = JSON.parse(raw);
150
- } catch (e) {
151
- throw new Error(`--model-stdin: stdin is not valid JSON: ${(e as Error).message}`);
152
- }
153
- const m = (parsed ?? {}) as Partial<ModelConfig>;
154
- if (!m.apiKey) {
155
- throw new Error('--model-stdin: stdin JSON missing apiKey');
156
- }
157
- return { apiKey: m.apiKey, modelName: m.modelName ?? '', baseURL: m.baseURL ?? '' };
158
- }
159
-
160
- /**
161
- * Bridge the stdin-injected model params + CLI device/agent options into the
162
- * `AUTOTEST_*` env vars the batch launcher reads — these become the
163
- * `new AutoTestAgent({ apiKey, modelName, … })` constructor args, so the
164
- * key/model/device flow straight into the agent constructor with no
165
- * config.yaml file and no `~/.hometrans/config.json`.
166
- */
167
- function agentEnvFromModel(model: ModelConfig, dev: DeviceOpts, base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
168
- const env: NodeJS.ProcessEnv = { ...base };
169
- env.AUTOTEST_API_KEY = model.apiKey;
170
- if (model.modelName) env.AUTOTEST_MODEL = model.modelName;
171
- if (model.baseURL) env.AUTOTEST_BASE_URL = model.baseURL;
172
- // resolveModelParams returns no provider; OpenAI-compatible default.
173
- env.AUTOTEST_PROVIDER = 'openai';
174
- if (dev.deviceSn) env.AUTOTEST_DEVICE_SN = dev.deviceSn;
175
- if (dev.ip) env.AUTOTEST_IP = dev.ip;
176
- if (dev.port) env.AUTOTEST_PORT = dev.port;
177
- env.AUTOTEST_MODE = dev.agentMode || 'single';
178
- if (dev.maxSteps) env.AUTOTEST_MAX_STEPS = dev.maxSteps;
179
- if (dev.promptVersion) env.AUTOTEST_PROMPT_VERSION = dev.promptVersion;
180
- return env;
181
- }
182
-
183
- // ---- sync subprocess runner ------------------------------------------------
184
-
185
- interface RunCmdResult {
186
- returncode: number;
187
- stdout: string;
188
- stderr: string;
189
- }
190
-
191
- /**
192
- * Synchronous subprocess runner with logging. On non-zero exit logs
193
- * stdout/stderr; if `check` (default true), exits with the failing code.
194
- */
195
- function runCmd(
196
- cmd: string[],
197
- opts: { cwd?: string; check?: boolean; timeout?: number; env?: NodeJS.ProcessEnv } = {},
198
- ): RunCmdResult {
199
- const { cwd, check = true, timeout, env } = opts;
200
- const cmdStr = cmd.join(' ');
201
- logger.info(`>>> ${cmdStr}`);
202
- const result = spawnSync(cmd[0], cmd.slice(1), {
203
- cwd,
204
- env,
205
- encoding: 'utf-8',
206
- timeout: timeout ? timeout * 1000 : undefined,
207
- maxBuffer: 1024 * 1024 * 1024,
208
- windowsHide: true,
209
- stdio: ['ignore', 'pipe', 'pipe'],
210
- });
211
- if (result.error) {
212
- logger.error(`命令异常: ${result.error.message}`);
213
- throw result.error;
214
- }
215
- const status = result.status ?? 0;
216
- const stdout = result.stdout ?? '';
217
- const stderr = result.stderr ?? '';
218
- if (status !== 0) {
219
- logger.warning(`退出码=${status}: ${cmdStr}`);
220
- if (stdout) logger.warning(` stdout: ${stdout.trim()}`);
221
- if (stderr) logger.warning(` stderr: ${stderr.trim()}`);
222
- if (check) process.exit(status);
223
- } else {
224
- logger.debug(`成功 (rc=0): ${cmdStr}`);
225
- }
226
- return { returncode: status, stdout, stderr };
227
- }
228
-
229
- // ---- testcases JSONL normalizer -------------------------------------------
230
-
231
- /**
232
- * Normalize `--testcases` into a JSONL path. A JSON array (legacy
233
- * `testcases.json`) is converted to JSONL at `<taskSubdir>/testcases.jsonl`,
234
- * filling missing `uuid` (md5 of case_name or `case_<idx>`, first 8 hex) and
235
- * `spec` (""). Already-JSONL input is validated line-by-line and passed through.
236
- */
237
- export function prepareTestcasesJsonl(inputPath: string, taskSubdir: string): string {
238
- if (!fs.existsSync(inputPath)) {
239
- logger.error(`测试用例文件不存在: ${inputPath}`);
240
- process.exit(1);
241
- }
242
- let text = fs.readFileSync(inputPath, 'utf-8');
243
- if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // strip BOM
244
- const stripped = text.trimStart();
245
- if (!stripped) {
246
- logger.error(`测试用例文件为空: ${inputPath}`);
247
- process.exit(1);
248
- }
249
-
250
- if (stripped[0] === '[') {
251
- let arr: unknown;
252
- try {
253
- arr = JSON.parse(text);
254
- } catch (e) {
255
- logger.error(`测试用例 JSON 数组解析失败 ${inputPath}: ${e instanceof Error ? e.message : e}`);
256
- process.exit(1);
257
- }
258
- if (!Array.isArray(arr)) {
259
- logger.error(`测试用例文件根不是数组: ${inputPath}`);
260
- process.exit(1);
261
- }
262
- const outPath = path.join(taskSubdir, 'testcases.jsonl');
263
- const lines = arr.map((entry, idx) => {
264
- const i = idx + 1;
265
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
266
- logger.error(`用例 #${i} 不是 JSON 对象: ${JSON.stringify(entry)}`);
267
- process.exit(1);
268
- }
269
- const c = entry as Record<string, unknown>;
270
- if (!c.uuid) {
271
- const seed = typeof c.case_name === 'string' && c.case_name ? c.case_name : `case_${i}`;
272
- c.uuid = crypto.createHash('md5').update(seed, 'utf-8').digest('hex').slice(0, 8);
273
- }
274
- if (!('spec' in c)) c.spec = '';
275
- return JSON.stringify(c);
276
- });
277
- fs.writeFileSync(outPath, lines.join('\n') + '\n', 'utf-8');
278
- logger.info(`已将 JSON 数组转换为 JSONL: ${outPath}(${arr.length} 条用例)`);
279
- return outPath;
280
- }
281
-
282
- const lines = text.split(/\r?\n/).filter((l) => l.trim());
283
- lines.forEach((line, i) => {
284
- try {
285
- JSON.parse(line);
286
- } catch (e) {
287
- logger.error(`JSONL 第 ${i + 1} 行解析失败 ${inputPath}: ${e instanceof Error ? e.message : e}`);
288
- process.exit(1);
289
- }
290
- });
291
- logger.info(`测试用例文件已为 JSONL,原样透传: ${inputPath}(${lines.length} 条用例)`);
292
- return inputPath;
293
- }
294
-
295
- // ---- HAP install -----------------------------------------------------------
296
-
297
- /**
298
- * Install every `.hap`/`.hsp` from a comma-separated `--hap` arg (each entry a
299
- * file or a directory) in one `hdc install -r` transaction. Real apps ship an
300
- * entry HAP plus HSPs, so at least one `.hap` is required. Success is judged by
301
- * hdc's stdout heuristic — hdc can return 0 on failure.
302
- */
303
- function installHaps(hapArg: string, hdc: string): void {
304
- const entries = hapArg
305
- .split(',')
306
- .map((s) => s.trim())
307
- .filter((s) => s);
308
- if (entries.length === 0) {
309
- logger.error('hap-path 为空');
310
- process.exit(1);
311
- }
312
- const pkgs: string[] = [];
313
- for (const e of entries) {
314
- if (!fs.existsSync(e)) {
315
- logger.error(`hap 路径不存在: ${e}`);
316
- process.exit(1);
317
- }
318
- const stat = fs.statSync(e);
319
- const lower = e.toLowerCase();
320
- if (stat.isDirectory()) {
321
- const found = fs
322
- .readdirSync(e)
323
- .filter((f) => f.toLowerCase().endsWith('.hap') || f.toLowerCase().endsWith('.hsp'))
324
- .sort()
325
- .map((f) => path.join(e, f));
326
- if (found.length === 0) {
327
- logger.error(`目录内无 .hap/.hsp 包: ${e}`);
328
- process.exit(1);
329
- }
330
- pkgs.push(...found);
331
- } else if (stat.isFile() && (lower.endsWith('.hap') || lower.endsWith('.hsp'))) {
332
- pkgs.push(e);
333
- } else {
334
- logger.error(`既不是目录也不是 .hap/.hsp 文件: ${e}`);
335
- process.exit(1);
336
- }
337
- }
338
- // Resolve to native absolute paths (backslashes on Windows) AND dedup in one
339
- // pass. Resolving is required, not cosmetic: hdc on Windows mis-parses a
340
- // forward-slash *drive-absolute* path (e.g. `C:/a/b.hap`) as relative and
341
- // prepends its own cwd (`C:\cwd\C:/a/b.hap` → invalid), silently skipping the
342
- // install after Step 2a already uninstalled the app. `path.resolve` yields the
343
- // native `C:\a\b.hap` form hdc accepts. On POSIX it's a harmless no-op for
344
- // already-absolute inputs. Callers assemble --hap from multiple sources
345
- // (package-set dir, comma-list, build-fixer's hsp/ copy) that can overlap, so
346
- // dedup on the same resolved key.
347
- const seen = new Set<string>();
348
- const deduped: string[] = [];
349
- for (const p of pkgs) {
350
- const resolved = path.resolve(p);
351
- if (seen.has(resolved)) continue;
352
- seen.add(resolved);
353
- deduped.push(resolved);
354
- }
355
- if (!deduped.some((p) => p.toLowerCase().endsWith('.hap'))) {
356
- logger.error(`缺少 entry HAP(至少需要一个 .hap): ${hapArg}`);
357
- process.exit(1);
358
- }
359
-
360
- // hdc install -r accepts multiple package paths in one transaction.
361
- logger.info(`安装 ${deduped.length} 个包: ${deduped.map((p) => path.basename(p)).join(', ')}`);
362
- const result = runCmd([hdc, 'install', '-r', ...deduped], {
363
- timeout: INSTALL_TIMEOUT,
364
- check: false,
365
- });
366
- const combined = `${(result.stdout || '').trim()}\n${(result.stderr || '').trim()}`;
367
- const combinedLower = combined.toLowerCase();
368
- // msg:error is case-sensitive; the rest match on lowercased output.
369
- const hasError =
370
- combined.includes('msg:error') ||
371
- combinedLower.includes('failed to install') ||
372
- (combined.includes('code:') && combinedLower.includes('error:'));
373
- const hasSuccess = combinedLower.includes('successfully');
374
- if (result.returncode === 0 && !hasError && hasSuccess) {
375
- logger.info('安装成功');
376
- sleep(HAP_INSTALL_WAIT);
377
- } else if (result.returncode === 0 && !hasError) {
378
- logger.warning(`安装结果不确定(rc=0 但无 success 消息): ${combined}`);
379
- sleep(HAP_INSTALL_WAIT);
380
- } else {
381
- logger.error(`安装失败: rc=${result.returncode}, output=${combined}`);
382
- process.exit(1);
383
- }
384
- }
385
-
386
- // ---- run subcommand --------------------------------------------------------
387
-
388
- export interface RunArgs {
389
- testcases: string;
390
- hap: string;
391
- bundleName: string;
392
- taskDir: string;
393
- outputDir?: string;
394
- category: string;
395
- modelStdin?: boolean;
396
- deviceSn?: string;
397
- ip?: string;
398
- port?: string;
399
- agentMode?: string;
400
- maxSteps?: string;
401
- promptVersion?: string;
402
- timeout?: number | 'auto';
403
- }
404
-
405
- // Per-case outer poll budget when `--timeout auto` is used: mirrors the
406
- // batch-launcher hard cap CASE_TIMEOUT_MS=10min + 20% margin = 720s/case. The
407
- // CLI already parses `--testcases`, so it derives the total itself instead of
408
- // making the caller compute `caseCount * 720` in a (shell-specific) wrapper.
409
- const PER_CASE_TIMEOUT_SEC = 720;
410
-
411
- export interface SpawnBatchOpts {
412
- cmd: string[];
413
- cwd: string;
414
- env: NodeJS.ProcessEnv;
415
- stdoutLog: string;
416
- pidFile: string;
417
- taskDir: string;
418
- taskSubdir: string;
419
- }
420
-
421
- /**
422
- * Spawn the batch-launcher detached and write pidFile. Resolves with the PID
423
- * on the spawn event, rejects with an Error on the spawn-error event (ENOENT,
424
- * EACCES, …). Caller is responsible for emitting JSON (RUNNING or FAILED) —
425
- * this function is silent on stdout so the same code path can serve both the
426
- * backward-compat "emit RUNNING immediately" mode and the `--timeout`
427
- * "suppress RUNNING, poll until terminal" mode. `spawnFn` is injectable for tests.
428
- */
429
- export function spawnBatch(opts: SpawnBatchOpts, spawnFn: typeof spawn = spawn): Promise<number> {
430
- return new Promise((resolve, reject) => {
431
- const { cmd, cwd, env, stdoutLog, pidFile, taskDir, taskSubdir } = opts;
432
- const logFd = fs.openSync(stdoutLog, 'w');
433
- let settled = false;
434
- const fail = (error: string): void => {
435
- if (settled) return;
436
- settled = true;
437
- try { fs.closeSync(logFd); } catch { /* ignore */ }
438
- try { fs.rmSync(pidFile, { force: true }); } catch { /* ignore */ }
439
- logger.error(`后台 batch 进程启动失败: ${error}`);
440
- reject(new Error(error));
441
- };
442
- const proc = spawnFn(cmd[0], cmd.slice(1), {
443
- cwd,
444
- stdio: ['ignore', logFd, logFd],
445
- env,
446
- detached: true,
447
- windowsHide: true,
448
- });
449
- // spawn() reports failure asynchronously via the 'error' event and success
450
- // via the 'spawn' event. proc.pid is set synchronously even for some
451
- // failures, so emitting RUNNING off the pid could hand the caller a
452
- // live-looking but dead PID. Gate both outcomes on the events (with a
453
- // `settled` latch) so exactly one of resolve/reject fires.
454
- proc.on('error', (e: Error) => fail(e.message));
455
- proc.on('spawn', () => {
456
- if (settled) return;
457
- settled = true;
458
- fs.writeFileSync(pidFile, String(proc.pid), 'utf-8');
459
- proc.unref();
460
- logger.info(`后台进程 PID=${proc.pid}`);
461
- resolve(proc.pid);
462
- });
463
- });
464
- }
465
-
466
- /**
467
- * Poll `probeStatus` every 60 seconds until a terminal status is reached
468
- * (COMPLETED / CRASHED / NOT_STARTED) or `timeoutSec` elapses. On timeout,
469
- * auto-kill the batch tree via `killProcessTree` and return a `TIMEOUT`
470
- * result with exit code 5 (new). The first probe happens immediately (no
471
- * leading sleep) so a fast-finished batch is detected without a 60s delay.
472
- *
473
- * Returns `{ json, exitCode }` — caller emits `JSON.stringify(json)` to stdout
474
- * and `process.exit(exitCode)`.
475
- *
476
- * Exit codes: COMPLETED=0, CRASHED=3, NOT_STARTED=4, TIMEOUT=5.
477
- */
478
- export function pollUntilTerminal(
479
- taskDir: string,
480
- outputDir: string,
481
- timeoutSec: number,
482
- ): { json: Record<string, unknown>; exitCode: number } {
483
- const start = Date.now();
484
-
485
- while (true) {
486
- const r = probeStatus(taskDir, outputDir);
487
-
488
- if (r.status === 'COMPLETED') {
489
- return {
490
- json: {
491
- status: 'COMPLETED',
492
- pid: r.pid,
493
- task_subdir: r.taskSubdir,
494
- cases_done: r.casesDone,
495
- pass_count: r.passCount ?? 0,
496
- fail_count: r.failCount ?? 0,
497
- unknown_count: r.unknownCount ?? 0,
498
- pass_rate: r.passRate ?? 0,
499
- log_tail: r.logTail,
500
- },
501
- exitCode: 0,
502
- };
503
- }
504
-
505
- if (r.status === 'CRASHED') {
506
- return {
507
- json: {
508
- status: 'CRASHED',
509
- pid: r.pid,
510
- task_subdir: r.taskSubdir,
511
- cases_done: r.casesDone,
512
- last_case: r.lastCase,
513
- log_tail: r.logTail,
514
- },
515
- exitCode: 3,
516
- };
517
- }
518
-
519
- if (r.status === 'NOT_STARTED') {
520
- return {
521
- json: { status: 'NOT_STARTED' },
522
- exitCode: 4,
523
- };
524
- }
525
-
526
- // r.status === 'RUNNING' — check timeout before sleeping
527
- const elapsed = (Date.now() - start) / 1000;
528
- if (elapsed >= timeoutSec) {
529
- // Auto-kill the batch tree, then return TIMEOUT.
530
- if (r.pid != null) {
531
- try {
532
- killProcessTree(r.pid);
533
- sleep(2);
534
- } catch {
535
- // best effort
536
- }
537
- }
538
- return {
539
- json: {
540
- status: 'TIMEOUT',
541
- pid: r.pid,
542
- task_subdir: r.taskSubdir,
543
- cases_done: r.casesDone,
544
- last_case: r.lastCase,
545
- log_tail: r.logTail,
546
- },
547
- exitCode: 5,
548
- };
549
- }
550
-
551
- // Still RUNNING and within budget — sleep 60s then probe again.
552
- sleep(60);
553
- }
554
- }
555
-
556
- /** `run` subcommand: prepare environment, then spawn the batch-launcher detached. */
557
- export async function cmdRun(args: RunArgs): Promise<void> {
558
- let taskDir: string;
559
- let outputDir: string;
560
- let taskSubdir: string;
561
- let pidFile: string;
562
- let stdoutLog: string;
563
- let batchEnv: NodeJS.ProcessEnv;
564
- let cmd: string[];
565
- let effectiveTimeout: number | undefined;
566
- try {
567
- taskDir = path.resolve(args.taskDir);
568
- fs.mkdirSync(taskDir, { recursive: true });
569
- outputDir = args.outputDir ? path.resolve(args.outputDir) : taskDir;
570
- fs.mkdirSync(outputDir, { recursive: true });
571
-
572
- setupLogging(outputDir);
573
- logger.info(`Node: ${process.execPath}`);
574
- logger.info(
575
- `参数: testcases=${args.testcases}, hap=${args.hap}, bundle=${args.bundleName}, ` +
576
- `task_dir=${taskDir}, category=${args.category}, model_stdin=${args.modelStdin ? 'yes' : 'no'}` +
577
- (args.deviceSn ? `, device_sn=${args.deviceSn}` : '') +
578
- (args.agentMode ? `, agent_mode=${args.agentMode}` : '') +
579
- (args.timeout === 'auto' ? `, timeout=auto` : args.timeout ? `, timeout=${args.timeout}s` : ''),
580
- );
581
-
582
- // Per-run task subdir; status subcommand globs `task_*` to find the latest.
583
- taskSubdir = path.join(taskDir, `task_${timestampForFile(new Date())}`);
584
- fs.mkdirSync(taskSubdir, { recursive: true });
585
-
586
- pidFile = path.join(taskDir, 'batch.pid');
587
- stdoutLog = path.join(taskDir, 'batch_stdout.log');
588
-
589
- // Kill any previous run's batch still recorded alive in the pid file.
590
- if (fs.existsSync(pidFile)) {
591
- try {
592
- const oldPid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
593
- if (Number.isFinite(oldPid) && pidAlive(oldPid)) {
594
- logger.warning(`杀掉上次残留进程 PID=${oldPid}`);
595
- killProcessTree(oldPid);
596
- sleep(2);
597
- }
598
- } catch {
599
- // best effort
600
- }
601
- }
602
-
603
- // a2h: model params come from stdin (--model-stdin, piped by the plugin
604
- // tool which resolved them via deps.resolveModelParams). No
605
- // ~/.hometrans/config.json and no HOMETRANS_MODEL_* env fallback.
606
- if (!args.modelStdin) {
607
- throw new Error(
608
- 'a2h selftest run requires --model-stdin: the plugin tool pipes ' +
609
- '{apiKey, modelName, baseURL} via stdin. No config-file fallback.',
610
- );
611
- }
612
- const model = readModelFromStdin();
613
- logger.info('已从 stdin 读取模型参数(apiKey/model/baseURL 将通过 new AutoTestAgent 构造器传入)');
614
- const devOpts: DeviceOpts = {
615
- deviceSn: args.deviceSn,
616
- ip: args.ip,
617
- port: args.port,
618
- agentMode: args.agentMode,
619
- maxSteps: args.maxSteps,
620
- promptVersion: args.promptVersion,
621
- };
622
-
623
- const hdc = getHdc();
624
-
625
- logger.info(`=== Step 2a: uninstall ${args.bundleName} ===`);
626
- runCmd([hdc, 'uninstall', args.bundleName], { timeout: INSTALL_TIMEOUT, check: false });
627
-
628
- logger.info(`=== Step 2b: install ${args.hap} ===`);
629
- installHaps(args.hap, hdc);
630
-
631
- const testcasesJsonl = prepareTestcasesJsonl(path.resolve(args.testcases), taskSubdir);
632
-
633
- // Resolve `--timeout auto` now that the testcases are parsed: total budget =
634
- // caseCount × 720s (min 1 case). A numeric --timeout passes through; omitted
635
- // stays undefined → backward-compat immediate-RUNNING mode below.
636
- if (args.timeout === 'auto') {
637
- const caseCount = fs.readFileSync(testcasesJsonl, 'utf-8').split(/\r?\n/).filter((l) => l.trim()).length;
638
- effectiveTimeout = Math.max(caseCount, 1) * PER_CASE_TIMEOUT_SEC;
639
- logger.info(`--timeout auto: ${caseCount} 条用例 × ${PER_CASE_TIMEOUT_SEC}s = ${effectiveTimeout}s`);
640
- } else {
641
- effectiveTimeout = args.timeout;
642
- }
643
-
644
- // Node engine: `node <batch-launcher.js>` constructs `new AutoTestAgent(...)`
645
- // from AUTOTEST_* env and runs the testcases batch in-process. cwd=taskSubdir
646
- // keeps the per-case report dirs and task_results.jsonl inside the subdir.
647
- cmd = [
648
- process.execPath, batchLauncherPath(),
649
- '--task-dir', taskSubdir,
650
- '--testcases', testcasesJsonl,
651
- '--category', args.category,
652
- ];
653
- batchEnv = agentEnvFromModel(model, devOpts, process.env);
654
- } catch (e) {
655
- const error = e instanceof Error ? e.message : String(e);
656
- logger.error(`异常:\n${e instanceof Error ? e.stack ?? error : String(e)}`);
657
- console.log(JSON.stringify({ status: 'FAILED', error }));
658
- process.exit(1);
659
- }
660
-
661
- logger.info('=== Step 3: 后台启动 AutoTest (Node @autotest/agent, new AutoTestAgent) ===');
662
-
663
- // No --timeout: backward-compat mode — emit RUNNING JSON immediately on successful spawn.
664
- try {
665
- // ponytail: the non-null assertions are safe because the prep try/catch above
666
- // either assigned all these locals or process.exit(1) — no path reaches here
667
- // with an unassigned variable. If the catch ever stops exiting, drop the `!`.
668
- const pid = await spawnBatch({ cmd: cmd!, cwd: taskSubdir!, env: batchEnv!, stdoutLog, pidFile, taskDir: taskDir!, taskSubdir: taskSubdir! });
669
- if (effectiveTimeout) {
670
- const r = pollUntilTerminal(taskDir!, outputDir!, effectiveTimeout);
671
- console.log(JSON.stringify(r.json));
672
- process.exit(r.exitCode);
673
- } else {
674
- console.log(
675
- JSON.stringify({
676
- status: 'RUNNING',
677
- pid,
678
- pid_file: pidFile,
679
- stdout_log: stdoutLog,
680
- task_dir: taskDir,
681
- task_subdir: taskSubdir,
682
- }),
683
- );
684
- }
685
- } catch (e) {
686
- const error = e instanceof Error ? e.message : String(e);
687
- console.log(JSON.stringify({ status: 'FAILED', error }));
688
- process.exit(1);
689
- }
690
- }
691
-
692
- export interface ProbeResult {
693
- status: 'NOT_STARTED' | 'COMPLETED' | 'RUNNING' | 'CRASHED';
694
- pid: number | null;
695
- taskSubdir: string | null;
696
- casesDone: number;
697
- lastCase: string;
698
- logTail: string;
699
- // COMPLETED-only (undefined for other statuses):
700
- passCount?: number;
701
- failCount?: number;
702
- unknownCount?: number;
703
- passRate?: string | number;
704
- }
705
-
706
- /**
707
- * Read batch state from on-disk artifacts (no JSON emission, no exit).
708
- * Same logic as `cmdStatus` but returns a structured `ProbeResult` so callers
709
- * (`cmdStatus` for one-shot, `pollUntilTerminal` for the `run --timeout` loop)
710
- * can share one source of truth.
711
- */
712
- export function probeStatus(taskDir: string, outputDir: string): ProbeResult {
713
- const pidFile = path.join(taskDir, 'batch.pid');
714
- if (!fs.existsSync(pidFile)) {
715
- return {
716
- status: 'NOT_STARTED',
717
- pid: null,
718
- taskSubdir: null,
719
- casesDone: 0,
720
- lastCase: '',
721
- logTail: '',
722
- };
723
- }
724
-
725
- const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
726
- const taskSubdir = findTaskSubdir(taskDir);
727
-
728
- let casesDone = 0;
729
- let lastCase = '';
730
- const jsonlFile = taskSubdir ? path.join(taskSubdir, 'task_results.jsonl') : null;
731
- if (jsonlFile && fs.existsSync(jsonlFile)) {
732
- const text = fs.readFileSync(jsonlFile, 'utf-8').trim();
733
- const lines = text ? text.split(/\r?\n/) : [];
734
- casesDone = lines.length;
735
- if (lines.length) {
736
- try {
737
- const last = JSON.parse(lines[lines.length - 1]) as { case_name?: string };
738
- if (typeof last.case_name === 'string') lastCase = last.case_name;
739
- } catch {
740
- // ignore
741
- }
742
- }
743
- }
744
-
745
- let logTail = '';
746
- const logFile = findLatestLog(outputDir);
747
- if (logFile && fs.existsSync(logFile)) {
748
- const text = fs.readFileSync(logFile, 'utf-8').trim();
749
- logTail = (text ? text.split(/\r?\n/) : []).slice(-5).join('\n');
750
- }
751
-
752
- const summaryFile = taskSubdir ? path.join(taskSubdir, 'summary.json') : null;
753
- if (summaryFile && fs.existsSync(summaryFile)) {
754
- let summary: Record<string, unknown> = {};
755
- try {
756
- summary = JSON.parse(fs.readFileSync(summaryFile, 'utf-8'));
757
- } catch {
758
- // ignore
759
- }
760
- const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback);
761
- // ponytail: cast `pass_rate` (unknown from JSON) to the documented schema
762
- // (string like "30.00%" or absent). `num(...)` would zero a string rate, so
763
- // we keep the `?? 0` fallback verbatim from the original cmdStatus.
764
- return {
765
- status: 'COMPLETED',
766
- pid,
767
- taskSubdir,
768
- casesDone: num(summary.total_cases, casesDone),
769
- lastCase,
770
- passCount: num(summary.pass_count, num(summary.success_count, 0)),
771
- failCount: num(summary.fail_count, 0),
772
- unknownCount: num(summary.unknown_count, 0),
773
- passRate: (summary.pass_rate as string | number | undefined) ?? 0,
774
- logTail,
775
- };
776
- }
777
-
778
- const alive = pidAlive(pid);
779
- return {
780
- status: alive ? 'RUNNING' : 'CRASHED',
781
- pid,
782
- taskSubdir,
783
- casesDone,
784
- lastCase,
785
- logTail,
786
- };
787
- }
788
-
789
- // ---- status subcommand -----------------------------------------------------
790
-
791
- export interface StatusArgs {
792
- taskDir: string;
793
- outputDir?: string;
794
- }
795
-
796
- /** Latest `task_*` subdir in `taskDir` (name-sorted desc), or null. */
797
- export function findTaskSubdir(taskDir: string): string | null {
798
- if (!fs.existsSync(taskDir)) return null;
799
- let entries: fs.Dirent[];
800
- try {
801
- entries = fs.readdirSync(taskDir, { withFileTypes: true });
802
- } catch {
803
- return null;
804
- }
805
- const candidates = entries
806
- .filter((e) => e.isDirectory() && e.name.startsWith('task_'))
807
- .map((e) => e.name)
808
- .sort()
809
- .reverse();
810
- return candidates.length ? path.join(taskDir, candidates[0]) : null;
811
- }
812
-
813
- /** Latest `self_test_*.log` (desc), falling back to `batch_stdout.log`, or null. */
814
- export function findLatestLog(outputDir: string): string | null {
815
- if (!fs.existsSync(outputDir)) return null;
816
- let entries: fs.Dirent[];
817
- try {
818
- entries = fs.readdirSync(outputDir, { withFileTypes: true });
819
- } catch {
820
- return null;
821
- }
822
- const selfTest = entries
823
- .filter((e) => e.isFile() && e.name.startsWith('self_test_') && e.name.endsWith('.log'))
824
- .map((e) => e.name)
825
- .sort()
826
- .reverse();
827
- const batchLog = entries
828
- .filter((e) => e.isFile() && e.name === 'batch_stdout.log')
829
- .map((e) => e.name);
830
- const ordered = [...selfTest, ...batchLog];
831
- return ordered.length ? path.join(outputDir, ordered[0]) : null;
832
- }
833
-
834
- /**
835
- * `status` subcommand: report state from output files.
836
- * summary.json present → COMPLETED (exit 0)
837
- * else pid alive → RUNNING (exit 2)
838
- * else → CRASHED (exit 3)
839
- * No pid file at all → NOT_STARTED (exit 4).
840
- */
841
- export function cmdStatus(args: StatusArgs): void {
842
- const taskDir = path.resolve(args.taskDir);
843
- const outputDir = args.outputDir ? path.resolve(args.outputDir) : taskDir;
844
-
845
- const r = probeStatus(taskDir, outputDir);
846
-
847
- if (r.status === 'NOT_STARTED') {
848
- console.log(JSON.stringify({ status: 'NOT_STARTED' }));
849
- process.exit(4);
850
- }
851
-
852
- if (r.status === 'COMPLETED') {
853
- console.log(
854
- JSON.stringify({
855
- status: 'COMPLETED',
856
- pid: r.pid,
857
- task_subdir: r.taskSubdir,
858
- cases_done: r.casesDone,
859
- pass_count: r.passCount ?? 0,
860
- fail_count: r.failCount ?? 0,
861
- unknown_count: r.unknownCount ?? 0,
862
- pass_rate: r.passRate ?? 0,
863
- log_tail: r.logTail,
864
- }),
865
- );
866
- process.exit(0);
867
- }
868
-
869
- // RUNNING or CRASHED
870
- console.log(
871
- JSON.stringify({
872
- status: r.status,
873
- pid: r.pid,
874
- cases_done: r.casesDone,
875
- last_case: r.lastCase,
876
- task_subdir: r.taskSubdir,
877
- log_tail: r.logTail,
878
- }),
879
- );
880
- process.exit(r.status === 'RUNNING' ? 2 : 3);
881
- }
882
-
883
- // ---- kill subcommand -------------------------------------------------------
884
-
885
- export interface KillArgs {
886
- taskDir: string;
887
- }
888
-
889
- /** `kill` subcommand: terminate the batch-launcher background process by pid. */
890
- export function cmdKill(args: KillArgs): void {
891
- const taskDir = path.resolve(args.taskDir);
892
- const pidFile = path.join(taskDir, 'batch.pid');
893
- if (!fs.existsSync(pidFile)) {
894
- console.log(JSON.stringify({ status: 'NO_PID_FILE', killed: false }));
895
- return;
896
- }
897
- let pid: number;
898
- try {
899
- pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
900
- } catch {
901
- console.log(JSON.stringify({ status: 'NO_PID_FILE', killed: false }));
902
- return;
903
- }
904
- if (!pidAlive(pid)) {
905
- console.log(JSON.stringify({ status: 'ALREADY_DEAD', pid, killed: false }));
906
- return;
907
- }
908
- killProcessTree(pid);
909
- console.log(JSON.stringify({ status: 'KILLED', pid, killed: true }));
910
- }
911
-
912
- // ---- CLI entry -------------------------------------------------------------
913
-
914
- /** Wire up `run` / `status` / `kill` subcommands via commander. */
915
- export function main(): void {
916
- const program = new Command();
917
- program.name('self-test-runner').description('自测执行器 - 统一的进程管理和测试执行');
918
-
919
- program
920
- .command('run')
921
- .description('准备环境并执行用例')
922
- .requiredOption('--testcases <path>', '测试用例文件,JSON 数组或 JSONL')
923
- .requiredOption('--hap <path>', '签名包:单个 .hap/.hsp 文件、目录,或逗号分隔的多个文件/目录')
924
- .requiredOption('--bundle-name <name>', '包名')
925
- .option('--task-dir <dir>', '任务目录(内部会创建 task_<timestamp> 子目录)', 'task')
926
- .option('--output-dir <dir>', '日志目录')
927
- .option('--category <name>', '测试分类名称,传给 batch-launcher', DEFAULT_CATEGORY)
928
- .option('--model-stdin', '从 stdin 读取 {apiKey, modelName, baseURL}(a2h 插件工具注入;无 config 回退)')
929
- .option('--device-sn <sn>', '设备序列号(传给 AutoTestAgent.deviceSn)')
930
- .option('--ip <ip>', '设备 IP(传给 AutoTestAgent.ip)')
931
- .option('--port <port>', '设备端口(传给 AutoTestAgent.port)')
932
- .option('--agent-mode <mode>', 'agent 模式(默认 single)', 'single')
933
- .option('--max-steps <n>', '单用例最大步数')
934
- .option('--prompt-version <ver>', 'prompt 版本(v1.0/v2.0/v3.0,默认 v2.0)', 'v2.0')
935
- .option('--timeout <sec>', '阻塞直到终态或超时(秒),或 `auto` = 按用例数 × 720s 自动推导。不传则立即返回 RUNNING JSON。超时后自动 kill batch 并返回 TIMEOUT JSON(exit 5)', v => {
936
- if (v === 'auto') return 'auto' as const;
937
- const n = Number(v);
938
- if (!Number.isInteger(n) || n <= 0) {
939
- throw new Error(`--timeout 必须是正整数或 'auto',收到: ${v}`);
940
- }
941
- return n;
942
- })
943
- .action(async (opts: RunArgs) => {
944
- await cmdRun({
945
- testcases: opts.testcases,
946
- hap: opts.hap,
947
- bundleName: opts.bundleName,
948
- taskDir: opts.taskDir,
949
- outputDir: opts.outputDir,
950
- category: opts.category,
951
- modelStdin: opts.modelStdin,
952
- deviceSn: opts.deviceSn,
953
- ip: opts.ip,
954
- port: opts.port,
955
- agentMode: opts.agentMode,
956
- maxSteps: opts.maxSteps,
957
- promptVersion: opts.promptVersion,
958
- timeout: opts.timeout,
959
- });
960
- });
961
-
962
- program
963
- .command('status')
964
- .description('查询执行状态')
965
- .requiredOption('--task-dir <dir>')
966
- .option('--output-dir <dir>')
967
- .action((opts: StatusArgs) => {
968
- cmdStatus({ taskDir: opts.taskDir, outputDir: opts.outputDir });
969
- });
970
-
971
- program
972
- .command('kill')
973
- .description('终止进程')
974
- .requiredOption('--task-dir <dir>')
975
- .action((opts: KillArgs) => {
976
- cmdKill({ taskDir: opts.taskDir });
977
- });
978
-
979
- // ---- Validation gates (replace the self-tester.md `uv run python -c` lines) --
980
- // Each prints its message to stdout on pass, or stderr + exit(1) on fail —
981
- // matching the prompt's `… && echo OK` expectation. These are NOT part of the
982
- // run/status/kill stdout-JSON contract; they are plain pass/fail checks.
983
- const gate = (fn: () => CheckResult): void => {
984
- const r = fn();
985
- if (r.ok) {
986
- console.log(r.message);
987
- } else {
988
- console.error(r.message);
989
- process.exit(1);
990
- }
991
- };
992
-
993
- program
994
- .command('check-hap')
995
- .description('校验 hap_path:各项存在且至少含一个入口 .hap')
996
- .requiredOption('--hap <path>', '签名包:文件、目录,或逗号分隔的多个文件/目录')
997
- .action((opts: { hap: string }) => gate(() => checkHap(opts.hap)));
998
-
999
- program
1000
- .command('check-inputs')
1001
- .description('校验 testcases.json(非空数组)与 app-metadata.json(必需字段)可解析')
1002
- .requiredOption('--testcases <path>')
1003
- .requiredOption('--metadata <path>')
1004
- .action((opts: { testcases: string; metadata: string }) =>
1005
- gate(() => checkInputs(opts.testcases, opts.metadata)),
1006
- );
1007
-
1008
- program
1009
- .command('check-pre')
1010
- .description('校验 testcases.json 中 [PRE] 用例从索引 0 起连续')
1011
- .requiredOption('--testcases <path>')
1012
- .action((opts: { testcases: string }) => gate(() => checkPre(opts.testcases)));
1013
-
1014
- program.parseAsync(process.argv).catch((err) => {
1015
- console.error(err instanceof Error ? err.message : err);
1016
- process.exit(1);
1017
- });
1018
- }
1019
-
1020
- const __filename = fileURLToPath(import.meta.url);
1021
- const isMainEntry = !!process.argv[1] && path.resolve(process.argv[1]) === __filename;
1022
- if (isMainEntry) {
1023
- main();
1024
- }