android2harmony 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/self-tester.md +376 -0
- package/dist/index.js +152 -7
- package/dist/index.js.map +4 -4
- package/package.json +32 -29
- package/skills/hmos-incremental-ui-align/README.md +251 -0
- package/skills/hmos-incremental-ui-align/SKILL.md +365 -0
- package/skills/hmos-incremental-ui-align/diff_analysis.md +53 -0
- package/skills/hmos-incremental-ui-align/page_align.md +62 -0
- package/skills/hmos-incremental-ui-align/references/Comparison_Template.md +38 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@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 +648 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@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 +2089 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@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 +1033 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@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 +1183 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@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 +576 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@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 +297 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@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 +395 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/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 +903 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md +106 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/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 +1178 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/MVVM/346/250/241/345/274/217V1.md +911 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/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 +355 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243//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 +11 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/!!/350/257/255/346/263/225/357/274/232/345/217/214/345/220/221/347/273/221/345/256/232.md +206 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +373 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +158 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +750 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +1704 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +1012 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +164 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +840 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +856 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/@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 +83 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/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 +294 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/MVVM/346/250/241/345/274/217/357/274/210V2/357/274/211.md +1407 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/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 +1220 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2/_/350/214/203/345/274/217/351/200/211/346/213/251/350/257/264/346/230/216.md +47 -0
- package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243V2//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 +840 -0
- package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +74 -0
- package/skills/hmos-incremental-ui-align/references/UI_Analysis_Template.md +34 -0
- package/skills/hmos-incremental-ui-align/references/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2533 -0
- package/skills/hmos-incremental-ui-align/references/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
- package/skills/hmos-incremental-ui-align/references/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +999 -0
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +343 -0
- package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -0
- package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +977 -0
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +188 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 统一页面采集工具 (TypeScript 版) —— 单次采集当前设备页面的 View Tree (XML)、截图,支持滚动采集。
|
|
4
|
+
*
|
|
5
|
+
* 支持 Android (ADB) 和 HarmonyOS (HDC) 两种设备。
|
|
6
|
+
* 零第三方依赖,仅使用 Node 内置模块,可独立分发。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* # HarmonyOS
|
|
10
|
+
* node page_capture.ts --device hdc -o ./output
|
|
11
|
+
*
|
|
12
|
+
* # Android
|
|
13
|
+
* node page_capture.ts --device adb -o ./output --serial emulator-5554
|
|
14
|
+
*
|
|
15
|
+
* 等价于原 page_capture.py(functional equivalence,非逐字节一致)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const SCROLL_DURATION_MS = 300;
|
|
27
|
+
const HDC_SWIPE_SPEED = 600; // pixels/second for hdc uitest uiInput swipe
|
|
28
|
+
const IDLE_POLL_INTERVAL = 0.2; // seconds
|
|
29
|
+
const IDLE_TIMEOUT = 5; // seconds
|
|
30
|
+
|
|
31
|
+
const HMOS_SKIP_TYPES = new Set(['WindowScene', '__Common__', 'EffectComponent', 'metaballNode']);
|
|
32
|
+
const HMOS_SKIP_BUNDLES = new Set([
|
|
33
|
+
'com.huawei.systemui',
|
|
34
|
+
'com.ohos.systemui',
|
|
35
|
+
'com.huawei.android.launcher',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const HORIZONTAL_SCROLL_CLASSES = new Set([
|
|
39
|
+
'android.widget.HorizontalScrollView',
|
|
40
|
+
'HorizontalScrollView',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function sleep(seconds: number): void {
|
|
44
|
+
// 同步睡眠,保持与 Python time.sleep 一致的顺序执行语义。
|
|
45
|
+
const end = Date.now() + seconds * 1000;
|
|
46
|
+
const sab = new SharedArrayBuffer(4);
|
|
47
|
+
const ia = new Int32Array(sab);
|
|
48
|
+
while (Date.now() < end) {
|
|
49
|
+
Atomics.wait(ia, 0, 0, Math.min(50, end - Date.now()));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// 最小 XML 解析器 —— 仅覆盖 uiautomator dump / HarmonyOS 转换后 XML 的用法:
|
|
55
|
+
// 遍历 <node> 元素并读取属性。等价于 Python xml.etree 的 root.iter("node")。
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
interface XmlNode {
|
|
58
|
+
tag: string;
|
|
59
|
+
attrs: Record<string, string>;
|
|
60
|
+
children: XmlNode[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const XML_ENTITIES: Record<string, string> = {
|
|
64
|
+
amp: '&',
|
|
65
|
+
lt: '<',
|
|
66
|
+
gt: '>',
|
|
67
|
+
quot: '"',
|
|
68
|
+
apos: "'",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function decodeEntities(s: string): string {
|
|
72
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent: string) => {
|
|
73
|
+
if (ent[0] === '#') {
|
|
74
|
+
const code = ent[1] === 'x' || ent[1] === 'X' ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10);
|
|
75
|
+
return Number.isNaN(code) ? m : String.fromCodePoint(code);
|
|
76
|
+
}
|
|
77
|
+
return ent in XML_ENTITIES ? XML_ENTITIES[ent] : m;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class XmlParseError extends Error {}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析 XML 字符串为节点树。抛 XmlParseError 表示格式错误(等价 ET.ParseError)。
|
|
85
|
+
* 忽略文本内容(uiautomator 的信息全在属性上)。
|
|
86
|
+
*/
|
|
87
|
+
function parseXml(xml: string): XmlNode {
|
|
88
|
+
let i = 0;
|
|
89
|
+
const n = xml.length;
|
|
90
|
+
const stack: XmlNode[] = [];
|
|
91
|
+
let root: XmlNode | null = null;
|
|
92
|
+
|
|
93
|
+
const readAttrs = (raw: string): Record<string, string> => {
|
|
94
|
+
const attrs: Record<string, string> = {};
|
|
95
|
+
const re = /([^\s=/>]+)\s*=\s*"([^"]*)"|([^\s=/>]+)\s*=\s*'([^']*)'/g;
|
|
96
|
+
let m: RegExpExecArray | null;
|
|
97
|
+
while ((m = re.exec(raw)) !== null) {
|
|
98
|
+
if (m[1] !== undefined) attrs[m[1]] = decodeEntities(m[2]);
|
|
99
|
+
else if (m[3] !== undefined) attrs[m[3]] = decodeEntities(m[4]);
|
|
100
|
+
}
|
|
101
|
+
return attrs;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
while (i < n) {
|
|
105
|
+
const lt = xml.indexOf('<', i);
|
|
106
|
+
if (lt < 0) break;
|
|
107
|
+
i = lt;
|
|
108
|
+
|
|
109
|
+
// 跳过声明 <?...?> 与注释 <!--...-->
|
|
110
|
+
if (xml.startsWith('<?', i)) {
|
|
111
|
+
const end = xml.indexOf('?>', i);
|
|
112
|
+
if (end < 0) throw new XmlParseError('unterminated declaration');
|
|
113
|
+
i = end + 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (xml.startsWith('<!--', i)) {
|
|
117
|
+
const end = xml.indexOf('-->', i);
|
|
118
|
+
if (end < 0) throw new XmlParseError('unterminated comment');
|
|
119
|
+
i = end + 3;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (xml.startsWith('<!', i)) {
|
|
123
|
+
const end = xml.indexOf('>', i);
|
|
124
|
+
if (end < 0) throw new XmlParseError('unterminated markup declaration');
|
|
125
|
+
i = end + 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 找标签结束 '>',注意跳过引号内的 '>'
|
|
130
|
+
let j = i + 1;
|
|
131
|
+
let quote: string | null = null;
|
|
132
|
+
for (; j < n; j++) {
|
|
133
|
+
const c = xml[j];
|
|
134
|
+
if (quote) {
|
|
135
|
+
if (c === quote) quote = null;
|
|
136
|
+
} else if (c === '"' || c === "'") {
|
|
137
|
+
quote = c;
|
|
138
|
+
} else if (c === '>') {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (j >= n) throw new XmlParseError('unterminated tag');
|
|
143
|
+
|
|
144
|
+
const inner = xml.slice(i + 1, j);
|
|
145
|
+
i = j + 1;
|
|
146
|
+
|
|
147
|
+
if (inner[0] === '/') {
|
|
148
|
+
// 闭合标签
|
|
149
|
+
const closed = stack.pop();
|
|
150
|
+
if (!closed) throw new XmlParseError('unexpected closing tag');
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const selfClosing = inner.endsWith('/');
|
|
155
|
+
const body = selfClosing ? inner.slice(0, -1) : inner;
|
|
156
|
+
const spaceIdx = body.search(/[\s/]/);
|
|
157
|
+
const tag = spaceIdx < 0 ? body : body.slice(0, spaceIdx);
|
|
158
|
+
const attrRaw = spaceIdx < 0 ? '' : body.slice(spaceIdx);
|
|
159
|
+
const node: XmlNode = { tag, attrs: readAttrs(attrRaw), children: [] };
|
|
160
|
+
|
|
161
|
+
if (stack.length > 0) {
|
|
162
|
+
stack[stack.length - 1].children.push(node);
|
|
163
|
+
} else if (root === null) {
|
|
164
|
+
root = node;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!selfClosing) stack.push(node);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (root === null) throw new XmlParseError('no root element');
|
|
171
|
+
return root;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 等价 element.iter(tag):先序遍历自身及所有后代,产出 tag 匹配的节点。 */
|
|
175
|
+
function* iterNodes(root: XmlNode, tag: string): Generator<XmlNode> {
|
|
176
|
+
if (root.tag === tag) yield root;
|
|
177
|
+
for (const child of root.children) {
|
|
178
|
+
yield* iterNodes(child, tag);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 遍历所有后代(含自身),不限 tag。 */
|
|
183
|
+
function* iterAll(root: XmlNode): Generator<XmlNode> {
|
|
184
|
+
yield root;
|
|
185
|
+
for (const child of root.children) {
|
|
186
|
+
yield* iterAll(child);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function attr(node: XmlNode, key: string, dflt = ''): string {
|
|
191
|
+
return key in node.attrs ? node.attrs[key] : dflt;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// 子进程封装
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
function runCapture(
|
|
198
|
+
file: string,
|
|
199
|
+
args: string[],
|
|
200
|
+
opts: { timeout?: number; binary?: boolean } = {},
|
|
201
|
+
): { stdout: string; stdoutBuf: Buffer; stderr: string; code: number | null } {
|
|
202
|
+
const timeoutMs = (opts.timeout ?? 15) * 1000;
|
|
203
|
+
const res = spawnSync(file, args, {
|
|
204
|
+
timeout: timeoutMs,
|
|
205
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
206
|
+
encoding: opts.binary ? 'buffer' : undefined,
|
|
207
|
+
windowsHide: true,
|
|
208
|
+
});
|
|
209
|
+
if (opts.binary) {
|
|
210
|
+
const buf = (res.stdout as unknown as Buffer) ?? Buffer.alloc(0);
|
|
211
|
+
return { stdout: '', stdoutBuf: buf, stderr: '', code: res.status };
|
|
212
|
+
}
|
|
213
|
+
// 文本模式:spawnSync 默认返回 Buffer,需按 utf-8 解码(errors='replace' 语义)
|
|
214
|
+
const outBuf = (res.stdout as unknown as Buffer) ?? Buffer.alloc(0);
|
|
215
|
+
const errBuf = (res.stderr as unknown as Buffer) ?? Buffer.alloc(0);
|
|
216
|
+
return {
|
|
217
|
+
stdout: outBuf.toString('utf-8'),
|
|
218
|
+
stdoutBuf: outBuf,
|
|
219
|
+
stderr: errBuf.toString('utf-8'),
|
|
220
|
+
code: res.status,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// ADB helpers
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
function adb(args: string[], serial: string | null = null, timeout = 15): string {
|
|
228
|
+
const full: string[] = [];
|
|
229
|
+
if (serial) full.push('-s', serial);
|
|
230
|
+
full.push(...args);
|
|
231
|
+
const res = spawnSync('adb', full, {
|
|
232
|
+
timeout: timeout * 1000,
|
|
233
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
234
|
+
windowsHide: true,
|
|
235
|
+
});
|
|
236
|
+
if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
|
237
|
+
console.log(` [WARN] adb timeout: ${args.join(' ')}`);
|
|
238
|
+
return '';
|
|
239
|
+
}
|
|
240
|
+
const out = (res.stdout as Buffer) ?? Buffer.alloc(0);
|
|
241
|
+
return out.toString('utf-8').trim();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function adbShell(args: string[], serial: string | null = null, timeout = 15): string {
|
|
245
|
+
return adb(['shell', ...args], serial, timeout);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function adbWaitIdle(serial: string | null = null): void {
|
|
249
|
+
const deadline = Date.now() + IDLE_TIMEOUT * 1000;
|
|
250
|
+
let prevFocus: string | null = null;
|
|
251
|
+
let interval = IDLE_POLL_INTERVAL;
|
|
252
|
+
while (Date.now() < deadline) {
|
|
253
|
+
sleep(interval);
|
|
254
|
+
interval = Math.min(interval * 1.5, 0.5);
|
|
255
|
+
const focusOut = adbShell(['dumpsys', 'window'], serial);
|
|
256
|
+
let currentFocus = '';
|
|
257
|
+
for (const line of focusOut.split(/\r?\n/)) {
|
|
258
|
+
if (line.includes('mCurrentFocus')) {
|
|
259
|
+
currentFocus = line.trim();
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (currentFocus && currentFocus === prevFocus) return;
|
|
264
|
+
prevFocus = currentFocus;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// HDC helpers
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
function hdc(args: string[], timeout = 15): string {
|
|
272
|
+
// Python 用 shell=True,这里直接调用 hdc 可执行文件(等价,且避免 shell 注入/转义问题)
|
|
273
|
+
const res = spawnSync('hdc', args, {
|
|
274
|
+
timeout: timeout * 1000,
|
|
275
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
276
|
+
windowsHide: true,
|
|
277
|
+
});
|
|
278
|
+
if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
|
279
|
+
console.log(` [WARN] hdc timeout: ${args.join(' ')}`);
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
const errBuf = (res.stderr as Buffer) ?? Buffer.alloc(0);
|
|
283
|
+
const errStr = errBuf.toString('utf-8').trim();
|
|
284
|
+
if (res.status !== 0 && errStr) {
|
|
285
|
+
console.log(` [WARN] hdc stderr: ${errStr}`);
|
|
286
|
+
}
|
|
287
|
+
const out = (res.stdout as Buffer) ?? Buffer.alloc(0);
|
|
288
|
+
return out.toString('utf-8').trim();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function hdcShell(args: string[], timeout = 15): string {
|
|
292
|
+
return hdc(['shell', ...args], timeout);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function hdcWaitIdle(): void {
|
|
296
|
+
sleep(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Return HarmonyOS display density: {dpi, density_factor}.
|
|
301
|
+
* HarmonyOS vp uses the same 160-baseline as Android dp, so
|
|
302
|
+
* density_factor = dpi / 160 and 1vp = density_factor px.
|
|
303
|
+
* Falls back to 480dpi (3x) if the value cannot be read.
|
|
304
|
+
*/
|
|
305
|
+
function hdcGetDensity(): DensityInfo {
|
|
306
|
+
let dpi: number | null = null;
|
|
307
|
+
const out = hdcShell(['hidumper', '-s', '3601', '-a', '-a']); // DisplayManagerService
|
|
308
|
+
const m = out.match(/(?:densityDpi|dpi)\D*(\d{2,4})/i);
|
|
309
|
+
if (m) {
|
|
310
|
+
const val = parseInt(m[1], 10);
|
|
311
|
+
if (val >= 100 && val <= 1000) dpi = val;
|
|
312
|
+
}
|
|
313
|
+
if (dpi === null) {
|
|
314
|
+
const param = hdcShell(['param', 'get', 'persist.sys.dpi']).trim();
|
|
315
|
+
if (/^\d+$/.test(param)) {
|
|
316
|
+
const val = parseInt(param, 10);
|
|
317
|
+
if (val >= 100 && val <= 1000) dpi = val;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (dpi === null) dpi = 480; // fallback: 3x
|
|
321
|
+
return { dpi, density_factor: Math.round((dpi / 160.0) * 10000) / 10000 };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// XML utilities (shared)
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
interface ScrollContainer {
|
|
328
|
+
bounds: [number, number, number, number];
|
|
329
|
+
class: string;
|
|
330
|
+
'resource-id': string;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function extractInts(s: string): number[] {
|
|
334
|
+
const m = s.match(/\d+/g);
|
|
335
|
+
return m ? m.map((x) => parseInt(x, 10)) : [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function findScrollableContainers(xmlContent: string): ScrollContainer[] {
|
|
339
|
+
const containers: ScrollContainer[] = [];
|
|
340
|
+
if (!xmlContent) return containers;
|
|
341
|
+
let root: XmlNode;
|
|
342
|
+
try {
|
|
343
|
+
root = parseXml(xmlContent);
|
|
344
|
+
} catch {
|
|
345
|
+
return containers;
|
|
346
|
+
}
|
|
347
|
+
for (const node of iterNodes(root, 'node')) {
|
|
348
|
+
if (attr(node, 'scrollable') !== 'true') continue;
|
|
349
|
+
const cls = attr(node, 'class');
|
|
350
|
+
if (HORIZONTAL_SCROLL_CLASSES.has(cls)) continue;
|
|
351
|
+
const nums = extractInts(attr(node, 'bounds'));
|
|
352
|
+
if (nums.length !== 4) continue;
|
|
353
|
+
const [x1, y1, x2, y2] = nums;
|
|
354
|
+
if (y2 - y1 < 200) continue;
|
|
355
|
+
containers.push({ bounds: [x1, y1, x2, y2], class: cls, 'resource-id': attr(node, 'resource-id') });
|
|
356
|
+
}
|
|
357
|
+
containers.sort(
|
|
358
|
+
(a, b) =>
|
|
359
|
+
(b.bounds[2] - b.bounds[0]) * (b.bounds[3] - b.bounds[1]) -
|
|
360
|
+
(a.bounds[2] - a.bounds[0]) * (a.bounds[3] - a.bounds[1]),
|
|
361
|
+
);
|
|
362
|
+
return containers;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function extractNodeKeys(xmlContent: string): Set<string> {
|
|
366
|
+
const keys = new Set<string>();
|
|
367
|
+
if (!xmlContent) return keys;
|
|
368
|
+
let root: XmlNode;
|
|
369
|
+
try {
|
|
370
|
+
root = parseXml(xmlContent);
|
|
371
|
+
} catch {
|
|
372
|
+
return keys;
|
|
373
|
+
}
|
|
374
|
+
for (const node of iterNodes(root, 'node')) {
|
|
375
|
+
keys.add(
|
|
376
|
+
[
|
|
377
|
+
attr(node, 'class'),
|
|
378
|
+
attr(node, 'resource-id'),
|
|
379
|
+
attr(node, 'text'),
|
|
380
|
+
attr(node, 'content-desc'),
|
|
381
|
+
attr(node, 'bounds'),
|
|
382
|
+
].join(''),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return keys;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
|
389
|
+
if (a.size !== b.size) return false;
|
|
390
|
+
for (const v of a) if (!b.has(v)) return false;
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function parseBoundsCenter(bounds: string): [number, number] | null {
|
|
395
|
+
const nums = extractInts(bounds);
|
|
396
|
+
if (nums.length === 4) {
|
|
397
|
+
const [x1, y1, x2, y2] = nums;
|
|
398
|
+
return [Math.trunc((x1 + x2) / 2), Math.trunc((y1 + y2) / 2)];
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
interface ClickableElement {
|
|
404
|
+
class: string;
|
|
405
|
+
text: string;
|
|
406
|
+
'resource-id': string;
|
|
407
|
+
'content-desc': string;
|
|
408
|
+
bounds: string;
|
|
409
|
+
center: [number, number] | null;
|
|
410
|
+
'long-clickable': boolean;
|
|
411
|
+
scroll_index?: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function getClickableElements(xmlContent: string): ClickableElement[] {
|
|
415
|
+
const elements: ClickableElement[] = [];
|
|
416
|
+
if (!xmlContent) return elements;
|
|
417
|
+
let root: XmlNode;
|
|
418
|
+
try {
|
|
419
|
+
root = parseXml(xmlContent);
|
|
420
|
+
} catch (e) {
|
|
421
|
+
console.log(` [WARN] XML parse error: ${(e as Error).message}`);
|
|
422
|
+
return elements;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const listviewChildSet = new Set<XmlNode>();
|
|
426
|
+
for (const node of iterNodes(root, 'node')) {
|
|
427
|
+
const cls = attr(node, 'class');
|
|
428
|
+
if (cls.includes('ListView') || cls.includes('GridView') || cls === 'List') {
|
|
429
|
+
for (const child of node.children) listviewChildSet.add(child);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const extractText = (node: XmlNode): string => {
|
|
434
|
+
const t = attr(node, 'text');
|
|
435
|
+
if (t) return t;
|
|
436
|
+
for (const child of iterNodes(node, 'node')) {
|
|
437
|
+
const ct = attr(child, 'text');
|
|
438
|
+
if (ct) return ct;
|
|
439
|
+
}
|
|
440
|
+
return '';
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
for (const node of iterNodes(root, 'node')) {
|
|
444
|
+
if (attr(node, 'enabled') !== 'true') continue;
|
|
445
|
+
const isClickable = attr(node, 'clickable') === 'true';
|
|
446
|
+
const isLongClickable = attr(node, 'long-clickable') === 'true';
|
|
447
|
+
const isListviewItem = listviewChildSet.has(node);
|
|
448
|
+
const isCheckable = attr(node, 'checkable') === 'true';
|
|
449
|
+
if (!(isClickable || isLongClickable || isListviewItem || isCheckable)) continue;
|
|
450
|
+
|
|
451
|
+
const bounds = attr(node, 'bounds');
|
|
452
|
+
let text = attr(node, 'text');
|
|
453
|
+
if (!text) text = extractText(node);
|
|
454
|
+
|
|
455
|
+
const baseInfo = {
|
|
456
|
+
class: attr(node, 'class'),
|
|
457
|
+
text,
|
|
458
|
+
'resource-id': attr(node, 'resource-id'),
|
|
459
|
+
'content-desc': attr(node, 'content-desc'),
|
|
460
|
+
bounds,
|
|
461
|
+
center: parseBoundsCenter(bounds),
|
|
462
|
+
};
|
|
463
|
+
if (isClickable || isListviewItem || isCheckable) {
|
|
464
|
+
elements.push({ ...baseInfo, 'long-clickable': false });
|
|
465
|
+
}
|
|
466
|
+
if (isLongClickable) {
|
|
467
|
+
elements.push({ ...baseInfo, 'long-clickable': true });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return elements;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// HarmonyOS: JSON → XML conversion
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
interface HmosNode {
|
|
477
|
+
attributes?: Record<string, unknown>;
|
|
478
|
+
children?: HmosNode[];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 把 HarmonyOS layout JSON 节点转成 XmlNode(等价 hmos_to_xml)。返回 null 表示被过滤。 */
|
|
482
|
+
function hmosToXml(node: HmosNode): XmlNode | null {
|
|
483
|
+
const attrs = (node.attributes ?? {}) as Record<string, unknown>;
|
|
484
|
+
const t = String(attrs['type'] ?? '');
|
|
485
|
+
const bundle = String(attrs['bundleName'] ?? '');
|
|
486
|
+
if (HMOS_SKIP_TYPES.has(t)) return null;
|
|
487
|
+
if (HMOS_SKIP_BUNDLES.has(bundle)) return null;
|
|
488
|
+
|
|
489
|
+
const get = (k: string, d = ''): string => (attrs[k] === undefined || attrs[k] === null ? d : String(attrs[k]));
|
|
490
|
+
const xmlAttrs: Record<string, string> = {
|
|
491
|
+
class: t,
|
|
492
|
+
'resource-id': get('key'),
|
|
493
|
+
text: get('text'),
|
|
494
|
+
'content-desc': get('description'),
|
|
495
|
+
clickable: get('clickable', 'false'),
|
|
496
|
+
'long-clickable': get('longClickable', 'false'),
|
|
497
|
+
scrollable: get('scrollable', 'false'),
|
|
498
|
+
enabled: get('enabled', 'true'),
|
|
499
|
+
focused: get('focused', 'false'),
|
|
500
|
+
bounds: get('bounds'),
|
|
501
|
+
package: bundle,
|
|
502
|
+
};
|
|
503
|
+
const el: XmlNode = { tag: 'node', attrs: xmlAttrs, children: [] };
|
|
504
|
+
for (const child of node.children ?? []) {
|
|
505
|
+
const childEl = hmosToXml(child);
|
|
506
|
+
if (childEl !== null) el.children.push(childEl);
|
|
507
|
+
}
|
|
508
|
+
return el;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function escapeXmlAttr(s: string): string {
|
|
512
|
+
return s
|
|
513
|
+
.replace(/&/g, '&')
|
|
514
|
+
.replace(/</g, '<')
|
|
515
|
+
.replace(/>/g, '>')
|
|
516
|
+
.replace(/"/g, '"');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** 序列化为 XML 字符串(无缩进,等价 ET.tostring(encoding="unicode"))。 */
|
|
520
|
+
function serializeXml(node: XmlNode): string {
|
|
521
|
+
const attrStr = Object.entries(node.attrs)
|
|
522
|
+
.map(([k, v]) => ` ${k}="${escapeXmlAttr(v)}"`)
|
|
523
|
+
.join('');
|
|
524
|
+
if (node.children.length === 0) {
|
|
525
|
+
return `<${node.tag}${attrStr} />`;
|
|
526
|
+
}
|
|
527
|
+
const inner = node.children.map((c) => serializeXml(c)).join('');
|
|
528
|
+
return `<${node.tag}${attrStr}>${inner}</${node.tag}>`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** 带缩进序列化并写文件(等价 ET.indent(space=" ") + tree.write(xml_declaration=True))。 */
|
|
532
|
+
function serializeXmlIndented(node: XmlNode, level = 0): string {
|
|
533
|
+
const pad = ' '.repeat(level);
|
|
534
|
+
const attrStr = Object.entries(node.attrs)
|
|
535
|
+
.map(([k, v]) => ` ${k}="${escapeXmlAttr(v)}"`)
|
|
536
|
+
.join('');
|
|
537
|
+
if (node.children.length === 0) {
|
|
538
|
+
return `${pad}<${node.tag}${attrStr} />`;
|
|
539
|
+
}
|
|
540
|
+
const inner = node.children.map((c) => serializeXmlIndented(c, level + 1)).join('\n');
|
|
541
|
+
return `${pad}<${node.tag}${attrStr}>\n${inner}\n${pad}</${node.tag}>`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// Android capture
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
function adbDumpXmlQuick(serial: string | null = null): string {
|
|
548
|
+
const remotePath = '/sdcard/ui_dump.xml';
|
|
549
|
+
adbShell(['rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml'], serial, 20);
|
|
550
|
+
for (let k = 0; k < 6; k++) {
|
|
551
|
+
const check = adbShell(['ls', remotePath], serial);
|
|
552
|
+
if (check.includes(remotePath)) break;
|
|
553
|
+
sleep(0.2);
|
|
554
|
+
}
|
|
555
|
+
return adbShell(['cat', remotePath], serial, 15);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function adbDumpXml(outputDir: string, serial: string | null = null): string {
|
|
559
|
+
const xmlContent = adbDumpXmlQuick(serial);
|
|
560
|
+
if (xmlContent) fs.writeFileSync(path.join(outputDir, 'view.xml'), xmlContent, 'utf-8');
|
|
561
|
+
return xmlContent;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function adbScreenshot(outputDir: string, filename = 'screenshot.png', serial: string | null = null): void {
|
|
565
|
+
const localPath = path.join(outputDir, filename);
|
|
566
|
+
const args: string[] = [];
|
|
567
|
+
if (serial) args.push('-s', serial);
|
|
568
|
+
args.push('exec-out', 'screencap', '-p');
|
|
569
|
+
const { stdoutBuf, code } = runCapture('adb', args, { timeout: 15, binary: true });
|
|
570
|
+
if (code === null) {
|
|
571
|
+
console.log(' [WARN] screenshot timeout');
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (stdoutBuf.length > 0) fs.writeFileSync(localPath, stdoutBuf);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
interface DensityInfo {
|
|
578
|
+
dpi: number;
|
|
579
|
+
density_factor: number;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Return device density info: {dpi, density_factor}.
|
|
584
|
+
* density_factor = dpi / 160 (Android baseline). 1dp = density_factor px.
|
|
585
|
+
* Used by the alignment pipeline to convert raw px bounds -> dp/vp correctly
|
|
586
|
+
* instead of assuming a fixed 3x. Falls back to dpi=480 (3x) if unreadable.
|
|
587
|
+
*/
|
|
588
|
+
function adbGetDensity(serial: string | null = null): DensityInfo {
|
|
589
|
+
let dpi: number | null = null;
|
|
590
|
+
const out = adbShell(['wm', 'density'], serial);
|
|
591
|
+
const mOver = out.match(/Override density:\s*(\d+)/);
|
|
592
|
+
const mPhys = out.match(/Physical density:\s*(\d+)/);
|
|
593
|
+
if (mOver) {
|
|
594
|
+
dpi = parseInt(mOver[1], 10);
|
|
595
|
+
} else if (mPhys) {
|
|
596
|
+
dpi = parseInt(mPhys[1], 10);
|
|
597
|
+
}
|
|
598
|
+
if (dpi === null) {
|
|
599
|
+
const prop = adbShell(['getprop', 'ro.sf.lcd_density'], serial).trim();
|
|
600
|
+
if (/^\d+$/.test(prop)) dpi = parseInt(prop, 10);
|
|
601
|
+
}
|
|
602
|
+
if (dpi === null) dpi = 480; // fallback: 3x
|
|
603
|
+
return { dpi, density_factor: Math.round((dpi / 160.0) * 10000) / 10000 };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function adbGetActivity(serial: string | null = null): Record<string, string> {
|
|
607
|
+
const info: Record<string, string> = {};
|
|
608
|
+
const actOut = adbShell(['dumpsys', 'activity', 'activities'], serial);
|
|
609
|
+
const winOut = adbShell(['dumpsys', 'window'], serial);
|
|
610
|
+
for (const line of actOut.split(/\r?\n/)) {
|
|
611
|
+
if (/ResumedActivity|topActivity/i.test(line)) {
|
|
612
|
+
info['resumed_activity'] = line.trim();
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
for (const line of winOut.split(/\r?\n/)) {
|
|
617
|
+
if (line.includes('mCurrentFocus')) {
|
|
618
|
+
info['current_focus'] = line.trim();
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
if (line.includes('mFocusedApp') && !('focused_app' in info)) {
|
|
622
|
+
info['focused_app'] = line.trim();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return info;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function adbScrollAndCapture(
|
|
629
|
+
outputDir: string,
|
|
630
|
+
containerBounds: [number, number, number, number],
|
|
631
|
+
maxScrolls: number,
|
|
632
|
+
initialXml: string,
|
|
633
|
+
serial: string | null = null,
|
|
634
|
+
): string[] {
|
|
635
|
+
const [x1, y1, x2, y2] = containerBounds;
|
|
636
|
+
const cx = Math.trunc((x1 + x2) / 2);
|
|
637
|
+
const height = y2 - y1;
|
|
638
|
+
const swipeFromY = y1 + Math.trunc(height * 0.7);
|
|
639
|
+
const swipeToY = y1 + Math.trunc(height * 0.3);
|
|
640
|
+
|
|
641
|
+
const xmlList: string[] = [];
|
|
642
|
+
let prevKeys: Set<string> | null = initialXml ? extractNodeKeys(initialXml) : null;
|
|
643
|
+
let scrollCount = 0;
|
|
644
|
+
|
|
645
|
+
for (let i = 0; i < maxScrolls; i++) {
|
|
646
|
+
adbShell(
|
|
647
|
+
['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(SCROLL_DURATION_MS)],
|
|
648
|
+
serial,
|
|
649
|
+
);
|
|
650
|
+
adbWaitIdle(serial);
|
|
651
|
+
const newXml = adbDumpXmlQuick(serial);
|
|
652
|
+
const newKeys = extractNodeKeys(newXml);
|
|
653
|
+
if (prevKeys !== null && setsEqual(newKeys, prevKeys)) {
|
|
654
|
+
console.log(` Reached bottom after ${i + 1} scroll(s)`);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
prevKeys = newKeys;
|
|
658
|
+
scrollCount += 1;
|
|
659
|
+
xmlList.push(newXml);
|
|
660
|
+
fs.writeFileSync(path.join(outputDir, `view_scroll_${i + 1}.xml`), newXml, 'utf-8');
|
|
661
|
+
adbScreenshot(outputDir, `screenshot_scroll_${i + 1}.png`, serial);
|
|
662
|
+
console.log(` Scroll ${i + 1}: XML + screenshot saved`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (scrollCount > 0) {
|
|
666
|
+
console.log(` Restoring initial position: scrolling back ${scrollCount} time(s)...`);
|
|
667
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
668
|
+
adbShell(
|
|
669
|
+
['input', 'swipe', String(cx), String(swipeToY), String(cx), String(swipeFromY), String(SCROLL_DURATION_MS)],
|
|
670
|
+
serial,
|
|
671
|
+
);
|
|
672
|
+
adbWaitIdle(serial);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return xmlList;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function dedupeClickable(allXmlContents: string[]): ClickableElement[] {
|
|
679
|
+
const seen = new Set<string>();
|
|
680
|
+
const clickable: ClickableElement[] = [];
|
|
681
|
+
allXmlContents.forEach((xc, idx) => {
|
|
682
|
+
for (const elem of getClickableElements(xc)) {
|
|
683
|
+
const key = [
|
|
684
|
+
elem['resource-id'],
|
|
685
|
+
elem.text,
|
|
686
|
+
elem['content-desc'],
|
|
687
|
+
elem.class,
|
|
688
|
+
elem.bounds,
|
|
689
|
+
String(elem['long-clickable']),
|
|
690
|
+
].join('');
|
|
691
|
+
if (!seen.has(key)) {
|
|
692
|
+
seen.add(key);
|
|
693
|
+
elem.scroll_index = idx;
|
|
694
|
+
clickable.push(elem);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
return clickable;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function captureAndroid(outputDir: string, serial: string | null = null, maxScrolls = 10): void {
|
|
702
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
703
|
+
console.log('[1/4] Dumping view XML...');
|
|
704
|
+
const xmlContent = adbDumpXml(outputDir, serial);
|
|
705
|
+
if (!xmlContent) {
|
|
706
|
+
console.log('[ERR] Failed to dump XML');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
console.log('[2/4] Taking screenshot...');
|
|
710
|
+
adbScreenshot(outputDir, 'screenshot.png', serial);
|
|
711
|
+
|
|
712
|
+
console.log('[3/4] Checking scrollable containers...');
|
|
713
|
+
const containers = findScrollableContainers(xmlContent);
|
|
714
|
+
const allXmlContents = [xmlContent];
|
|
715
|
+
if (containers.length > 0) {
|
|
716
|
+
const container = containers[0];
|
|
717
|
+
console.log(` Found scrollable: ${container.class} ${container['resource-id']}`);
|
|
718
|
+
const scrollXmls = adbScrollAndCapture(outputDir, container.bounds, maxScrolls, xmlContent, serial);
|
|
719
|
+
allXmlContents.push(...scrollXmls);
|
|
720
|
+
console.log(` Scroll capture done: ${scrollXmls.length} scroll(s)`);
|
|
721
|
+
} else {
|
|
722
|
+
console.log(' No scrollable container found');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
console.log('[4/4] Building meta.json...');
|
|
726
|
+
const density = adbGetDensity(serial);
|
|
727
|
+
console.log(` Density: ${density.dpi}dpi (${density.density_factor}x)`);
|
|
728
|
+
const activityInfo = adbGetActivity(serial);
|
|
729
|
+
const clickable = dedupeClickable(allXmlContents);
|
|
730
|
+
const meta = {
|
|
731
|
+
timestamp: new Date().toISOString(),
|
|
732
|
+
device: 'adb',
|
|
733
|
+
dpi: density.dpi,
|
|
734
|
+
density_factor: density.density_factor,
|
|
735
|
+
activity_info: activityInfo,
|
|
736
|
+
clickable_count: clickable.length,
|
|
737
|
+
clickable_elements: clickable,
|
|
738
|
+
};
|
|
739
|
+
fs.writeFileSync(path.join(outputDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
|
|
740
|
+
console.log(`\nDone! Files saved to ${outputDir}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// HarmonyOS capture
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
function hdcDumpLayout(outputDir: string): { xml: string; pageName: string } {
|
|
747
|
+
const out = hdcShell(['uitest dumpLayout']);
|
|
748
|
+
const match = out.match(/saved to:(.+\.json)/);
|
|
749
|
+
if (!match) throw new Error(`Cannot parse dumpLayout output: ${out}`);
|
|
750
|
+
const remoteJson = match[1].trim();
|
|
751
|
+
|
|
752
|
+
const localJson = path.join(outputDir, '_tmp_layout.json');
|
|
753
|
+
hdc(['file', 'recv', remoteJson, localJson]);
|
|
754
|
+
|
|
755
|
+
const data = JSON.parse(fs.readFileSync(localJson, 'utf-8')) as HmosNode;
|
|
756
|
+
|
|
757
|
+
let pageName = '';
|
|
758
|
+
if (data.children && data.children.length > 0) {
|
|
759
|
+
const pp = String(data.children[0].attributes?.['pagePath'] ?? '');
|
|
760
|
+
pageName = pp ? pp.split('/').pop() || 'unknown' : 'unknown';
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const rootEl = hmosToXml(data);
|
|
764
|
+
const xmlPath = path.join(outputDir, 'view.xml');
|
|
765
|
+
if (rootEl) {
|
|
766
|
+
const doc = `<?xml version='1.0' encoding='utf-8'?>\n${serializeXmlIndented(rootEl)}`;
|
|
767
|
+
fs.writeFileSync(xmlPath, doc, 'utf-8');
|
|
768
|
+
}
|
|
769
|
+
const xmlStr = rootEl ? serializeXml(rootEl) : '';
|
|
770
|
+
|
|
771
|
+
fs.rmSync(localJson, { force: true });
|
|
772
|
+
return { xml: xmlStr, pageName };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function hdcDumpLayoutQuick(outputDir: string): string {
|
|
776
|
+
const out = hdcShell(['uitest dumpLayout']);
|
|
777
|
+
const match = out.match(/saved to:(.+\.json)/);
|
|
778
|
+
if (!match) return '';
|
|
779
|
+
const remoteJson = match[1].trim();
|
|
780
|
+
const localJson = path.join(outputDir, '_tmp_layout.json');
|
|
781
|
+
hdc(['file', 'recv', remoteJson, localJson]);
|
|
782
|
+
try {
|
|
783
|
+
const data = JSON.parse(fs.readFileSync(localJson, 'utf-8')) as HmosNode;
|
|
784
|
+
const rootEl = hmosToXml(data);
|
|
785
|
+
return rootEl ? serializeXml(rootEl) : '';
|
|
786
|
+
} finally {
|
|
787
|
+
if (fs.existsSync(localJson)) fs.rmSync(localJson, { force: true });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function hdcScreenshot(outputDir: string, filename = 'screenshot.jpeg'): void {
|
|
792
|
+
const remoteImg = '/data/local/tmp/screenshot.jpeg';
|
|
793
|
+
hdcShell([`snapshot_display -f ${remoteImg}`]);
|
|
794
|
+
hdc(['file', 'recv', remoteImg, path.join(outputDir, filename)]);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function hdcScrollAndCapture(
|
|
798
|
+
outputDir: string,
|
|
799
|
+
containerBounds: [number, number, number, number],
|
|
800
|
+
maxScrolls: number,
|
|
801
|
+
initialXml: string,
|
|
802
|
+
): string[] {
|
|
803
|
+
const [x1, y1, x2, y2] = containerBounds;
|
|
804
|
+
const cx = Math.trunc((x1 + x2) / 2);
|
|
805
|
+
const height = y2 - y1;
|
|
806
|
+
const swipeFromY = y1 + Math.trunc(height * 0.7);
|
|
807
|
+
const swipeToY = y1 + Math.trunc(height * 0.3);
|
|
808
|
+
|
|
809
|
+
const xmlList: string[] = [];
|
|
810
|
+
let prevKeys: Set<string> | null = initialXml ? extractNodeKeys(initialXml) : null;
|
|
811
|
+
let scrollCount = 0;
|
|
812
|
+
|
|
813
|
+
for (let i = 0; i < maxScrolls; i++) {
|
|
814
|
+
hdcShell(['uitest', 'uiInput', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(HDC_SWIPE_SPEED)]);
|
|
815
|
+
hdcWaitIdle();
|
|
816
|
+
const newXml = hdcDumpLayoutQuick(outputDir);
|
|
817
|
+
const newKeys = extractNodeKeys(newXml);
|
|
818
|
+
if (prevKeys !== null && setsEqual(newKeys, prevKeys)) {
|
|
819
|
+
console.log(` Reached bottom after ${i + 1} scroll(s)`);
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
prevKeys = newKeys;
|
|
823
|
+
scrollCount += 1;
|
|
824
|
+
xmlList.push(newXml);
|
|
825
|
+
fs.writeFileSync(path.join(outputDir, `view_scroll_${i + 1}.xml`), newXml, 'utf-8');
|
|
826
|
+
hdcScreenshot(outputDir, `screenshot_scroll_${i + 1}.jpeg`);
|
|
827
|
+
console.log(` Scroll ${i + 1}: XML + screenshot saved`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (scrollCount > 0) {
|
|
831
|
+
console.log(` Restoring initial position: scrolling back ${scrollCount} time(s)...`);
|
|
832
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
833
|
+
hdcShell(['uitest', 'uiInput', 'swipe', String(cx), String(swipeToY), String(cx), String(swipeFromY), String(HDC_SWIPE_SPEED)]);
|
|
834
|
+
hdcWaitIdle();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return xmlList;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function captureHdc(outputDir: string, maxScrolls = 10): void {
|
|
841
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
842
|
+
console.log('[1/3] Dumping layout (JSON -> XML)...');
|
|
843
|
+
const { xml: xmlContent, pageName } = hdcDumpLayout(outputDir);
|
|
844
|
+
if (!xmlContent) {
|
|
845
|
+
console.log('[ERR] Failed to dump layout');
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
console.log(` Page: ${pageName}`);
|
|
849
|
+
|
|
850
|
+
console.log('[2/3] Taking screenshot...');
|
|
851
|
+
hdcScreenshot(outputDir);
|
|
852
|
+
|
|
853
|
+
console.log('[3/3] Checking scrollable containers...');
|
|
854
|
+
const containers = findScrollableContainers(xmlContent);
|
|
855
|
+
if (containers.length > 0) {
|
|
856
|
+
const container = containers[0];
|
|
857
|
+
console.log(` Found scrollable: ${container.class} ${container['resource-id']}`);
|
|
858
|
+
const scrollXmls = hdcScrollAndCapture(outputDir, container.bounds, maxScrolls, xmlContent);
|
|
859
|
+
console.log(` Scroll capture done: ${scrollXmls.length} scroll(s)`);
|
|
860
|
+
} else {
|
|
861
|
+
console.log(' No scrollable container found');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const density = hdcGetDensity();
|
|
865
|
+
console.log(` Density: ${density.dpi}dpi (${density.density_factor}x)`);
|
|
866
|
+
const meta = {
|
|
867
|
+
timestamp: new Date().toISOString(),
|
|
868
|
+
device: 'hdc',
|
|
869
|
+
dpi: density.dpi,
|
|
870
|
+
density_factor: density.density_factor,
|
|
871
|
+
page_name: pageName,
|
|
872
|
+
};
|
|
873
|
+
fs.writeFileSync(path.join(outputDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
|
|
874
|
+
|
|
875
|
+
console.log(`\nDone! Files saved to ${outputDir}`);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ---------------------------------------------------------------------------
|
|
879
|
+
// CLI
|
|
880
|
+
// ---------------------------------------------------------------------------
|
|
881
|
+
interface CliArgs {
|
|
882
|
+
device: 'hdc' | 'adb';
|
|
883
|
+
outputDir: string;
|
|
884
|
+
maxScrolls: number;
|
|
885
|
+
serial: string | null;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function parseArgs(argv: string[]): CliArgs {
|
|
889
|
+
const args: CliArgs = { device: 'hdc', outputDir: '', maxScrolls: 10, serial: null };
|
|
890
|
+
for (let i = 0; i < argv.length; i++) {
|
|
891
|
+
const a = argv[i];
|
|
892
|
+
const next = (): string => {
|
|
893
|
+
if (i + 1 >= argv.length) {
|
|
894
|
+
console.error(`Missing value for ${a}`);
|
|
895
|
+
process.exit(2);
|
|
896
|
+
}
|
|
897
|
+
return argv[++i];
|
|
898
|
+
};
|
|
899
|
+
if (a === '--device') {
|
|
900
|
+
const v = next();
|
|
901
|
+
if (v !== 'hdc' && v !== 'adb') {
|
|
902
|
+
console.error(`--device must be hdc or adb`);
|
|
903
|
+
process.exit(2);
|
|
904
|
+
}
|
|
905
|
+
args.device = v;
|
|
906
|
+
} else if (a === '--output-dir' || a === '-o') {
|
|
907
|
+
args.outputDir = next();
|
|
908
|
+
} else if (a === '--max-scrolls') {
|
|
909
|
+
args.maxScrolls = parseInt(next(), 10);
|
|
910
|
+
} else if (a === '--serial') {
|
|
911
|
+
args.serial = next();
|
|
912
|
+
} else if (a === '-h' || a === '--help') {
|
|
913
|
+
printHelp();
|
|
914
|
+
process.exit(0);
|
|
915
|
+
} else {
|
|
916
|
+
console.error(`Unknown argument: ${a}`);
|
|
917
|
+
process.exit(2);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (!args.outputDir) {
|
|
921
|
+
console.error('error: --output-dir/-o is required');
|
|
922
|
+
process.exit(2);
|
|
923
|
+
}
|
|
924
|
+
return args;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function printHelp(): void {
|
|
928
|
+
console.log(
|
|
929
|
+
[
|
|
930
|
+
'Capture current page: view tree XML + screenshots (Android / HarmonyOS)',
|
|
931
|
+
'',
|
|
932
|
+
'Usage: node page_capture.ts --device <hdc|adb> -o <dir> [--max-scrolls N] [--serial S]',
|
|
933
|
+
'',
|
|
934
|
+
' --device Device type: hdc=HarmonyOS, adb=Android (default: hdc)',
|
|
935
|
+
' --output-dir,-o Output directory for captured files (required)',
|
|
936
|
+
' --max-scrolls Max scroll count per container (default: 10)',
|
|
937
|
+
' --serial ADB device serial (optional, for multi-device)',
|
|
938
|
+
].join('\n'),
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function main(): number {
|
|
943
|
+
const args = parseArgs(process.argv.slice(2));
|
|
944
|
+
if (args.device === 'adb') {
|
|
945
|
+
captureAndroid(args.outputDir, args.serial, args.maxScrolls);
|
|
946
|
+
} else {
|
|
947
|
+
captureHdc(args.outputDir, args.maxScrolls);
|
|
948
|
+
}
|
|
949
|
+
return 0;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// 仅当作为入口脚本直接运行时执行 main;被 import(测试)时不执行。
|
|
953
|
+
const isEntry = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
954
|
+
if (isEntry) {
|
|
955
|
+
process.exit(main());
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// 导出纯函数供测试(Node 直接 run .ts 时 import.meta 存在,import 本文件即可拿到)
|
|
959
|
+
export {
|
|
960
|
+
parseXml,
|
|
961
|
+
iterNodes,
|
|
962
|
+
iterAll,
|
|
963
|
+
findScrollableContainers,
|
|
964
|
+
extractNodeKeys,
|
|
965
|
+
parseBoundsCenter,
|
|
966
|
+
getClickableElements,
|
|
967
|
+
hmosToXml,
|
|
968
|
+
serializeXml,
|
|
969
|
+
serializeXmlIndented,
|
|
970
|
+
dedupeClickable,
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
|