#!/usr/bin/env node /* * BFS view-tree dump → ui_elements.json (TCG schema) + page.parsed.json (display_texts). * * 靠 Node 原生类型擦除运行(Node ≥ 22.18 / 23.6 直接 `node convert_to_ui_elements.ts`),无需编译、无依赖。 * * * REAL per-edge directed flows synthesized from click_path via PREFIX reconstruction * keyed on (element, center) — deterministic, collision-proof. * * structural gaps[] emission (0-text-element / unresolved-flow). * * app-agnostic: app_name/package/description from CLI or inferred; NO business literals. * * honest locators: text / bounds only; never fabricate id / source_location / platform_path. * * bounds are emitted as [left, top, right, bottom] (raw Android "[x1,y1][x2,y2]"). * page.parsed.json (display_texts for assertion targets) written per-page into //. */ import * as fs from "node:fs"; import * as path from "node:path"; type Dict = Record; function load(p: string): Dict { return JSON.parse(fs.readFileSync(p, "utf-8")); } function isDict(v: unknown): v is Dict { return typeof v === "object" && v !== null && !Array.isArray(v); } function commaInt(n: number): string { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); } // ---- XML parse + page extraction ---- function sortCopy(arr: string[]): string[] { return [...arr].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); } function actSimple(meta: Dict): string { const info = isDict(meta["activity_info"]) ? (meta["activity_info"] as Dict) : {}; const s = (info["resumed_activity"] as string) || (info["current_focus"] as string) || ""; const m = s.match(/[\w.]+\/([\w.]+)/); if (m) { const parts = m[1].split("."); return parts[parts.length - 1]; } return "unknown"; } function inferPackage(meta: Dict): string | null { const info = isDict(meta["activity_info"]) ? (meta["activity_info"] as Dict) : {}; const s = (info["resumed_activity"] as string) || (info["current_focus"] as string) || ""; const m = s.match(/([\w.]+)\//); return m ? m[1] : null; } function boundsArr(b: unknown): number[] { const m = (typeof b === "string" ? b : "").match(/-?\d+/g) || []; return m.length === 4 ? m.map((x) => parseInt(x, 10)) : [0, 0, 0, 0]; } function dedupClickables(ce: Dict[]): Dict[] { const seen = new Set(); const out: Dict[] = []; for (const e of ce) { const k = JSON.stringify([ e["class"] ?? null, e["resource-id"] ?? null, e["text"] ?? null, e["content-desc"] ?? null, e["bounds"] ?? null, ]); if (seen.has(k)) continue; seen.add(k); out.push(e); } return out; } // ---- 最小 XML 遍历器:前序、root 深度 0,仅取属性(uiautomator dump 把字段存为属性)---- const ENTITIES: Record = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'", }; function unescapeXml(s: string): string { return s.replace(/&(?:amp|lt|gt|quot|apos);|&#x?[0-9A-Fa-f]+;/g, (m) => { if (m in ENTITIES) return ENTITIES[m]; if (m[1] === "#") { const hex = m[2] === "x" || m[2] === "X"; const code = parseInt(m.slice(hex ? 3 : 2, -1), hex ? 16 : 10); return String.fromCodePoint(code); } return m; }); } interface XmlNode { attrs: Record; depth: number; } function parseXmlNodes(xml: string): XmlNode[] { const nodes: XmlNode[] = []; const tagRe = /<(\/)?([A-Za-z_][\w:.\-]*)((?:\s+[\w:.\-]+\s*=\s*"[^"]*")*)\s*(\/)?>/g; const attrRe = /([\w:.\-]+)\s*=\s*"([^"]*)"/g; let depth = -1; let m: RegExpExecArray | null; while ((m = tagRe.exec(xml)) !== null) { const isClose = m[1] === "/"; const attrStr = m[3] || ""; const selfClose = m[4] === "/"; if (isClose) { depth -= 1; continue; } depth += 1; const attrs: Record = {}; let am: RegExpExecArray | null; attrRe.lastIndex = 0; while ((am = attrRe.exec(attrStr)) !== null) { attrs[am[1]] = unescapeXml(am[2]); } nodes.push({ attrs, depth }); if (selfClose) depth -= 1; } return nodes; } interface TextNode { text: string; bounds: string; clickable: boolean; depth: number; class: string; } function viewTextNodes(viewPath: string): TextNode[] { if (!fs.existsSync(viewPath)) return []; let xml: string; try { xml = fs.readFileSync(viewPath, "utf-8"); } catch { return []; } let parsed: XmlNode[]; try { parsed = parseXmlNodes(xml); } catch { return []; } const out: TextNode[] = []; for (const n of parsed) { const t = (n.attrs["text"] || "").trim(); if (t) { out.push({ text: t, bounds: n.attrs["bounds"] || "", clickable: n.attrs["clickable"] === "true", depth: n.depth, class: n.attrs["class"] || "", }); } } return out; } function listViewXmls(pdir: string, meta: Dict): string[] { const rels = (meta["view_xmls"] as string[]) || []; const paths: string[] = []; for (const r of rels) { const cand = path.join(pdir, path.basename(r)); if (fs.existsSync(cand)) paths.push(cand); } if (paths.length === 0) { const entries = fs.existsSync(pdir) ? fs.readdirSync(pdir) : []; const hits: string[] = []; for (const name of entries) { if (name === "view.xml" || /^view_scroll_.*\.xml$/.test(name)) { hits.push(path.join(pdir, name)); } } return sortCopy(hits); } return paths; } function groupDisplayText(pdir: string, meta: Dict): Array<{ text: string; bounds: string }> { const rows: Array<{ text: string; bounds: string; _y: number }> = []; const seen = new Set(); for (const vp of listViewXmls(pdir, meta)) { for (const n of viewTextNodes(vp)) { if (n.clickable) continue; const t = n.text; if (seen.has(t)) continue; seen.add(t); rows.push({ text: t, bounds: n.bounds, _y: boundsArr(n.bounds)[1] }); } } rows.sort((a, b) => a._y - b._y); return rows.map((r) => ({ text: r.text, bounds: r.bounds })); } function reprText(pdir: string, meta: Dict, k = 8): string[] { let nodes: TextNode[] = []; const frames = listViewXmls(pdir, meta).slice(0, 1); for (const vp of frames) nodes = viewTextNodes(vp); const yOf = (n: TextNode): number => boundsArr(n.bounds)[1]; const sorted = [...nodes].sort((a, b) => yOf(a) - yOf(b)); const cand = sorted.filter((n) => n.text.length > 0 && n.text.length <= 20); const seen = new Set(); const out: string[] = []; for (const n of cand) { if (seen.has(n.text)) continue; seen.add(n.text); out.push(n.text); if (out.length >= k) break; } return out; } function signature(act: string, clickables: Dict[]): string { const keys = sortCopy([...new Set( clickables.map((e) => (e["resource-id"] as string) || (e["text"] as string) || (e["content-desc"] as string) || ""), )]); return act + "|" + keys.join("|"); } // ---- TCG schema synthesis ---- function shortId(pid: string | null | undefined): string { const m = (pid || "").match(/page_(\d+)/); if (m) return "p" + m[1]; return (pid || "p").replace(/[^0-9A-Za-z]+/g, "_"); } function slug(t: string | null | undefined, i: number): string { let s = (t || "").trim().replace(/[^0-9A-Za-z一-鿿]+/gu, "_"); s = s.slice(0, 24); s = s.replace(/^_+/, "").replace(/_+$/, ""); return (s || "el") + `_${i}`; } function elemType(cls: string | null | undefined, text: unknown): string { cls = cls || ""; if (cls.includes("EditText")) return "TextInput"; if (cls.includes("Image") && !truthy(text)) return "Image"; return "Clickable"; } function truthy(v: unknown): boolean { if (v === null || v === undefined) return false; if (typeof v === "string") return v.length > 0; if (typeof v === "number") return v !== 0; if (typeof v === "boolean") return v; if (Array.isArray(v)) return v.length > 0; if (isDict(v)) return Object.keys(v).length > 0; return true; } function ctr(c: unknown): string | null { if (Array.isArray(c) && c.length === 2) return `${c[0]},${c[1]}`; return null; } function listStr(c: unknown): string { if (c === null || c === undefined) return "None"; if (Array.isArray(c)) return "[" + c.map((x) => String(x)).join(", ") + "]"; return String(c); } function pathKey(cp: unknown): string { const key: Array<[string, string | null]> = []; if (Array.isArray(cp)) { for (const s of cp) { if (!isDict(s)) continue; const name = (s["element"] as string) || (s["text"] as string) || ""; key.push([name, ctr(s["center"])]); } } return JSON.stringify(key); } function parseArgs(argv: string[]): Dict { const out: Dict = { dump: null, out: null, app_name: null, package: null, description: null, }; const map: Record = { "--dump": "dump", "--out": "out", "--app-name": "app_name", "--package": "package", "--description": "description", }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a in map) { out[map[a]] = argv[++i]; } } for (const req of ["dump", "out"]) { if (!out[req]) { console.error(`convert_to_ui_elements.ts: error: the following arguments are required: --${req}`); process.exit(2); } } return out; } interface PageRec { pid: string; act: string; canonical: string; isDup: boolean; repr_text: string[]; clickable_elements: Dict[]; click_path: unknown[]; display_texts: Array<{ text: string; bounds: string }>; element_count: number; } function main(): void { const args = parseArgs(process.argv.slice(2)); const dump = args.dump as string; // ---- parse all pages in-memory ---- const entries = fs.existsSync(dump) ? fs.readdirSync(dump) : []; const pageDirs = sortCopy( entries .filter((name) => name.startsWith("page_")) .map((name) => path.join(dump, name)) .filter((d) => { try { return fs.statSync(d).isDirectory() && fs.existsSync(path.join(d, "meta.json")); } catch { return false; } }), ); if (pageDirs.length === 0) { console.error(`no page_*/meta.json under ${dump}`); process.exit(1); } const allPages: PageRec[] = []; const sigCanonical = new Map(); let pkg = args.package as string | null; let totDisp = 0; for (const pdir of pageDirs) { const pid = path.basename(pdir); const meta = load(path.join(pdir, "meta.json")); if (pkg === null || pkg === undefined) pkg = inferPackage(meta); const act = actSimple(meta); const ceRaw = dedupClickables((meta["clickable_elements"] as Dict[]) || []); const ce: Dict[] = ceRaw.map((e) => ({ class: e["class"] ?? null, text: ((e["text"] as string) || "").trim(), resource_id: ((e["resource-id"] as string) || "").trim(), content_desc: ((e["content-desc"] as string) || "").trim(), bounds: e["bounds"] ?? null, center: e["center"] ?? null, long_clickable: "long-clickable" in e ? e["long-clickable"] : false, })); const disp = groupDisplayText(pdir, meta); const rep = reprText(pdir, meta); totDisp += disp.length; const sig = signature(act, ceRaw); const isDup = sigCanonical.has(sig); const canonical = isDup ? sigCanonical.get(sig)! : pid; if (!isDup) sigCanonical.set(sig, pid); // side-effect: page.parsed.json (display_texts for _display_texts_ref) const parsed: Dict = { page_id: pid, activity: act, clickable_elements: ce, display_texts: disp, display_text_count: disp.length, }; fs.writeFileSync(path.join(pdir, "page.parsed.json"), JSON.stringify(parsed, null, 2), "utf-8"); allPages.push({ pid, act, canonical, isDup, repr_text: rep, clickable_elements: ce, click_path: (meta["click_path"] as unknown[]) || [], display_texts: disp, element_count: ce.length, }); } // ---- canonical short-id mapping ---- const canonShort: Record = {}; for (const p of allPages) { canonShort[p.pid] = shortId(p.canonical); } // ---- build canonical pages with honest elements ---- const pages: Record = {}; const centerIndex: Record> = {}; let hasResourceId = false; const acts = new Set(); for (const p of allPages) { if (p.isDup) continue; const sid = shortId(p.pid); acts.add(p.act); const elements: Dict[] = []; const cmap: Record = {}; const clickables = p.clickable_elements; for (let i = 0; i < clickables.length; i++) { const e = clickables[i]; if (((e["resource_id"] as string) || "").trim()) hasResourceId = true; const t = (e["text"] as string) || (e["content_desc"] as string); const eid = truthy(t) ? slug(t, i) : `icon_${i}`; let locator: Dict; let name: string; if (truthy(t)) { locator = { type: "text", value: t }; name = t; } else { locator = { type: "bounds", value: (e["bounds"] as string) || "" }; name = `icon@${listStr(e["center"])}`; } const el: Dict = { element_id: eid, element_name: name, element_type: elemType(e["class"] as string, t), locator: locator, bounds: boundsArr(e["bounds"]), source: "bfs-runtime", }; elements.push(el); const c = ctr(e["center"]); if (c !== null) cmap[c] = eid; } pages[sid] = { page_id: sid, page_name: p.pid, page_name_cn: p.repr_text.length ? p.repr_text[0] : p.pid, component_type: "Screen", component_name: p.act, description: null, repr_text: p.repr_text, elements: elements, _bfs_page_id: p.pid, _display_texts_ref: `${p.pid}/page.parsed.json#display_texts`, }; centerIndex[sid] = cmap; } // ---- path_key index for prefix reconstruction ---- const pathIndex: Record = {}; for (const p of allPages) { const k = pathKey(p.click_path); if (!(k in pathIndex)) pathIndex[k] = canonShort[p.pid]; } // ---- synthesize real per-edge directed flows ---- const flows: Dict[] = []; const flowSeen = new Set(); const gaps: Dict[] = []; let resolved = 0; let nonroot = 0; for (const p of allPages) { const cp = p.click_path; if (!cp || cp.length === 0) continue; nonroot += 1; const tgt = canonShort[p.pid]; const trigger = cp[cp.length - 1] as Dict; const prefix = cp.slice(0, -1); const src = pathIndex[pathKey(prefix)]; if (src === undefined) { gaps.push({ type: "flow", location: tgt, reason: "parent page (click_path prefix) not present in dump; edge source unresolved", }); continue; } resolved += 1; const tname = (trigger["text"] as string) || (trigger["element"] as string) || ""; const tc = ctr(trigger["center"]); let teid: string | undefined = tc !== null ? (centerIndex[src] || {})[tc] : undefined; if (teid === undefined) { const srcEls = (pages[src]?.["elements"] as Dict[]) || []; teid = slug(tname, srcEls.length); if (src in pages) { (pages[src]["elements"] as Dict[]).push({ element_id: teid, element_name: tname, element_type: "Clickable", locator: truthy(tname) ? { type: "text", value: tname } : { type: "bounds", value: "" }, bounds: boundsArr(trigger["bounds"]), source: "bfs-runtime-clicked", }); if (tc !== null) centerIndex[src][tc] = teid; } } const edgeKey = JSON.stringify([src, teid, tgt]); if (flowSeen.has(edgeKey)) continue; flowSeen.add(edgeKey); const n = flows.length; flows.push({ flow_id: `${tgt}_e${n}`, source_page_id: src, trigger_event: { event_id: `${tgt}_e${n}_click`, event_type: "CLICK", target_element_id: teid, description: truthy(tname) ? `点击「${tname}」` : "点击", }, target_page_id: tgt, transition_type: "Navigate_compose", source: "bfs-click-path", }); } // ---- structural gaps: 0-text-element pages ---- for (const sid of Object.keys(pages)) { const pg = pages[sid]; const els = pg["elements"] as Dict[]; const textLocatable = els.filter((e) => (e["locator"] as Dict)["type"] === "text").length; if (textLocatable === 0) { gaps.push({ type: "element", location: sid, reason: "page has 0 text-locatable clickables after dedup; only icon-only/vision-pending controls", }); } } const tech = (acts.size === 1 && !hasResourceId) ? "Jetpack Compose (single-Activity)" : "Android (runtime view-tree)"; const out: Dict = { app_info: { app_name: args.app_name ?? null, package_name: pkg, tech_stack: tech, description: args.description ?? null, source: "BFS view-tree (mechanical); locators are text/bounds runtime truth, no source paths", }, pages: Object.values(pages), flows: flows, gaps: gaps, extraction_note: "Runtime BFS-derived. No platform_path/source_location/id locator " + "(view-tree has no stable resource-id). display_texts (assertion targets) " + "live per-page in page.parsed.json (see _display_texts_ref).", }; const outPath = args.out as string; fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true }); fs.writeFileSync(outPath, JSON.stringify(out, null, 2), "utf-8"); const cov = nonroot ? (100.0 * resolved) / nonroot : 100.0; const bytes = fs.statSync(outPath).size; console.log("complete"); console.log(` pages total = ${allPages.length} (canonical ${sigCanonical.size}, duplicate ${allPages.length - sigCanonical.size})`); console.log(` flows (edges) = ${flows.length}`); console.log(` gaps = ${gaps.length}`); console.log(` tech_stack = ${tech}`); console.log(` edge resolution = ${resolved}/${nonroot} = ${cov.toFixed(1)}% (acceptance >=90%)`); console.log(` package = ${pkg}`); console.log(` display_texts = ${totDisp} (scroll-aggregated assertion targets)`); console.log(` out = ${outPath} (${commaInt(bytes)} bytes)`); } main();