dsh-mobilecode 0.6.0 → 0.7.0
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 +68 -6
- package/lib/client.js +286 -27
- package/lib/device-build.js +77 -0
- package/lib/index.js +514 -23
- package/lib/list-rows.js +295 -0
- package/package.json +1 -1
package/lib/list-rows.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — list/feed row detection for the semantic UI tools.
|
|
3
|
+
*
|
|
4
|
+
* Port of ZSeven-W/dsh-android (MIT) src/list-rows.ts: reads the flattened
|
|
5
|
+
* uiautomator node tree and detects visible list/feed ROWS — runs of >=3
|
|
6
|
+
* sibling subtrees of one parent that share a class and near-equal height —
|
|
7
|
+
* then aggregates each row's distinct labels and parses generic counters
|
|
8
|
+
* (e.g. "3万 粉丝", "1.2k likes") out of them.
|
|
9
|
+
*
|
|
10
|
+
* A row is only ever EVIDENCE of repetition: Android has no "Cell" type to
|
|
11
|
+
* key off, so MIN_REPEATED_ROWS = 3 (one more than the iOS twin's 2) with a
|
|
12
|
+
* height tolerance of max(8px, 15% of the shorter sibling). Rows shorter than
|
|
13
|
+
* 16px are dividers, rows taller than 90% of the screen are pages.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is pure (no adb, no DOM), so the offline suite drives the
|
|
16
|
+
* clustering, the counter grammar and the tap planning with XML fixtures.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { isOffscreenBounds } from "./uitree.js"
|
|
20
|
+
|
|
21
|
+
/** A run must repeat at least this many times to be a row. */
|
|
22
|
+
export const MIN_REPEATED_ROWS = 3
|
|
23
|
+
/** Height tolerance floor (px) for "near-equal" siblings. */
|
|
24
|
+
export const HEIGHT_TOLERANCE_PX = 8
|
|
25
|
+
/** Height fraction of the shorter sibling added to the tolerance. */
|
|
26
|
+
export const HEIGHT_TOLERANCE_FRACTION = 0.15
|
|
27
|
+
/** Rows this short are dividers, not data. */
|
|
28
|
+
export const DIVIDER_MAX_HEIGHT_PX = 16
|
|
29
|
+
/** Rows taller than this fraction of the screen are pages. */
|
|
30
|
+
export const PAGE_MAX_HEIGHT_FRACTION = 0.9
|
|
31
|
+
|
|
32
|
+
/** Number → multiplier tokens the counter grammar accepts (中/EN units). */
|
|
33
|
+
const COUNTER_MULTIPLIERS = { 万: 1e4, 亿: 1e8, w: 1e4, W: 1e4, k: 1e3, K: 1e3, m: 1e6, M: 1e6 }
|
|
34
|
+
/** Characters that end a counter classifier token. */
|
|
35
|
+
const COUNTER_TERMINATORS = "。;;!!??::…—–·•·。,,、【】「」『』()(){}[]<>《》\"'“”‘’"
|
|
36
|
+
|
|
37
|
+
export function isRowLike(bounds) {
|
|
38
|
+
if (bounds.w <= 0 || bounds.h <= 0) return false
|
|
39
|
+
if (bounds.h < DIVIDER_MAX_HEIGHT_PX) return false
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Near-equal height test with the 8px/15% tolerance (dsh-android rule). */
|
|
44
|
+
export function heightsAlike(a, b) {
|
|
45
|
+
const tolerance = Math.max(HEIGHT_TOLERANCE_PX, Math.min(a, b) * HEIGHT_TOLERANCE_FRACTION)
|
|
46
|
+
return Math.abs(a - b) <= tolerance
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cluster sibling subtrees under one parent into repeated-row runs. Returns an
|
|
51
|
+
* array of runs; each run is an array of node children that are consecutive
|
|
52
|
+
* siblings sharing one type and near-equal height.
|
|
53
|
+
*/
|
|
54
|
+
export function clusterSiblings(children) {
|
|
55
|
+
if (!Array.isArray(children) || children.length === 0) return []
|
|
56
|
+
const byType = new Map()
|
|
57
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
58
|
+
const node = children[i]
|
|
59
|
+
const key = node.type ?? "Node"
|
|
60
|
+
const list = byType.get(key)
|
|
61
|
+
if (list) list.push(node)
|
|
62
|
+
else byType.set(key, [node])
|
|
63
|
+
}
|
|
64
|
+
const runs = []
|
|
65
|
+
for (const sameType of byType.values()) {
|
|
66
|
+
sameType.sort((l, r) => l.bounds.y - r.bounds.y || l.bounds.x - r.bounds.x)
|
|
67
|
+
let run = [sameType[0]]
|
|
68
|
+
for (let i = 1; i < sameType.length; i += 1) {
|
|
69
|
+
const previous = run[run.length - 1]
|
|
70
|
+
if (heightsAlike(previous.bounds.h, sameType[i].bounds.h)) {
|
|
71
|
+
run.push(sameType[i])
|
|
72
|
+
} else {
|
|
73
|
+
if (run.length >= MIN_REPEATED_ROWS) runs.push(run)
|
|
74
|
+
run = [sameType[i]]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (run.length >= MIN_REPEATED_ROWS) runs.push(run)
|
|
78
|
+
}
|
|
79
|
+
// Yield the largest runs first (stable): bigger rows are more likely the
|
|
80
|
+
// list; the caller collapses nested runs to the outermost.
|
|
81
|
+
return runs.sort((l, r) => r.length - l.length)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when `outer` strictly contains `inner` (bounds-inside-bounds). */
|
|
85
|
+
export function strictlyContains(outer, inner) {
|
|
86
|
+
return (
|
|
87
|
+
outer.x <= inner.x
|
|
88
|
+
&& outer.y <= inner.y
|
|
89
|
+
&& outer.x + outer.w >= inner.x + inner.w
|
|
90
|
+
&& outer.y + outer.h >= inner.y + inner.h
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every label (text or content-desc) under a subtree, in document order. */
|
|
95
|
+
function subtreeLabels(node, out = []) {
|
|
96
|
+
if (node.text !== undefined && node.text !== "") out.push(node.text)
|
|
97
|
+
if (node.contentDesc !== undefined && node.contentDesc !== "") out.push(node.contentDesc)
|
|
98
|
+
for (const child of node.children ?? []) subtreeLabels(child, out)
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Distinct labels in first-seen order, joined with spaces (row title text). */
|
|
103
|
+
export function aggregateRowLabel(rowNode) {
|
|
104
|
+
const seen = new Set()
|
|
105
|
+
const parts = []
|
|
106
|
+
for (const label of subtreeLabels(rowNode)) {
|
|
107
|
+
if (!seen.has(label)) {
|
|
108
|
+
seen.add(label)
|
|
109
|
+
parts.push(label)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return parts.join(" ")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Normalize a counter key the way verification compares them. */
|
|
116
|
+
export function normalizeCountKey(key) {
|
|
117
|
+
return key
|
|
118
|
+
.replace(/\u00a0/g, " ")
|
|
119
|
+
.toLowerCase()
|
|
120
|
+
.replace(/\s+/g, " ")
|
|
121
|
+
.trim()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generic counter grammar over a label: "3万 粉丝" → {key:"粉丝", value:30000},
|
|
126
|
+
* "1.2k likes" → {key:"likes", value:1200}, "42" (bare) → skipped. Also skips
|
|
127
|
+
* digit-suffix serials like "1A215" (the trailing-digit guard).
|
|
128
|
+
*/
|
|
129
|
+
export function parseCounters(label) {
|
|
130
|
+
const rule = new RegExp(
|
|
131
|
+
`(\\d[\\d,]*(?:\\.\\d+)?)\\s*([万亿wmWkKmM]?)\\s*([^\\d${escapeRegExp(COUNTER_TERMINATORS)}]+)(?!\\d)`,
|
|
132
|
+
"gu",
|
|
133
|
+
)
|
|
134
|
+
const counters = []
|
|
135
|
+
const seen = new Set()
|
|
136
|
+
for (const match of label.matchAll(rule)) {
|
|
137
|
+
const number = Number.parseFloat(match[1].replace(/,/g, ""))
|
|
138
|
+
if (!Number.isFinite(number)) continue
|
|
139
|
+
const multiplier = COUNTER_MULTIPLIERS[match[2]] ?? 1
|
|
140
|
+
const rawKey = match[3].trim()
|
|
141
|
+
if (rawKey === "") continue
|
|
142
|
+
const key = normalizeCountKey(rawKey)
|
|
143
|
+
if (seen.has(key)) continue
|
|
144
|
+
seen.add(key)
|
|
145
|
+
counters.push({ key, value: number * multiplier, raw: match[0].trim() })
|
|
146
|
+
}
|
|
147
|
+
return counters
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True when a subtree is fully outside the screen bounds. */
|
|
151
|
+
export function rowOffscreen(node, screen) {
|
|
152
|
+
if (screen === undefined) return false
|
|
153
|
+
return isOffscreenBounds(node.bounds, screen)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function rowIsPage(rowNode, screen) {
|
|
157
|
+
if (screen === undefined) return false
|
|
158
|
+
return rowNode.bounds.h > screen.height * PAGE_MAX_HEIGHT_FRACTION
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Detect list/feed rows across the whole tree. Returns {rows, omittedOffscreen}
|
|
163
|
+
* where each row is {index, group, frame, label, counters, node}:
|
|
164
|
+
* - index: 0-based, document order (y then x);
|
|
165
|
+
* - group: an id shared by isomorphic rows (parent bounds + type);
|
|
166
|
+
* - frame: the row's pixel box {x,y,w,h};
|
|
167
|
+
* - label: the aggregated distinct label text;
|
|
168
|
+
* - counters: parsed {key, value, raw} entries.
|
|
169
|
+
* Nested runs collapse to the OUTERMOST candidate; same-frame duplicates
|
|
170
|
+
* collapse; off-screen recycled views are counted, not emitted.
|
|
171
|
+
*/
|
|
172
|
+
export function detectRows(roots, screen) {
|
|
173
|
+
// Collect candidate runs from every parent. Parents precede their children
|
|
174
|
+
// in the walk, so nested candidates are found after their outer run.
|
|
175
|
+
const candidates = []
|
|
176
|
+
const walk = (node) => {
|
|
177
|
+
if (Array.isArray(node.children) && node.children.length > 0) {
|
|
178
|
+
for (const run of clusterSiblings(node.children)) {
|
|
179
|
+
// The run's frame is the union of its items' boxes.
|
|
180
|
+
const frame = run.reduce(
|
|
181
|
+
(acc, item) => ({
|
|
182
|
+
x: Math.min(acc.x, item.bounds.x),
|
|
183
|
+
y: Math.min(acc.y, item.bounds.y),
|
|
184
|
+
w: Math.max(acc.w, item.bounds.x + item.bounds.w - Math.min(acc.x, item.bounds.x)),
|
|
185
|
+
h: Math.max(acc.h, item.bounds.y + item.bounds.h - Math.min(acc.y, item.bounds.y)),
|
|
186
|
+
}),
|
|
187
|
+
{ x: Infinity, y: Infinity, w: 0, h: 0 },
|
|
188
|
+
)
|
|
189
|
+
candidates.push({ run, frame })
|
|
190
|
+
}
|
|
191
|
+
for (const child of node.children) walk(child)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const root of roots) walk(root)
|
|
195
|
+
|
|
196
|
+
// Collapse nested runs to the OUTERMOST candidate (a row inside a row is one
|
|
197
|
+
// row); collapse same-frame duplicates; drop page-sized candidates. Runs are
|
|
198
|
+
// sorted longest-first with parents before children, so an outer run is
|
|
199
|
+
// already kept when its inner run is examined.
|
|
200
|
+
const kept = []
|
|
201
|
+
for (const candidate of candidates.sort((l, r) => r.run.length - l.run.length)) {
|
|
202
|
+
if (kept.some((other) => strictlyContains(other.frame, candidate.frame))) continue
|
|
203
|
+
if (kept.some((other) => other.frame.x === candidate.frame.x && other.frame.y === candidate.frame.y
|
|
204
|
+
&& other.frame.w === candidate.frame.w && other.frame.h === candidate.frame.h)) continue
|
|
205
|
+
if (screen !== undefined && rowIsPage(candidate.run[0], screen)) continue
|
|
206
|
+
kept.push(candidate)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Order rows y-then-x, assign index/group/label/counters.
|
|
210
|
+
const flattened = []
|
|
211
|
+
for (const candidate of kept) {
|
|
212
|
+
for (const node of candidate.run) flattened.push({ node })
|
|
213
|
+
}
|
|
214
|
+
flattened.sort((l, r) => l.node.bounds.y - r.node.bounds.y || l.node.bounds.x - r.node.bounds.x)
|
|
215
|
+
const groupById = new Map()
|
|
216
|
+
let nextGroup = 0
|
|
217
|
+
const rowFrame = (node) => ({ x: node.bounds.x, y: node.bounds.y, w: node.bounds.w, h: node.bounds.h })
|
|
218
|
+
const rows = []
|
|
219
|
+
let omittedOffscreen = 0
|
|
220
|
+
for (const entry of flattened) {
|
|
221
|
+
if (screen !== undefined && rowOffscreen(entry.node, screen)) { omittedOffscreen += 1; continue }
|
|
222
|
+
const node = entry.node
|
|
223
|
+
const groupKey = `${node.type}|${node.bounds.w}x${node.bounds.h}`
|
|
224
|
+
if (!groupById.has(groupKey)) groupById.set(groupKey, nextGroup++)
|
|
225
|
+
const label = aggregateRowLabel(node)
|
|
226
|
+
rows.push({
|
|
227
|
+
index: rows.length,
|
|
228
|
+
group: groupById.get(groupKey),
|
|
229
|
+
frame: rowFrame(node),
|
|
230
|
+
label,
|
|
231
|
+
counters: parseCounters(label),
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
return { rows, omittedOffscreen }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Plan a pixel tap inside row `index` at relative (0..1) x/y of the row frame.
|
|
239
|
+
* Out-of-range indices REFUSE (never clamp — a stale rows list must not tap
|
|
240
|
+
* the wrong control). Returns absolute pixel coordinates for `input tap`.
|
|
241
|
+
*/
|
|
242
|
+
export function planRowTap(rows, index, fractionX, fractionY) {
|
|
243
|
+
const row = rows[index]
|
|
244
|
+
if (row === undefined) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`row ${index} does not exist — the list shows ${rows.length} row${rows.length === 1 ? "" : "s"} now. Re-run device_ui_rows for fresh indices.`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
if (!(fractionX >= 0 && fractionX <= 1 && fractionY >= 0 && fractionY <= 1)) {
|
|
250
|
+
throw new Error(`row-relative x,y must be in 0..1 (got ${fractionX},${fractionY})`)
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
x: Math.round(row.frame.x + fractionX * row.frame.w),
|
|
254
|
+
y: Math.round(row.frame.y + fractionY * row.frame.h),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Expect-count verification: BEFORE a tap the key must already exist in the
|
|
260
|
+
* row's counters ("never probe a control"). After the tap + settle, the row is
|
|
261
|
+
* re-detected by index and the count must have changed by EXACTLY delta (1|-1),
|
|
262
|
+
* guarded by a frame-drift check so a reordered list never validates.
|
|
263
|
+
*/
|
|
264
|
+
export function verifyCountChange(beforeRow, afterRow, key, delta) {
|
|
265
|
+
if (beforeRow === undefined) return { verified: false, reason: "before-tap row is missing" }
|
|
266
|
+
if (afterRow === undefined) return { verified: false, reason: "row not found after the tap (list may have changed)" }
|
|
267
|
+
const before = beforeRow.counters.find((counter) => counter.key === key)
|
|
268
|
+
const after = afterRow.counters.find((counter) => counter.key === key)
|
|
269
|
+
if (before === undefined) return { verified: false, reason: `counter "${key}" absent from the row before the tap` }
|
|
270
|
+
if (after === undefined) return { verified: false, reason: `counter "${key}" disappeared after the tap` }
|
|
271
|
+
if (Math.abs(after.value - before.value) > Math.max(1, Math.abs(before.value) * 0.05)) {
|
|
272
|
+
return { verified: false, reason: `counter "${key}" moved by ${after.value - before.value} (expected ${delta})` }
|
|
273
|
+
}
|
|
274
|
+
if (after.value !== before.value + delta) {
|
|
275
|
+
return { verified: false, reason: `counter "${key}" moved by ${after.value - before.value} (expected ${delta})` }
|
|
276
|
+
}
|
|
277
|
+
return { verified: true, before: before.value, after: after.value }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Drift guard for row-list verification: the row at the same index must sit at
|
|
282
|
+
* the same screen position after the tap, or the list reordered and the
|
|
283
|
+
* counters are no longer comparable. Tolerance: max(8px, 25% of the row height)
|
|
284
|
+
* on the y offset — the dsh-android rows-stayed-put rule.
|
|
285
|
+
*/
|
|
286
|
+
export function rowsStayedPut(beforeRow, afterRow) {
|
|
287
|
+
if (beforeRow === undefined || afterRow === undefined) return false
|
|
288
|
+
const tolerance = Math.max(8, beforeRow.frame.h * 0.25)
|
|
289
|
+
return Math.abs(afterRow.frame.y - beforeRow.frame.y) <= tolerance
|
|
290
|
+
&& Math.abs(afterRow.frame.x - beforeRow.frame.x) <= tolerance
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function escapeRegExp(text) {
|
|
294
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
295
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobilecode",
|
|
3
3
|
"description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 24 agent tools (device_run, device_screen, device_ui_tree, device_tap_element, device_wait_for, device_scroll_to, device_input, device_intent, device_connect, device_pair_qr, device_perf, device_app_info, device_install, device_uninstall, device_reboot, device_log, live screen stream, multimodal screenshots) with one classified adb boundary and a Wi-Fi connect/pair QR flow in the Connection settings tab. Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|