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.
- package/README.md +1 -407
- package/agents/self-tester.md +376 -339
- package/dist/index.js +131 -586
- package/dist/index.js.map +4 -4
- package/package.json +36 -37
- package/skills/a2h-resource-convert/SKILL.md +902 -0
- package/skills/a2h-resource-convert/references/code-vector-icon-rules.md +335 -0
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/conversion-rules.md +15 -2
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/dependency-analysis-rules.md +24 -2
- package/skills/a2h-resource-convert/references/lottie-conversion-rules.md +219 -0
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/resource-mapping-rules.md +27 -2
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/xml-drawable-to-svg-rules.md +118 -1
- package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +2166 -0
- package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
- package/skills/a2h-resource-convert/scripts/package.json +3 -0
- package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
- package/skills/a2h-ui-transfer/SKILL.md +420 -0
- package/skills/a2h-ui-transfer/references/conversion-procedure.md +572 -0
- 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
- 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
- package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +397 -283
- package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
- package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
- package/skills/a2h-ui-transfer/scripts/package.json +3 -0
- package/skills/hmos-incremental-ui-align/README.md +251 -251
- package/skills/hmos-incremental-ui-align/SKILL.md +364 -364
- package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
- package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
- package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
- package/skills/hmos-batch-ui-align/SKILL.md +0 -141
- package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
- package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
- package/skills/hmos-resources-convert/SKILL.md +0 -654
- package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
- package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
- package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
- package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
- package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
- package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
- package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
- package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
- package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
- package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
- package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
- package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
- package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
- package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
- package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
- package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
- package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
- package/tools/autotest/deps/autotest-agent-0.1.1.tgz +0 -0
- package/tools/autotest/engine/batch-launcher.ts +0 -326
- package/tools/autotest/engine/report-tool.ts +0 -773
- package/tools/autotest/engine/self-test-runner.ts +0 -1024
- package/tools/autotest/engine/testcases-tool.ts +0 -246
- package/tools/autotest/resolve-metadata-tool.ts +0 -143
- package/tools/autotest/runner/logger.ts +0 -45
- package/tools/autotest/runner/process-utils.ts +0 -41
- package/tools/autotest/validate.ts +0 -115
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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
- /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,773 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generate or validate self-test-report.md — TS port of report_tool.py.
|
|
3
|
-
*
|
|
4
|
-
* generate --task-subdir ... --app-metadata ... --hap ... --device ... \
|
|
5
|
-
* --suite ... --out ... [--validate]
|
|
6
|
-
* Render `self-test-report.md` from a task_<ts>/ directory, optionally
|
|
7
|
-
* re-validating the output in-process.
|
|
8
|
-
* validate <report-path>
|
|
9
|
-
* Check an existing report conforms to the required layout.
|
|
10
|
-
* Exit 0 = PASSED, exit 1 = FAILED.
|
|
11
|
-
*
|
|
12
|
-
* The emitted markdown is a FROZEN contract parsed by self-tester.md, the
|
|
13
|
-
* hmos-integration-test skill, and validateReport below — template strings are
|
|
14
|
-
* verbatim and must not drift.
|
|
15
|
-
*/
|
|
16
|
-
import fs from 'node:fs';
|
|
17
|
-
import path from 'node:path';
|
|
18
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
-
import { Command } from 'commander';
|
|
20
|
-
|
|
21
|
-
// Inlined from src/utils/regex.ts so this file is self-contained — no hop into src/.
|
|
22
|
-
function escapeRegExp(s: string): string {
|
|
23
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const PRE_PREFIX = '[PRE] ';
|
|
27
|
-
|
|
28
|
-
export interface Case {
|
|
29
|
-
case_name: string;
|
|
30
|
-
status: string;
|
|
31
|
-
duration_seconds?: number;
|
|
32
|
-
report_dir?: string;
|
|
33
|
-
test_steps?: string;
|
|
34
|
-
reason?: string;
|
|
35
|
-
_judgment?: Record<string, string>;
|
|
36
|
-
_last_thinking?: string;
|
|
37
|
-
[k: string]: unknown;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface Totals {
|
|
41
|
-
preTotal: number;
|
|
42
|
-
regularTotal: number;
|
|
43
|
-
prePass: number;
|
|
44
|
-
regularPass: number;
|
|
45
|
-
pass: number;
|
|
46
|
-
fail: number;
|
|
47
|
-
unknown: number;
|
|
48
|
-
passRate: string;
|
|
49
|
-
regularPassRate: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Name with the `[PRE] ` prefix stripped (idempotent for non-pre names). */
|
|
53
|
-
function stripPre(name: string): string {
|
|
54
|
-
return name.startsWith(PRE_PREFIX) ? name.slice(PRE_PREFIX.length) : name;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// =============================================================================
|
|
58
|
-
// Parsers
|
|
59
|
-
// =============================================================================
|
|
60
|
-
|
|
61
|
-
/** Parse <judgment>...</judgment> from a task_end event. Returns header→body map. */
|
|
62
|
-
export function parseJudgmentBlock(content: string): Record<string, string> {
|
|
63
|
-
const m = content.match(/<judgment>([\s\S]*?)<\/judgment>/s);
|
|
64
|
-
if (!m) return {};
|
|
65
|
-
|
|
66
|
-
const parts = m[1].trim().split(/\*\*([一-鿿A-Za-z]+)\*\*\s*[::]\s*/);
|
|
67
|
-
const sections: Record<string, string> = {};
|
|
68
|
-
for (let i = 1; i < parts.length; i += 2) {
|
|
69
|
-
sections[parts[i].trim()] = (i + 1 < parts.length ? parts[i + 1] : '').trim();
|
|
70
|
-
}
|
|
71
|
-
return sections;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Parse `动作:... -> ...\n预期结果:...` into (action_list, expected_text). */
|
|
75
|
-
export function parseTestSteps(testSteps: string): [string[], string] {
|
|
76
|
-
const actionsMatch = testSteps.match(/动作[::]\s*(.+?)(?=\n\s*预期结果[::]|$)/s);
|
|
77
|
-
const expectedMatch = testSteps.match(/预期结果[::]\s*(.+)/s);
|
|
78
|
-
|
|
79
|
-
const actionList = actionsMatch
|
|
80
|
-
? actionsMatch[1]
|
|
81
|
-
.trim()
|
|
82
|
-
.split(/\s*->\s*|\s*→\s*/)
|
|
83
|
-
.map(s => s.trim())
|
|
84
|
-
.filter(Boolean)
|
|
85
|
-
: testSteps.trim()
|
|
86
|
-
? [testSteps.trim()]
|
|
87
|
-
: [];
|
|
88
|
-
|
|
89
|
-
return [actionList, expectedMatch ? expectedMatch[1].trim() : ''];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Locate <report_dir>/<YYYY-MM-DD>/<HH-MM-SS>/<HH-MM-SS>.json (the structured run log). */
|
|
93
|
-
export function findLatestTaskJson(reportDir: string): string | null {
|
|
94
|
-
if (!fs.existsSync(reportDir)) return null;
|
|
95
|
-
|
|
96
|
-
const datePat = /^\d{4}-\d{2}-\d{2}$/;
|
|
97
|
-
const timePat = /^\d{2}-\d{2}-\d{2}$/;
|
|
98
|
-
|
|
99
|
-
// Python let iterdir() errors propagate; we stay robust (return null) but
|
|
100
|
-
// surface the cause on stderr so real FS problems aren't silently hidden.
|
|
101
|
-
const subdirs = (dir: string, pat: RegExp): string[] | null => {
|
|
102
|
-
try {
|
|
103
|
-
return fs
|
|
104
|
-
.readdirSync(dir)
|
|
105
|
-
.filter(n => pat.test(n) && fs.statSync(path.join(dir, n)).isDirectory())
|
|
106
|
-
.sort();
|
|
107
|
-
} catch (e) {
|
|
108
|
-
console.error(`WARN: cannot read dir ${dir}: ${e instanceof Error ? e.message : e}`);
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const dateDirs = subdirs(reportDir, datePat);
|
|
114
|
-
if (!dateDirs || dateDirs.length === 0) return null;
|
|
115
|
-
|
|
116
|
-
for (let di = dateDirs.length - 1; di >= 0; di--) {
|
|
117
|
-
const dateDirPath = path.join(reportDir, dateDirs[di]);
|
|
118
|
-
const timeDirs = subdirs(dateDirPath, timePat);
|
|
119
|
-
if (!timeDirs) continue;
|
|
120
|
-
for (let ti = timeDirs.length - 1; ti >= 0; ti--) {
|
|
121
|
-
const candidate = path.join(dateDirPath, timeDirs[ti], `${timeDirs[ti]}.json`);
|
|
122
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
interface Event {
|
|
129
|
-
event_type?: unknown;
|
|
130
|
-
content?: unknown;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Return content of the last event with the given type, or ''. */
|
|
134
|
-
function lastEventContent(events: Event[], type: string): string {
|
|
135
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
136
|
-
if (events[i]?.event_type === type) return String(events[i].content ?? '').trim();
|
|
137
|
-
}
|
|
138
|
-
return '';
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Load the judgment block + last thinking for one case. Returns [judgment, lastThinking]. */
|
|
142
|
-
export function loadCaseJudgment(reportDir: string): [Record<string, string>, string] {
|
|
143
|
-
const jsonPath = findLatestTaskJson(reportDir);
|
|
144
|
-
if (jsonPath === null) return [{}, ''];
|
|
145
|
-
|
|
146
|
-
let data: unknown;
|
|
147
|
-
try {
|
|
148
|
-
data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
149
|
-
} catch {
|
|
150
|
-
return [{}, ''];
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const raw = (data as { events?: unknown })?.events;
|
|
154
|
-
const events: Event[] = Array.isArray(raw) ? (raw as Event[]) : [];
|
|
155
|
-
if (events.length === 0) return [{}, ''];
|
|
156
|
-
|
|
157
|
-
const taskEnd = lastEventContent(events, 'task_end');
|
|
158
|
-
return [taskEnd ? parseJudgmentBlock(taskEnd) : {}, lastEventContent(events, 'planner_thinking')];
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// =============================================================================
|
|
162
|
-
// Render helpers
|
|
163
|
-
// =============================================================================
|
|
164
|
-
|
|
165
|
-
/** Compact a multi-line judgment value into a single line. */
|
|
166
|
-
function oneLine(s: string): string {
|
|
167
|
-
return s.replace(/\s+/g, ' ').trim();
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Truncate `s` to `max` chars, appending an ellipsis when cut. */
|
|
171
|
-
function truncate(s: string, max: number): string {
|
|
172
|
-
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Render `- key: value` under 'AutoTest 详情' (single-line, else nested lines). */
|
|
176
|
-
function emitJudgmentBullet(key: string, value: string): string[] {
|
|
177
|
-
const text = (value ?? '').trim();
|
|
178
|
-
const rawLines = text.split(/\r\n|\r|\n/).filter(ln => ln.trim());
|
|
179
|
-
if (rawLines.length <= 1) {
|
|
180
|
-
return [` - ${key}: ${oneLine(text) || '(空)'}`];
|
|
181
|
-
}
|
|
182
|
-
return [` - ${key}:`, ...rawLines.map(ln => ` ${ln.replace(/\s+$/, '')}`)];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// =============================================================================
|
|
186
|
-
// Renderers
|
|
187
|
-
// =============================================================================
|
|
188
|
-
|
|
189
|
-
/** Render the `## 测试概览` block. */
|
|
190
|
-
function renderOverview(
|
|
191
|
-
suite: string,
|
|
192
|
-
timeRange: string,
|
|
193
|
-
device: string,
|
|
194
|
-
appName: string,
|
|
195
|
-
bundleName: string,
|
|
196
|
-
hap: string,
|
|
197
|
-
totals: Totals,
|
|
198
|
-
): string {
|
|
199
|
-
const total = totals.preTotal + totals.regularTotal;
|
|
200
|
-
const notPassed = totals.fail + totals.unknown;
|
|
201
|
-
|
|
202
|
-
return [
|
|
203
|
-
'## 测试概览',
|
|
204
|
-
'',
|
|
205
|
-
`- **测试套件**: ${suite}`,
|
|
206
|
-
`- **测试时间**: ${timeRange}`,
|
|
207
|
-
`- **设备**: ${device}`,
|
|
208
|
-
`- **应用**: ${appName} (${bundleName})`,
|
|
209
|
-
`- **HAP**: ${hap}`,
|
|
210
|
-
`- **总用例数**: ${total}(前置 ${totals.preTotal} + 常规 ${totals.regularTotal})`,
|
|
211
|
-
`- **通过**: ${totals.pass}(前置 ${totals.prePass} / 常规 ${totals.regularPass})`,
|
|
212
|
-
`- **失败**: ${notPassed}(FAIL ${totals.fail} + UNKNOWN ${totals.unknown})`,
|
|
213
|
-
`- **常规通过率**: ${totals.regularPassRate}(仅功能场景,反映本次需求质量)`,
|
|
214
|
-
`- **含前置通过率**: ${totals.passRate}(仅供整体参考;前置用例属于数据/环境准备,与本次需求功能无关)`,
|
|
215
|
-
'',
|
|
216
|
-
].join('\n');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Derive the `操作执行` line from status + judgment. */
|
|
220
|
-
function opExecLine(status: string, judgment: Record<string, string>, reason: string): string {
|
|
221
|
-
if (status === 'PASS') {
|
|
222
|
-
const expected = (judgment['预期验证'] || '').trim();
|
|
223
|
-
return expected ? `成功 — ${expected}` : '成功 — AutoTest 判定通过';
|
|
224
|
-
}
|
|
225
|
-
if (status === 'FAIL') {
|
|
226
|
-
const detail = (judgment['Bug发现'] || '').trim() || (judgment['状态确认'] || '').trim();
|
|
227
|
-
if (detail) return `失败 — ${(detail.split(/\r\n|\r|\n/)[0] ?? '').slice(0, 200)}`;
|
|
228
|
-
return `失败 — ${reason || 'AutoTest 判定不通过'}`;
|
|
229
|
-
}
|
|
230
|
-
return `未完成 — ${reason || 'AutoTest 未能给出确定结论'}`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** Render a single Pre N / Case N block. */
|
|
234
|
-
export function renderCaseBlock(idx: number, prefix: string, case_: Case): string {
|
|
235
|
-
const title = prefix === 'Pre' ? stripPre(case_.case_name) : case_.case_name;
|
|
236
|
-
const status = case_.status;
|
|
237
|
-
const duration = case_.duration_seconds ?? 0;
|
|
238
|
-
const reportDir = case_.report_dir ?? '';
|
|
239
|
-
const reason = (case_.reason || '').trim();
|
|
240
|
-
const judgment = case_._judgment || {};
|
|
241
|
-
const lastThinking = case_._last_thinking || '';
|
|
242
|
-
|
|
243
|
-
const [actions, expected] = parseTestSteps(case_.test_steps ?? '');
|
|
244
|
-
|
|
245
|
-
const lines: string[] = [`### ${prefix} ${idx}: ${title}`, ''];
|
|
246
|
-
lines.push(`- **执行结果**: ${status}`);
|
|
247
|
-
lines.push('- **操作步骤**:');
|
|
248
|
-
if (actions.length > 0) {
|
|
249
|
-
actions.forEach((a, i) => lines.push(` ${i + 1}. ${a}`));
|
|
250
|
-
} else {
|
|
251
|
-
lines.push(' 1. (未提供动作步骤)');
|
|
252
|
-
}
|
|
253
|
-
lines.push(`- **期望结果**: ${expected || '(未提供期望结果)'}`);
|
|
254
|
-
lines.push(`- **操作执行**: ${opExecLine(status, judgment, reason)}`);
|
|
255
|
-
lines.push(`- **AutoTest 任务路径**: \`${reportDir}\``);
|
|
256
|
-
lines.push('- **AutoTest 详情**:');
|
|
257
|
-
lines.push(` - AutoTest 结果: ${status}`);
|
|
258
|
-
|
|
259
|
-
if (Object.keys(judgment).length > 0) {
|
|
260
|
-
// 失败原因 is the downstream-facing alias of Bug发现 — emit it (not Bug发现) so
|
|
261
|
-
// the validator's literal check passes without duplication.
|
|
262
|
-
const seen = new Set<string>(['Bug发现']);
|
|
263
|
-
for (const key of ['预期验证', '历史回顾', '状态确认', '判定结果']) {
|
|
264
|
-
if (key in judgment) {
|
|
265
|
-
lines.push(...emitJudgmentBullet(key, judgment[key]));
|
|
266
|
-
seen.add(key);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
for (const [key, value] of Object.entries(judgment)) {
|
|
270
|
-
if (!seen.has(key)) lines.push(...emitJudgmentBullet(key, value));
|
|
271
|
-
}
|
|
272
|
-
if (status === 'PASS') {
|
|
273
|
-
lines.push(' - 失败原因: 无');
|
|
274
|
-
} else {
|
|
275
|
-
lines.push(...emitJudgmentBullet('失败原因', judgment['Bug发现'] || reason || '未知'));
|
|
276
|
-
}
|
|
277
|
-
} else {
|
|
278
|
-
lines.push(` - 失败原因: ${reason || '未知'}`);
|
|
279
|
-
if (lastThinking) {
|
|
280
|
-
lines.push(` - 末态思考摘要: ${truncate(lastThinking.replace(/\n/g, ' '), 400)}`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
lines.push(` - 耗时: ${duration} 秒`);
|
|
285
|
-
|
|
286
|
-
if (status === 'UNKNOWN') {
|
|
287
|
-
lines.push('- **⚠️ UNKNOWN 处理规则(fixer 必读)**:');
|
|
288
|
-
lines.push(
|
|
289
|
-
' - 本用例 AutoTest agent **未到达验证步骤**(步数预算耗尽 / 超时 / 异常终止),真实通过状态未知,**不等价于 FAIL**。',
|
|
290
|
-
);
|
|
291
|
-
lines.push(
|
|
292
|
-
' - 即使白盒审查在相邻代码中发现瑕疵,也**不得**把本用例算作 confirmed → 已修复。原始 UNKNOWN 的根因(agent 超时)不会因任何代码改动消失,必须在 AutoTest 端加大步数上限/人工复测后才能定论。',
|
|
293
|
-
);
|
|
294
|
-
lines.push(
|
|
295
|
-
' - 处理建议:在 fixer 白盒审查阶段将本用例归类为 `unknown_no_verdict` —— 记录「待重跑确认」,**不计入本轮修复成果**,也不消耗 fixer 的有效尝试预算。',
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
lines.push('');
|
|
300
|
-
return lines.join('\n');
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Render the `## 测试总结` block. */
|
|
304
|
-
function renderSummary(cases: Case[], totals: Totals): string {
|
|
305
|
-
const total = totals.preTotal + totals.regularTotal;
|
|
306
|
-
const notPassed = totals.fail + totals.unknown;
|
|
307
|
-
|
|
308
|
-
const lines = [
|
|
309
|
-
'## 测试总结',
|
|
310
|
-
'',
|
|
311
|
-
`- **总计用例**: ${total}(PRE ${totals.preTotal} + Regular ${totals.regularTotal})`,
|
|
312
|
-
`- **常规用例(功能场景)**: ${totals.regularPass}/${totals.regularTotal} 通过,**通过率 ${totals.regularPassRate}**(反映本次需求质量)`,
|
|
313
|
-
`- **前置用例(数据/环境准备)**: ${totals.prePass}/${totals.preTotal} 通过(与本次功能需求无关,仅作环境就绪信号)`,
|
|
314
|
-
`- **总计通过**: ${totals.pass}`,
|
|
315
|
-
`- **总计未通过**: ${notPassed}(FAIL ${totals.fail} + UNKNOWN ${totals.unknown})`,
|
|
316
|
-
`- **含前置通过率**: ${totals.passRate}(不作为功能质量指标)`,
|
|
317
|
-
'',
|
|
318
|
-
];
|
|
319
|
-
|
|
320
|
-
const notPassedCases = cases.filter(c => c.status !== 'PASS');
|
|
321
|
-
if (notPassedCases.length > 0) {
|
|
322
|
-
lines.push('### 未通过用例列表', '');
|
|
323
|
-
lines.push('| # | 类别 | 用例描述 | 失败类型 | 失败原因 |');
|
|
324
|
-
lines.push('|---|------|---------|---------|---------|');
|
|
325
|
-
notPassedCases.forEach((c, i) => {
|
|
326
|
-
const category = c.case_name.startsWith(PRE_PREFIX) ? '前置' : '常规';
|
|
327
|
-
const bug = ((c._judgment ?? {})['Bug发现'] || '').trim();
|
|
328
|
-
const failReason = truncate(oneLine(bug || (c.reason || '').trim() || '未知'), 200);
|
|
329
|
-
lines.push(`| ${i + 1} | ${category} | ${stripPre(c.case_name)} | ${c.status} | ${failReason} |`);
|
|
330
|
-
});
|
|
331
|
-
lines.push('');
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
lines.push('### 建议', '');
|
|
335
|
-
const suggestions = buildSuggestions(cases);
|
|
336
|
-
for (const s of suggestions) lines.push(`- ${s}`);
|
|
337
|
-
if (suggestions.length === 0) lines.push('- 全部用例通过,无需进一步动作。');
|
|
338
|
-
lines.push('');
|
|
339
|
-
return lines.join('\n');
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// =============================================================================
|
|
343
|
-
// Suggestions
|
|
344
|
-
// =============================================================================
|
|
345
|
-
|
|
346
|
-
/** Failure-text keywords hinting a regular case failed on unmet preconditions
|
|
347
|
-
* rather than a real defect — i.e. a cascade from a failed pre-case. */
|
|
348
|
-
const CASCADE_PATTERNS: readonly string[] = [
|
|
349
|
-
'测试环境', '环境条件', '环境异常', '前置',
|
|
350
|
-
'没有音乐', '无音乐', '没有文件', '无文件', '无可用',
|
|
351
|
-
'库为空', '歌曲库为空', '数据缺失', '数据不足',
|
|
352
|
-
'no files', 'no music', 'library is empty',
|
|
353
|
-
];
|
|
354
|
-
|
|
355
|
-
/** True if the case's failure text (judgment + last thinking) matches any cascade keyword. */
|
|
356
|
-
function isCascadeFailure(case_: Case): boolean {
|
|
357
|
-
const j = case_._judgment ?? {};
|
|
358
|
-
const text = [
|
|
359
|
-
case_.reason ?? '',
|
|
360
|
-
j['Bug发现'] ?? '',
|
|
361
|
-
j['状态确认'] ?? '',
|
|
362
|
-
j['历史回顾'] ?? '',
|
|
363
|
-
case_._last_thinking ?? '',
|
|
364
|
-
]
|
|
365
|
-
.join(' ')
|
|
366
|
-
.toLowerCase();
|
|
367
|
-
return CASCADE_PATTERNS.some(p => text.includes(p.toLowerCase()));
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Join stripped, per-item-truncated case names for suggestion text. */
|
|
371
|
-
function names(cases: Case[], max: number): string {
|
|
372
|
-
return cases.map(c => stripPre(c.case_name).slice(0, max)).join(', ');
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** Compose templated suggestions based on failure categories — no LLM needed.
|
|
376
|
-
* PRE fail → env/data fix; cascade → warn PRE-caused; UNKNOWN → not "fixed";
|
|
377
|
-
* real regular FAIL → route to self-test-fixer. */
|
|
378
|
-
export function buildSuggestions(cases: Case[]): string[] {
|
|
379
|
-
const out: string[] = [];
|
|
380
|
-
const preFails = cases.filter(c => c.case_name.startsWith(PRE_PREFIX) && c.status !== 'PASS');
|
|
381
|
-
const regNonPass = cases.filter(c => !c.case_name.startsWith(PRE_PREFIX) && c.status !== 'PASS');
|
|
382
|
-
const regUnknowns = regNonPass.filter(c => c.status === 'UNKNOWN');
|
|
383
|
-
const regFails = regNonPass.filter(c => c.status === 'FAIL');
|
|
384
|
-
|
|
385
|
-
// Cascade detection only makes sense when pre-cases also failed.
|
|
386
|
-
const cascadeCases = preFails.length > 0 ? regNonPass.filter(isCascadeFailure) : [];
|
|
387
|
-
|
|
388
|
-
if (preFails.length > 0) {
|
|
389
|
-
out.push(
|
|
390
|
-
`**前置用例失败**(${preFails.length} 条:${names(preFails, 30)}):**前置用例不是本次需求功能的测试**,它们只是为常规用例准备数据/环境(如导入媒体、授予权限、跳过引导)。失败 90% 以上是测试环境/数据问题(缺素材、设备未授权、文件选择器交互异常等),**不应当作应用缺陷处理**。建议:人工补齐前置数据后重跑受影响的常规用例;只有当白盒审查确认前置流程触发的是真实代码 Bug 时,才进入 self-test-fixer 修复流。`,
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (cascadeCases.length > 0) {
|
|
395
|
-
out.push(
|
|
396
|
-
`**⚠️ ${cascadeCases.length} 条常规用例疑似前置连带失败**(${names(cascadeCases, 50)}):失败原因中出现「测试环境/环境条件/没有音乐文件/前置不满足」等关键词,说明这些用例 **可能不是独立的功能 Bug,而是 PRE 失败的传导**——在前置数据未准备好的前提下,这些用例根本没机会跑到验证步骤。**修复优先级**:先补齐前置 → 重跑这些用例 → 仍 FAIL 才认定为真实应用缺陷再进入修复流。self-test-fixer 收到此类 case 应在白盒审查时严格甄别,避免给「环境不就绪」打补丁。`,
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (regUnknowns.length > 0) {
|
|
401
|
-
out.push(
|
|
402
|
-
`**${regUnknowns.length} 条 UNKNOWN**(${names(regUnknowns, 50)}):AutoTest agent 因步数预算耗尽 / 超时未达验证阶段,**这些用例的真实通过状态未知**,与 FAIL 性质不同。**fixer agent 不应把 UNKNOWN 算作「已修复」**——即使白盒能找出顺手可修的瑕疵,原始 UNKNOWN 的根因(agent 超时)不会因代码改动消失,必须在 AutoTest 端加大步数上限或人工复测后才能定论。建议:将 UNKNOWN 案例从本轮修复目标中排除,仅作「待重跑候选」。`,
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const realRegFails = regFails.filter(c => !cascadeCases.includes(c));
|
|
407
|
-
if (realRegFails.length > 0) {
|
|
408
|
-
out.push(
|
|
409
|
-
`**${realRegFails.length} 条疑似真实常规 FAIL**(已排除前置连带):交由 \`self-test-fixer\` agent 白盒审查后修复,或人工复核失败用例对应的 HarmonyOS 代码与 Android 参考实现。`,
|
|
410
|
-
);
|
|
411
|
-
} else if (regFails.length > 0) {
|
|
412
|
-
out.push(
|
|
413
|
-
'本次所有常规 FAIL 均疑似前置连带,**预计无需修改应用代码**。请优先把前置流程跑通,再用同一批用例复跑确认。',
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
return out;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// =============================================================================
|
|
421
|
-
// Validator
|
|
422
|
-
// =============================================================================
|
|
423
|
-
|
|
424
|
-
const REQUIRED_OVERVIEW_FIELDS = [
|
|
425
|
-
'测试套件', '测试时间', '设备', '应用', 'HAP', '总用例数', '通过', '失败',
|
|
426
|
-
];
|
|
427
|
-
const REQUIRED_CASE_FIELDS = [
|
|
428
|
-
'执行结果', '操作步骤', '期望结果', 'AutoTest 任务路径', 'AutoTest 详情',
|
|
429
|
-
];
|
|
430
|
-
const REQUIRED_SECTIONS = ['测试概览', '前置用例', '用例详情', '测试总结'];
|
|
431
|
-
const CASE_SECTIONS: Array<[string, string]> = [
|
|
432
|
-
['前置用例', 'Pre'],
|
|
433
|
-
['用例详情', 'Case'],
|
|
434
|
-
];
|
|
435
|
-
|
|
436
|
-
/** Return the body of `## <section_title>` up to the next `## ` (exclusive), or null. */
|
|
437
|
-
function extractMdSection(text: string, sectionTitle: string): string | null {
|
|
438
|
-
const m = text.match(
|
|
439
|
-
new RegExp(`##\\s*${escapeRegExp(sectionTitle)}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`),
|
|
440
|
-
);
|
|
441
|
-
return m ? m[1] : null;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/** Split a case section into (heading, body) blocks by `### ` headings. */
|
|
445
|
-
function splitCaseBlocks(sectionBody: string): Array<[string, string]> {
|
|
446
|
-
return sectionBody
|
|
447
|
-
.split(/^###\s+/m)
|
|
448
|
-
.slice(1)
|
|
449
|
-
.map(chunk => {
|
|
450
|
-
const nl = chunk.indexOf('\n');
|
|
451
|
-
return nl === -1
|
|
452
|
-
? ([chunk.trim(), ''] as [string, string])
|
|
453
|
-
: ([chunk.slice(0, nl).trim(), chunk.slice(nl + 1)] as [string, string]);
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** Validate one case section's headings. Returns {blocks, errors}. */
|
|
458
|
-
function validateCaseSection(
|
|
459
|
-
sectionBody: string,
|
|
460
|
-
sectionTitle: string,
|
|
461
|
-
expectedPrefix: string,
|
|
462
|
-
): { blocks: Array<[string, string]>; errors: string[] } {
|
|
463
|
-
const errors: string[] = [];
|
|
464
|
-
const blocks = splitCaseBlocks(sectionBody);
|
|
465
|
-
|
|
466
|
-
if (blocks.length === 0 && expectedPrefix === 'Case') {
|
|
467
|
-
errors.push(
|
|
468
|
-
`Section '## ${sectionTitle}': no '### Case <N>:' blocks found — report may be using a table-only format without per-case detail sections`,
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
const pattern = new RegExp(`^${escapeRegExp(expectedPrefix)}\\s+(\\d+):\\s`);
|
|
473
|
-
const indices: number[] = [];
|
|
474
|
-
for (const [heading] of blocks) {
|
|
475
|
-
const m = heading.match(pattern);
|
|
476
|
-
if (!m) {
|
|
477
|
-
errors.push(
|
|
478
|
-
`Section '## ${sectionTitle}': heading does NOT match \`### ${expectedPrefix} <N>: <title>\` — got \`### ${heading}\``,
|
|
479
|
-
);
|
|
480
|
-
} else {
|
|
481
|
-
indices.push(parseInt(m[1], 10));
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (indices.length > 0) {
|
|
486
|
-
const expected = Array.from({ length: indices.length }, (_, k) => k + 1);
|
|
487
|
-
if (indices.join(',') !== expected.join(',')) {
|
|
488
|
-
errors.push(
|
|
489
|
-
`Section '## ${sectionTitle}': ${expectedPrefix} indices not contiguous from 1; expected [${expected.join(', ')}], got [${indices.join(', ')}]`,
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
return { blocks, errors };
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/** Return a list of validation errors for self-test-report.md. Empty == PASSED. */
|
|
498
|
-
export function validateReport(reportPath: string): string[] {
|
|
499
|
-
if (!fs.existsSync(reportPath)) return [`File not found: ${reportPath}`];
|
|
500
|
-
|
|
501
|
-
const text = fs.readFileSync(reportPath, 'utf-8');
|
|
502
|
-
const errors: string[] = [];
|
|
503
|
-
|
|
504
|
-
for (const section of REQUIRED_SECTIONS) {
|
|
505
|
-
if (!text.includes(section)) errors.push(`Missing section: '${section}'`);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
const overview = extractMdSection(text, '测试概览');
|
|
509
|
-
if (overview === null) {
|
|
510
|
-
errors.push("Cannot locate '## 测试概览' section");
|
|
511
|
-
} else {
|
|
512
|
-
for (const field of REQUIRED_OVERVIEW_FIELDS) {
|
|
513
|
-
if (!overview.includes(field)) errors.push(`Missing overview field: '${field}'`);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const allBlocks: Array<[string, string, string]> = []; // (sectionTitle, heading, body)
|
|
518
|
-
for (const [sectionTitle, expectedPrefix] of CASE_SECTIONS) {
|
|
519
|
-
const body = extractMdSection(text, sectionTitle);
|
|
520
|
-
if (body === null) continue;
|
|
521
|
-
const { blocks, errors: sectionErrors } = validateCaseSection(body, sectionTitle, expectedPrefix);
|
|
522
|
-
errors.push(...sectionErrors);
|
|
523
|
-
for (const [heading, blockBody] of blocks) {
|
|
524
|
-
allBlocks.push([sectionTitle, heading, blockBody]);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const label = (heading: string): string => truncate(heading, 60);
|
|
529
|
-
|
|
530
|
-
for (const [sectionTitle, heading, body] of allBlocks) {
|
|
531
|
-
const missing = REQUIRED_CASE_FIELDS.filter(f => !body.includes(f));
|
|
532
|
-
if (missing.length > 0) {
|
|
533
|
-
errors.push(
|
|
534
|
-
`Section '## ${sectionTitle}', block \`### ${label(heading)}\`: missing fields: [${missing
|
|
535
|
-
.map(f => `'${f}'`)
|
|
536
|
-
.join(', ')}]`,
|
|
537
|
-
);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
if (text.match(/\*\*失败\*\*:\s*([1-9])/)) {
|
|
542
|
-
if (!text.includes('未通过用例列表')) {
|
|
543
|
-
errors.push("Report has failures but missing '未通过用例列表' section");
|
|
544
|
-
}
|
|
545
|
-
for (const [sectionTitle, heading, body] of allBlocks) {
|
|
546
|
-
if (/\*\*执行结果\*\*:\s*FAIL/i.test(body)) {
|
|
547
|
-
const detailParts = body.split('AutoTest 详情');
|
|
548
|
-
const detailPart = detailParts.length > 1 ? detailParts[1] : body;
|
|
549
|
-
if (!detailPart.includes('失败原因') && !detailPart.includes('失败')) {
|
|
550
|
-
errors.push(
|
|
551
|
-
`Section '## ${sectionTitle}', block \`### ${label(heading)}\`: FAIL but missing failure reason in 'AutoTest 详情'`,
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
return errors;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// =============================================================================
|
|
562
|
-
// Pipeline (generate)
|
|
563
|
-
// =============================================================================
|
|
564
|
-
|
|
565
|
-
class FileNotFoundError extends Error {
|
|
566
|
-
constructor(message: string) {
|
|
567
|
-
super(message);
|
|
568
|
-
this.name = 'FileNotFoundError';
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** Shape of `summary.json` written by AutoTest.batch (all fields optional). */
|
|
573
|
-
interface SummaryJson {
|
|
574
|
-
pass_count?: number;
|
|
575
|
-
fail_count?: number;
|
|
576
|
-
unknown_count?: number;
|
|
577
|
-
pass_rate?: string;
|
|
578
|
-
start_time?: string;
|
|
579
|
-
end_time?: string;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** Read task_results.jsonl and hydrate each case with its judgment + thinking. */
|
|
583
|
-
function loadCases(jsonlPath: string): Case[] {
|
|
584
|
-
const cases: Case[] = [];
|
|
585
|
-
for (const line of fs.readFileSync(jsonlPath, 'utf-8').split(/\r?\n/)) {
|
|
586
|
-
const trimmed = line.trim();
|
|
587
|
-
if (!trimmed) continue;
|
|
588
|
-
const case_: Case = JSON.parse(trimmed);
|
|
589
|
-
const [judgment, lastThinking] = case_.report_dir
|
|
590
|
-
? loadCaseJudgment(case_.report_dir)
|
|
591
|
-
: [{}, ''];
|
|
592
|
-
case_._judgment = judgment;
|
|
593
|
-
case_._last_thinking = lastThinking;
|
|
594
|
-
cases.push(case_);
|
|
595
|
-
}
|
|
596
|
-
return cases;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/** Compose the complete self-test-report.md text from task_<ts>/ data. */
|
|
600
|
-
export function renderReport(
|
|
601
|
-
taskSubdir: string,
|
|
602
|
-
appMetadata: string,
|
|
603
|
-
hap: string,
|
|
604
|
-
device: string,
|
|
605
|
-
suite: string,
|
|
606
|
-
): string {
|
|
607
|
-
const summaryPath = path.join(taskSubdir, 'summary.json');
|
|
608
|
-
const jsonlPath = path.join(taskSubdir, 'task_results.jsonl');
|
|
609
|
-
for (const p of [summaryPath, jsonlPath]) {
|
|
610
|
-
if (!fs.existsSync(p)) throw new FileNotFoundError(`missing file in task-subdir: ${p}`);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const summary: SummaryJson = JSON.parse(fs.readFileSync(summaryPath, 'utf-8')) as SummaryJson;
|
|
614
|
-
const cases = loadCases(jsonlPath);
|
|
615
|
-
|
|
616
|
-
const preCases = cases.filter(c => c.case_name.startsWith(PRE_PREFIX));
|
|
617
|
-
const regCases = cases.filter(c => !c.case_name.startsWith(PRE_PREFIX));
|
|
618
|
-
const count = (seq: Case[], status: string): number =>
|
|
619
|
-
seq.filter(c => c.status === status).length;
|
|
620
|
-
const regularPass = count(regCases, 'PASS');
|
|
621
|
-
|
|
622
|
-
const totals: Totals = {
|
|
623
|
-
preTotal: preCases.length,
|
|
624
|
-
regularTotal: regCases.length,
|
|
625
|
-
prePass: count(preCases, 'PASS'),
|
|
626
|
-
regularPass,
|
|
627
|
-
pass: summary.pass_count ?? count(cases, 'PASS'),
|
|
628
|
-
fail: summary.fail_count ?? count(cases, 'FAIL'),
|
|
629
|
-
unknown: summary.unknown_count ?? count(cases, 'UNKNOWN'),
|
|
630
|
-
passRate: summary.pass_rate ?? '0%',
|
|
631
|
-
regularPassRate:
|
|
632
|
-
regCases.length > 0 ? `${((regularPass / regCases.length) * 100).toFixed(2)}%` : 'N/A',
|
|
633
|
-
};
|
|
634
|
-
|
|
635
|
-
const meta: { bundle_name?: string; app_name?: string } = JSON.parse(
|
|
636
|
-
fs.readFileSync(appMetadata, 'utf-8'),
|
|
637
|
-
) as { bundle_name?: string; app_name?: string };
|
|
638
|
-
|
|
639
|
-
const startTime = summary.start_time ?? '';
|
|
640
|
-
const endTime = summary.end_time ?? '';
|
|
641
|
-
const timeRange = startTime && endTime ? `${startTime} ~ ${endTime}` : startTime || '未知';
|
|
642
|
-
|
|
643
|
-
const parts: string[] = [
|
|
644
|
-
'# Self-Test 测试报告',
|
|
645
|
-
'',
|
|
646
|
-
renderOverview(
|
|
647
|
-
suite,
|
|
648
|
-
timeRange,
|
|
649
|
-
device,
|
|
650
|
-
meta.app_name ?? '',
|
|
651
|
-
meta.bundle_name ?? '',
|
|
652
|
-
path.basename(hap),
|
|
653
|
-
totals,
|
|
654
|
-
),
|
|
655
|
-
'---',
|
|
656
|
-
'',
|
|
657
|
-
'## 前置用例',
|
|
658
|
-
'',
|
|
659
|
-
];
|
|
660
|
-
if (preCases.length > 0) {
|
|
661
|
-
parts.push(
|
|
662
|
-
'> **前置用例是为常规用例准备数据/环境的设置脚本(来自 `pre_test_case.md`),**不属于本次需求功能本身**。此处的 PASS/FAIL 仅反映测试前置条件是否就绪:FAIL 通常意味着测试环境问题(如缺少素材、权限未授予、首启引导未跳过),并非应用功能缺陷。但需注意——前置失败可能让后续依赖该数据的常规用例无效,判读常规结果时应将受影响的用例排除或在补齐前置后重跑。',
|
|
663
|
-
);
|
|
664
|
-
parts.push('');
|
|
665
|
-
preCases.forEach((c, i) => parts.push(renderCaseBlock(i + 1, 'Pre', c)));
|
|
666
|
-
} else {
|
|
667
|
-
parts.push('_无前置用例_', '');
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
parts.push('---', '', '## 用例详情', '');
|
|
671
|
-
regCases.forEach((c, i) => parts.push(renderCaseBlock(i + 1, 'Case', c)));
|
|
672
|
-
|
|
673
|
-
parts.push('---', '', renderSummary(cases, totals));
|
|
674
|
-
return parts.join('\n');
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// =============================================================================
|
|
678
|
-
// CLI
|
|
679
|
-
// =============================================================================
|
|
680
|
-
|
|
681
|
-
interface GenerateOpts {
|
|
682
|
-
taskSubdir: string;
|
|
683
|
-
appMetadata: string;
|
|
684
|
-
hap: string;
|
|
685
|
-
device: string;
|
|
686
|
-
suite: string;
|
|
687
|
-
out: string;
|
|
688
|
-
validate?: boolean;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/** Print validation result and return exit code (0 = pass, 1 = fail). */
|
|
692
|
-
function printValidation(errors: string[], reportPath: string): number {
|
|
693
|
-
if (errors.length === 0) {
|
|
694
|
-
console.log(`VALIDATION PASSED: ${reportPath}`);
|
|
695
|
-
return 0;
|
|
696
|
-
}
|
|
697
|
-
console.log(`VALIDATION FAILED: ${errors.length} issue(s) in ${reportPath}`);
|
|
698
|
-
for (const e of errors) console.log(` - ${e}`);
|
|
699
|
-
return 1;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/** `generate` subcommand: render the report and optionally re-validate. */
|
|
703
|
-
function cmdGenerate(opts: GenerateOpts): void {
|
|
704
|
-
const taskSubdir = path.resolve(opts.taskSubdir);
|
|
705
|
-
if (!fs.existsSync(taskSubdir)) {
|
|
706
|
-
console.error(`ERROR: task-subdir not found: ${taskSubdir}`);
|
|
707
|
-
process.exit(2);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
let reportText: string;
|
|
711
|
-
try {
|
|
712
|
-
reportText = renderReport(taskSubdir, opts.appMetadata, opts.hap, opts.device, opts.suite);
|
|
713
|
-
} catch (e) {
|
|
714
|
-
if (e instanceof FileNotFoundError) {
|
|
715
|
-
console.error(`ERROR: ${e.message}`);
|
|
716
|
-
process.exit(2);
|
|
717
|
-
}
|
|
718
|
-
throw e;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
const outPath = path.resolve(opts.out);
|
|
722
|
-
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
723
|
-
fs.writeFileSync(outPath, reportText, 'utf-8');
|
|
724
|
-
console.log(`Generated: ${outPath}`);
|
|
725
|
-
|
|
726
|
-
if (opts.validate) {
|
|
727
|
-
process.exit(printValidation(validateReport(outPath), outPath));
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/** `validate` subcommand: validate an existing report. */
|
|
732
|
-
function cmdValidate(reportPath: string): void {
|
|
733
|
-
const resolved = path.resolve(reportPath);
|
|
734
|
-
process.exit(printValidation(validateReport(resolved), resolved));
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/** Wire up commander with `generate` and `validate` subcommands. */
|
|
738
|
-
export function main(): void {
|
|
739
|
-
const program = new Command();
|
|
740
|
-
program.name('report-tool').description('Generate or validate self-test-report.md.');
|
|
741
|
-
|
|
742
|
-
program
|
|
743
|
-
.command('generate')
|
|
744
|
-
.description('Render self-test-report.md from a task_<ts>/ directory')
|
|
745
|
-
.requiredOption(
|
|
746
|
-
'--task-subdir <path>',
|
|
747
|
-
'task_<ts>/ directory with summary.json + task_results.jsonl',
|
|
748
|
-
)
|
|
749
|
-
.requiredOption('--app-metadata <path>', 'app-metadata.json path')
|
|
750
|
-
.requiredOption('--hap <path>', 'HAP file path')
|
|
751
|
-
.requiredOption('--device <device>', 'device identifier (e.g. 127.0.0.1:5555)')
|
|
752
|
-
.requiredOption('--suite <name>', 'test suite title')
|
|
753
|
-
.requiredOption('--out <path>', 'output self-test-report.md path')
|
|
754
|
-
.option('--validate', 'after writing, re-validate the report in-process')
|
|
755
|
-
.action((opts: GenerateOpts) => cmdGenerate(opts));
|
|
756
|
-
|
|
757
|
-
program
|
|
758
|
-
.command('validate')
|
|
759
|
-
.description('Validate an existing self-test-report.md')
|
|
760
|
-
.argument('<path>', 'path to the self-test-report.md to validate')
|
|
761
|
-
.action((reportPath: string) => cmdValidate(reportPath));
|
|
762
|
-
|
|
763
|
-
program.parseAsync(process.argv).catch(err => {
|
|
764
|
-
console.error(err instanceof Error ? err.message : err);
|
|
765
|
-
process.exit(1);
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
770
|
-
const isMainEntry = !!process.argv[1] && path.resolve(process.argv[1]) === __filename;
|
|
771
|
-
if (isMainEntry) {
|
|
772
|
-
main();
|
|
773
|
-
}
|