@harmonyos-arkts/d2h 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +78 -44
  2. package/agents/android-to-hmos-00-orchestrator.md +34 -28
  3. package/agents/d2h.md +266 -95
  4. package/dist/bin/install-opencode.js +137 -43
  5. package/dist/src/install-opencode.js +44 -0
  6. package/dist/src/install-vscode.js +226 -0
  7. package/dist/src/toolchain.js +87 -0
  8. package/docs/environment-check.md +86 -0
  9. package/opencode/agents.json +1 -1
  10. package/opencode/vscode-d2h-package.json +7 -0
  11. package/opencode/vscode-d2h-plugin.mjs +29 -0
  12. package/package.json +6 -4
  13. package/schemas/task-envelope.schema.json +58 -4
  14. package/schemas/task-plan.schema.json +281 -63
  15. package/scripts/check-environment.mjs +321 -0
  16. package/skills/d2h-hypium-forge/SKILL.md +252 -0
  17. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
  18. package/skills/d2h-hypium-forge/references/official-api.md +135 -0
  19. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
  20. package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
  21. package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
  22. package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
  23. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
  24. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
  25. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
  26. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
  27. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
  28. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
  29. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
  30. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
  31. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
  32. package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
  33. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
  34. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
  35. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
  36. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
  37. package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
  38. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
  39. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
  40. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
  41. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
  42. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
  43. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
  44. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
  45. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
  46. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
  47. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
  48. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
  49. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
  50. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
  51. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
  52. package/skills/d2h-utg-static-generate/SKILL.md +73 -0
  53. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
  54. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
  55. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
  56. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
  57. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
  58. package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
  59. package/skills/harmonyos-migration-playbook/references/windows-environment.md +1 -1
  60. package/skills/managing-d2h-toolchain/SKILL.md +23 -0
  61. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
  62. package/skills/managing-d2h-toolchain/toolchain.json +16 -0
  63. package/skills/preparing-migration-workspace/SKILL.md +9 -7
  64. package/agents/d2h_v2.md +0 -351
  65. package/skills/preparing-migration-workspace/toolchain.json +0 -19
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from utg_common import (
10
+ UtgError,
11
+ atomic_write_json,
12
+ build_click_paths,
13
+ load_json,
14
+ normalize_facts,
15
+ summary,
16
+ validate_pair,
17
+ )
18
+ from build_page_json import build_page_plan, validate_page_plan
19
+
20
+
21
+ def parse_args() -> argparse.Namespace:
22
+ parser = argparse.ArgumentParser(
23
+ description="Build normalized utg.json and deterministic click_paths.json from AI source facts."
24
+ )
25
+ parser.add_argument("--facts", required=True, type=Path)
26
+ parser.add_argument("--project-root", required=True, type=Path)
27
+ parser.add_argument("--workspace", required=True, type=Path)
28
+ parser.add_argument("--force", action="store_true", help="replace existing generated outputs")
29
+ return parser.parse_args()
30
+
31
+
32
+ def main() -> int:
33
+ args = parse_args()
34
+ workspace = args.workspace.resolve()
35
+ project_root = args.project_root.resolve()
36
+ if not project_root.is_dir():
37
+ print(f"ERROR: project root is not a directory: {project_root}", file=sys.stderr)
38
+ return 2
39
+ outputs = [
40
+ workspace / "utg.json",
41
+ workspace / "click_paths.json",
42
+ workspace / "page.json",
43
+ ]
44
+ existing = [path for path in outputs if path.exists()]
45
+ if existing and not args.force:
46
+ print(
47
+ "ERROR: generated output already exists; use --force only for an authorized regeneration: "
48
+ + ", ".join(str(path) for path in existing),
49
+ file=sys.stderr,
50
+ )
51
+ return 2
52
+ try:
53
+ facts = load_json(args.facts.resolve())
54
+ utg, normalizations, unresolved = normalize_facts(facts, project_root)
55
+ click_paths = build_click_paths(utg)
56
+ errors = validate_pair(utg, click_paths, project_root)
57
+ if errors:
58
+ raise UtgError("generated pair failed validation:\n- " + "\n- ".join(errors))
59
+ page_plan = build_page_plan(
60
+ utg,
61
+ click_paths,
62
+ utg_path=str(outputs[0]),
63
+ click_paths_path=str(outputs[1]),
64
+ )
65
+ page_errors = validate_page_plan(
66
+ page_plan,
67
+ utg,
68
+ click_paths,
69
+ utg_path=str(outputs[0]),
70
+ click_paths_path=str(outputs[1]),
71
+ )
72
+ if page_errors:
73
+ raise UtgError("generated page plan failed validation:\n- " + "\n- ".join(page_errors))
74
+ report = {
75
+ "schema_version": "d2h-utg-ai-generation-report/v1",
76
+ "status": "valid",
77
+ "method": facts.get('graph', {}).get('method', 'ai-source-analysis'),
78
+ "facts": str(args.facts.resolve()),
79
+ "project_root": str(project_root),
80
+ "summary": summary(utg, click_paths),
81
+ "page_plan": page_plan["summary"],
82
+ "normalizations": normalizations,
83
+ "unresolved": unresolved,
84
+ }
85
+ if 'projection_summary' in facts.get('graph', {}):
86
+ report['projection'] = facts['graph']['projection_summary']
87
+ atomic_write_json(outputs[0], utg)
88
+ atomic_write_json(outputs[1], click_paths)
89
+ atomic_write_json(outputs[2], page_plan)
90
+ atomic_write_json(workspace / "generation-report.json", report)
91
+ # Interactive visualization — same renderer android-utg-static ships
92
+ # (extended for reconciliation semantics), so the output is comparable.
93
+ # It reads <workspace>/utg.json and writes <workspace>/utg.html. Best
94
+ # effort: a visualization failure must not fail the build.
95
+ html_script = Path(__file__).resolve().parent / "utg_html.py"
96
+ if html_script.exists():
97
+ result = subprocess.run(
98
+ [sys.executable, str(html_script), str(workspace)],
99
+ capture_output=True,
100
+ text=True,
101
+ )
102
+ if result.returncode != 0:
103
+ print(
104
+ f"WARN: utg.html generation failed: {result.stderr.strip()}",
105
+ file=sys.stderr,
106
+ )
107
+ except UtgError as exc:
108
+ print(f"ERROR: {exc}", file=sys.stderr)
109
+ return 2
110
+
111
+ counts = report["summary"]
112
+ print(
113
+ f"valid: nodes={counts['nodes']} edges={counts['edges']} "
114
+ f"reachable={counts['reachable']} unreachable={counts['unreachable']}"
115
+ )
116
+ print(outputs[0])
117
+ print(outputs[1])
118
+ print(outputs[2])
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ raise SystemExit(main())
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+ /** Standalone Android page-plan replay and capture using only Node and ADB. */
3
+
4
+ import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+
9
+ type JsonObject = Record<string, unknown>;
10
+ interface Selector { text?: string; text_any?: string[]; content_desc?: string; content_desc_any?: string[]; resource_id?: string; resource_id_any?: string[]; normalized_point?: [number, number] }
11
+ interface Expected { text_any?: string[]; text_all?: string[]; content_desc_any?: string[]; resource_id_any?: string[]; activity_any?: string[] }
12
+ interface PlanStep {
13
+ component?: string;
14
+ action: string; label?: string | null; label_source?: string | null; label_ambiguous?: boolean; nl_hint?: string;
15
+ selector?: Selector; direction?: string; distance?: number; duration_ms?: number; max_scrolls?: number;
16
+ target_id?: string; target_kind?: string; guard?: { kind?: string; text?: string } | null; mechanism?: string;
17
+ }
18
+ interface PlanPage {
19
+ node_id: string; kind: string; label: string; declared_at: string; required_state: string[]; expected: Expected;
20
+ reachable: boolean; capture: boolean; capture_key: string | null; depth: number | null; ui_sequence: string;
21
+ path_text: string | null; steps: PlanStep[]; skip_reason: string | null;
22
+ }
23
+ interface PagePlan {
24
+ schema_version: string;
25
+ source: { utg: string; click_paths: string; root: string; app: string; package: string; architecture: string; version_name: string; build_variant: string };
26
+ summary: { nodes: number; capture_planned: number; unreachable: number; excluded_by_kind: Record<string, number> };
27
+ pages: PlanPage[];
28
+ }
29
+ interface UiNode {
30
+ attrs: Record<string, string>; children: UiNode[]; parent: UiNode | null; text: string; desc: string; rid: string;
31
+ bounds: string; clickable: boolean; longClickable: boolean; scrollable: boolean; enabled: boolean; center: [number, number] | null;
32
+ }
33
+ interface CliArgs {
34
+ pageJson: string; outputWorkspace: string; packageName: string; device: string | null; adbPath: string;
35
+ only: Set<string> | null; satisfiedState: Set<string>; dryRun: boolean; allowPartial: boolean;
36
+ allowCoordinateFallback: boolean; settleMs: number; maxScrolls: number;
37
+ }
38
+
39
+ const SCHEMA_VERSION = 'd2h-page-plan/v1';
40
+ const NON_CAPTURE_KINDS = new Set(['nav-graph', 'sentinel', 'host-activity']);
41
+ const CONTAINMENT_MECHANISMS = new Set(['navhost-start', 'nav-graph-start', 'nav-component-start', 'fragment-embed']);
42
+
43
+ function sleep(ms: number): void { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
44
+ function asObject(value: unknown, where: string): JsonObject {
45
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${where} must be an object`);
46
+ return value as JsonObject;
47
+ }
48
+ function readJson(file: string): JsonObject {
49
+ try { return asObject(JSON.parse(fs.readFileSync(file, 'utf-8')), file); }
50
+ catch (error) { throw new Error(`cannot read JSON ${file}: ${error instanceof Error ? error.message : error}`); }
51
+ }
52
+ function atomicWriteJson(file: string, value: unknown): void {
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}`);
55
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf-8'); fs.renameSync(temp, file);
56
+ }
57
+ function slug(nodeId: string): string {
58
+ const readable = nodeId.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._-]+|[._-]+$/g, '') || 'page';
59
+ return `${readable.slice(0, 72)}-${createHash('sha256').update(nodeId).digest('hex').slice(0, 8)}`;
60
+ }
61
+ function resolveSource(planFile: string, value: string): string { return path.resolve(path.isAbsolute(value) ? value : path.join(path.dirname(planFile), value)); }
62
+ function nonCaptureReason(node: JsonObject, links: JsonObject[], steps: PlanStep[] = []): string | null {
63
+ const kind = String(node.kind ?? '');
64
+ if (NON_CAPTURE_KINDS.has(kind)) return `non-capture-kind:${kind}`;
65
+ if (kind === 'activity' && links.some((e) => e.source === node.id && CONTAINMENT_MECHANISMS.has(String(e.mechanism ?? '')))) return 'container-node:activity';
66
+ if ((node.extra as JsonObject | null)?.capture_policy === 'strict') {
67
+ const expected = (node.expected ?? {}) as JsonObject;
68
+ if (!['text_any', 'text_all', 'content_desc_any', 'resource_id_any', 'activity_any'].some((key) => Array.isArray(expected[key]) && (expected[key] as unknown[]).length)) return 'missing-arrival-rules';
69
+ for (const step of steps) {
70
+ const selector = (step.selector ?? {}) as JsonObject;
71
+ const selectorReady = ['text', 'text_any', 'content_desc', 'content_desc_any', 'resource_id', 'resource_id_any'].some((key) => (typeof selector[key] === 'string' && Boolean(selector[key])) || (Array.isArray(selector[key]) && (selector[key] as unknown[]).length > 0 && (selector[key] as unknown[]).every((value) => typeof value === 'string' && value.length > 0)));
72
+ if (['tap', 'long-press'].includes(step.action) && !selectorReady && !(step.label && ['string', 'content-description', 'system'].includes(step.label_source ?? '') && !step.label_ambiguous)) return 'missing-executable-selector';
73
+ if (!['launch', 'start-activity', 'deep-link', 'home', 'back', 'default', 'tap', 'swipe', 'long-press'].includes(step.action)) return 'unsupported-replay-action';
74
+ if (step.action === 'start-activity' && !/^[\w.]+\/[\w.$]+$/.test(step.component ?? '')) return 'missing-launch-component';
75
+ if (step.action === 'deep-link' && !/^[a-z][a-z0-9+.-]*:/i.test(step.label ?? '')) return 'missing-entry-uri';
76
+ if (step.action === 'swipe' && !step.direction) return 'missing-swipe-direction';
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function validatePlan(planFile: string, raw: JsonObject): PagePlan {
83
+ if (raw.schema_version !== SCHEMA_VERSION) throw new Error(`page.json schema_version must be ${JSON.stringify(SCHEMA_VERSION)}`);
84
+ const source = asObject(raw.source, 'page.source'); const summary = asObject(raw.summary, 'page.summary');
85
+ if (!Array.isArray(raw.pages)) throw new Error('page.pages must be an array');
86
+ for (const key of ['utg', 'click_paths', 'root', 'app', 'architecture']) if (typeof source[key] !== 'string' || source[key] === '') throw new Error(`page.source.${key} must be a non-empty string`);
87
+ for (const key of ['package', 'version_name', 'build_variant']) if (typeof source[key] !== 'string') throw new Error(`page.source.${key} must be a string`);
88
+ const utgFile = resolveSource(planFile, String(source.utg)); const clickFile = resolveSource(planFile, String(source.click_paths));
89
+ if (!fs.existsSync(utgFile) || !fs.existsSync(clickFile)) throw new Error('page.source.utg/click_paths must reference existing files');
90
+ const utg = readJson(utgFile); const clickPaths = readJson(clickFile);
91
+ if (!Array.isArray(utg.nodes) || !Array.isArray(utg.links)) throw new Error('source UTG has invalid nodes/links');
92
+ const graph = asObject(utg.graph, 'utg.graph');
93
+ if (clickPaths.root !== source.root || graph.root !== source.root) throw new Error('page root disagrees with UTG/click_paths');
94
+ const paths = asObject(clickPaths.paths, 'click_paths.paths');
95
+ if (!Array.isArray(clickPaths.unreachable)) throw new Error('click_paths.unreachable must be an array');
96
+ const nodes = new Map<string, JsonObject>();
97
+ for (const item of utg.nodes) {
98
+ const node = asObject(item, 'utg.nodes[]');
99
+ if (typeof node.id !== 'string' || nodes.has(node.id)) throw new Error(`invalid or duplicate UTG node id: ${String(node.id)}`);
100
+ nodes.set(node.id, node);
101
+ }
102
+ const links = utg.links.map((item) => asObject(item, 'utg.links[]'));
103
+ const pages = raw.pages.map((item, index) => {
104
+ const page = asObject(item, `page.pages[${index}]`) as unknown as PlanPage;
105
+ if (typeof page.node_id !== 'string' || !nodes.has(page.node_id)) throw new Error(`page.pages[${index}] references a missing node`);
106
+ if (!Array.isArray(page.steps) || !Array.isArray(page.required_state) || page.expected === null || typeof page.expected !== 'object') throw new Error(`page.pages[${index}] has invalid steps/required_state/expected`);
107
+ return page;
108
+ });
109
+ if (pages.length !== nodes.size || new Set(pages.map((p) => p.node_id)).size !== nodes.size) throw new Error('page.pages must cover every UTG node exactly once');
110
+ let captureIndex = 0, unreachable = 0; const excluded: Record<string, number> = {};
111
+ for (const page of pages) {
112
+ const node = nodes.get(page.node_id)!; const clickPath = paths[page.node_id] as JsonObject | undefined; const reachable = clickPath !== undefined;
113
+ if (!reachable) unreachable += 1; const reason = nonCaptureReason(node, links, ((paths[page.node_id] as JsonObject | undefined)?.steps ?? []) as PlanStep[]); const capture = reachable && reason === null;
114
+ if (reason !== null) { const key = reason.startsWith('container-node') ? `${String(node.kind)}(container)` : String(node.kind); excluded[key] = (excluded[key] ?? 0) + 1; }
115
+ if (capture) captureIndex += 1;
116
+ const expectedKey = capture ? `page_${String(captureIndex).padStart(4, '0')}_${slug(page.node_id)}` : null;
117
+ const expectedSteps = reachable && Array.isArray(clickPath!.steps) ? clickPath!.steps : [];
118
+ const expectedSkip = capture ? null : !reachable ? 'unreachable-from-root' : reason;
119
+ if (page.reachable !== reachable || page.capture !== capture || page.capture_key !== expectedKey || JSON.stringify(page.steps) !== JSON.stringify(expectedSteps) || JSON.stringify(page.expected) !== JSON.stringify(node.expected ?? {}) || page.skip_reason !== expectedSkip) throw new Error('page.json is not the deterministic plan for this UTG pair');
120
+ }
121
+ if (summary.nodes !== nodes.size || summary.capture_planned !== captureIndex || summary.unreachable !== unreachable || JSON.stringify(summary.excluded_by_kind) !== JSON.stringify(Object.fromEntries(Object.entries(excluded).sort()))) throw new Error('page.json is not the deterministic plan for this UTG pair');
122
+ return raw as unknown as PagePlan;
123
+ }
124
+
125
+ function decodeXml(value: string): string {
126
+ return value.replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos);/g, (whole, entity: string) => {
127
+ const named: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; if (entity in named) return named[entity];
128
+ const code = Number.parseInt(entity.startsWith('#x') ? entity.slice(2) : entity.slice(1), entity.startsWith('#x') ? 16 : 10);
129
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
130
+ });
131
+ }
132
+ function parseUiTree(xml: string): UiNode {
133
+ const stack: UiNode[] = []; let root: UiNode | null = null; const token = /<\/?node\b[^>]*>|<hierarchy\b[^>]*>|<\/hierarchy>/g;
134
+ for (const match of xml.matchAll(token)) {
135
+ const raw = match[0]; if (raw.startsWith('</')) { if (raw.startsWith('</node')) stack.pop(); continue; } if (!raw.startsWith('<node')) continue;
136
+ const attrs: Record<string, string> = {}; for (const attr of raw.matchAll(/([^\s=/>]+)="([^"]*)"/g)) attrs[attr[1]] = decodeXml(attr[2]);
137
+ const nums = (attrs.bounds ?? '').match(/\d+/g)?.map(Number) ?? []; const parent = stack.length ? stack[stack.length - 1] : null;
138
+ const node: UiNode = { attrs, children: [], parent, text: attrs.text ?? '', desc: attrs['content-desc'] ?? '', rid: attrs['resource-id'] ?? '', bounds: attrs.bounds ?? '', clickable: attrs.clickable === 'true', longClickable: attrs['long-clickable'] === 'true', scrollable: attrs.scrollable === 'true', enabled: attrs.enabled !== 'false', center: nums.length === 4 ? [Math.trunc((nums[0] + nums[2]) / 2), Math.trunc((nums[1] + nums[3]) / 2)] : null };
139
+ if (parent) parent.children.push(node); else if (!root) root = node; if (!raw.endsWith('/>')) stack.push(node);
140
+ }
141
+ if (!root) throw new Error('uiautomator XML has no node root'); return root;
142
+ }
143
+ function flatten(root: UiNode): UiNode[] { const out: UiNode[] = []; const walk = (n: UiNode): void => { out.push(n); n.children.forEach(walk); }; walk(root); return out; }
144
+ function list(value?: string, values?: string[]): string[] { return [...(value ? [value] : []), ...(Array.isArray(values) ? values : [])].filter(Boolean); }
145
+ function fieldScore(actual: string, wanted: string): number {
146
+ if (!actual) return -1; if (actual === wanted) return 120; if (actual.endsWith(`/${wanted}`) || actual.endsWith(`:${wanted}`)) return 110;
147
+ if (actual.toLocaleLowerCase() === wanted.toLocaleLowerCase()) return 100; return actual.toLocaleLowerCase().includes(wanted.toLocaleLowerCase()) ? 70 : -1;
148
+ }
149
+ function semanticScore(node: UiNode, selector: Selector | undefined, label: string | null): number {
150
+ if (!node.enabled || node.center === null) return -1; let score = -1;
151
+ const pairs: Array<[string, string[], number]> = [[node.text, list(selector?.text, selector?.text_any), 30], [node.desc, list(selector?.content_desc, selector?.content_desc_any), 25], [node.rid, list(selector?.resource_id, selector?.resource_id_any), 20]];
152
+ const explicit = pairs.some(([, wanted]) => wanted.length > 0);
153
+ for (const [actual, wanted, bonus] of pairs) for (const value of wanted) { const base = fieldScore(actual, value); if (base >= 0) score = Math.max(score, base + bonus); }
154
+ if (!explicit && label) for (const actual of [node.text, node.desc, node.rid]) score = Math.max(score, fieldScore(actual, label)); return score;
155
+ }
156
+ function clickableTarget(node: UiNode): UiNode { let current: UiNode | null = node; while (current) { if ((current.clickable || current.longClickable) && current.center) return current; current = current.parent; } return node; }
157
+ function verification(expected: Expected, xml: string, activity: Record<string, string>): { ok: boolean; checks: JsonObject[]; reason?: string } {
158
+ const recognized = ['text_any', 'text_all', 'content_desc_any', 'resource_id_any', 'activity_any'].some((key) => Array.isArray((expected as JsonObject)[key]) && ((expected as JsonObject)[key] as unknown[]).length > 0);
159
+ if (!recognized) return { ok: false, checks: [], reason: 'arrival verification missing: node.expected has no supported non-empty rule' };
160
+ const nodes = flatten(parseUiTree(xml)); const checks: JsonObject[] = [];
161
+ const any = (name: string, wanted: string[] | undefined, actual: string[]): boolean => { if (!wanted?.length) return true; const hits = wanted.filter((v) => actual.some((a) => a === v || a.includes(v))); checks.push({ rule: name, wanted, hits, passed: hits.length > 0 }); return hits.length > 0; };
162
+ const all = (name: string, wanted: string[] | undefined, actual: string[]): boolean => { if (!wanted?.length) return true; const missing = wanted.filter((v) => !actual.some((a) => a === v || a.includes(v))); checks.push({ rule: name, wanted, missing, passed: !missing.length }); return !missing.length; };
163
+ const texts = nodes.map((n) => n.text).filter(Boolean), descs = nodes.map((n) => n.desc).filter(Boolean), rids = nodes.map((n) => n.rid).filter(Boolean);
164
+ const ok = any('text_any', expected.text_any, texts) && all('text_all', expected.text_all, texts) && any('content_desc_any', expected.content_desc_any, descs) && any('resource_id_any', expected.resource_id_any, rids) && any('activity_any', expected.activity_any, Object.values(activity));
165
+ return { ok, checks, ...(ok ? {} : { reason: 'arrival verification failed' }) };
166
+ }
167
+
168
+ class Adb {
169
+ executable: string; serial: string | null; settleMs: number; maxScrolls: number; allowCoordinateFallback: boolean;
170
+ private screen = { width: 1080, height: 1920 };
171
+ constructor(executable: string, serial: string | null, settleMs: number, maxScrolls: number, allowCoordinateFallback: boolean) { this.executable = executable; this.serial = serial; this.settleMs = settleMs; this.maxScrolls = maxScrolls; this.allowCoordinateFallback = allowCoordinateFallback; }
172
+ private args(args: string[]): string[] { return this.serial ? ['-s', this.serial, ...args] : args; }
173
+ run(args: string[], binary = false, timeoutSec = 25): Buffer {
174
+ const result = spawnSync(this.executable, this.args(args), { timeout: timeoutSec * 1000, maxBuffer: 128 * 1024 * 1024, windowsHide: true }) as SpawnSyncReturns<Buffer>;
175
+ if (result.error) throw new Error(`adb ${args.join(' ')} failed: ${result.error.message}`);
176
+ if (result.status !== 0) { const detail = (result.stderr ?? Buffer.alloc(0)).toString('utf-8').trim(); throw new Error(`adb ${args.join(' ')} exited ${result.status}${detail ? `: ${detail}` : ''}`); }
177
+ const output = result.stdout ?? Buffer.alloc(0); return binary ? output : Buffer.from(output.toString('utf-8').trim(), 'utf-8');
178
+ }
179
+ text(args: string[], timeoutSec = 25): string { return this.run(args, false, timeoutSec).toString('utf-8'); }
180
+ shell(args: string[], timeoutSec = 25): string { return this.text(['shell', ...args], timeoutSec); }
181
+ selectDevice(): void {
182
+ const online = this.text(['devices']).split(/\r?\n/).slice(1).map((l) => l.trim()).filter((l) => /\sdevice(?:\s|$)/.test(l)).map((l) => l.split(/\s+/)[0]);
183
+ if (!online.length) throw new Error('no online ADB device'); if (this.serial && !online.includes(this.serial)) throw new Error(`ADB device is not online: ${this.serial}`); if (!this.serial) this.serial = online[0];
184
+ }
185
+ launch(pkg: string): void { this.shell(['pm', 'path', pkg]); this.shell(['am', 'force-stop', pkg]); this.shell(['monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']); sleep(this.settleMs); }
186
+ dumpXml(): string { const remote = '/sdcard/d2h_utg_ai_window.xml'; this.shell(['uiautomator', 'dump', remote], 30); const xml = this.shell(['cat', remote], 20); if (!xml.includes('<hierarchy')) throw new Error('uiautomator dump did not return a hierarchy'); return xml; }
187
+ activityInfo(): Record<string, string> { return { current_focus: this.shell(['dumpsys', 'window', 'displays']).split(/\r?\n/).find((l) => l.includes('mCurrentFocus'))?.trim() ?? '', resumed_activity: this.shell(['dumpsys', 'activity', 'activities']).split(/\r?\n/).find((l) => /ResumedActivity|topResumedActivity/.test(l))?.trim() ?? '' }; }
188
+ packageInfo(pkg: string): JsonObject { const raw = this.shell(['dumpsys', 'package', pkg], 30); return { version_name: /versionName=([^\s]+)/.exec(raw)?.[1] ?? '', version_code: /versionCode=(\d+)/.exec(raw)?.[1] ?? '', raw_excerpt: raw.split(/\r?\n/).filter((l) => /version(Name|Code)=|codePath=/.test(l)).slice(0, 6) }; }
189
+ deviceInfo(): JsonObject {
190
+ const densityRaw = this.shell(['wm', 'density']), sizeRaw = this.shell(['wm', 'size']); const od = /Override density:\s*(\d+)/.exec(densityRaw), pd = /Physical density:\s*(\d+)/.exec(densityRaw); const dpi = Number((od ?? pd)?.[1] ?? 0) || null;
191
+ const os = /Override size:\s*(\d+)x(\d+)/.exec(sizeRaw), ps = /Physical size:\s*(\d+)x(\d+)/.exec(sizeRaw), size = os ?? ps; if (size) this.screen = { width: Number(size[1]), height: Number(size[2]) };
192
+ return { serial: this.serial, dpi, density: dpi === null ? null : Math.round((dpi / 160) * 1000) / 1000, density_source: dpi === null ? 'unavailable' : od ? 'wm density (override)' : 'wm density (physical)', width_px: size ? Number(size[1]) : null, height_px: size ? Number(size[2]) : null, locale: this.shell(['getprop', 'persist.sys.locale']), raw: { wm_density: densityRaw, wm_size: sizeRaw } };
193
+ }
194
+ swipe(direction = 'up', distance = 0.45, durationMs = 350, bounds?: string): void {
195
+ const nums = bounds?.match(/\d+/g)?.map(Number) ?? [0, 0, this.screen.width, this.screen.height]; const [x1, y1, x2, y2] = nums.length === 4 ? nums : [0, 0, this.screen.width, this.screen.height];
196
+ const cx = Math.trunc((x1 + x2) / 2), cy = Math.trunc((y1 + y2) / 2), dx = Math.trunc((x2 - x1) * Math.min(Math.max(distance, .1), .8)), dy = Math.trunc((y2 - y1) * Math.min(Math.max(distance, .1), .8));
197
+ const points: Record<string, [number, number, number, number]> = { up: [cx, Math.min(y2 - 10, cy + dy / 2), cx, Math.max(y1 + 10, cy - dy / 2)], down: [cx, Math.max(y1 + 10, cy - dy / 2), cx, Math.min(y2 - 10, cy + dy / 2)], left: [Math.min(x2 - 10, cx + dx / 2), cy, Math.max(x1 + 10, cx - dx / 2), cy], right: [Math.max(x1 + 10, cx - dx / 2), cy, Math.min(x2 - 10, cx + dx / 2), cy] };
198
+ if (!(direction in points)) throw new Error(`unsupported swipe direction: ${direction}`); this.shell(['input', 'swipe', ...points[direction].map((v) => String(Math.trunc(v))), String(durationMs)]); sleep(this.settleMs);
199
+ }
200
+ findTarget(step: PlanStep): { target: UiNode; semantic: UiNode; score: number; scrolls: number } | null {
201
+ const limit = step.max_scrolls ?? this.maxScrolls;
202
+ for (let attempt = 0; attempt <= limit; attempt += 1) {
203
+ const nodes = flatten(parseUiTree(this.dumpXml())); const candidates = nodes.map((semantic) => ({ semantic, target: clickableTarget(semantic), score: semanticScore(semantic, step.selector, step.label ?? null) })).filter((i) => i.score >= 0 && i.target.center !== null);
204
+ const dedup = new Map<string, typeof candidates[number]>(); for (const item of candidates) if (!dedup.has(item.target.bounds) || dedup.get(item.target.bounds)!.score < item.score) dedup.set(item.target.bounds, item);
205
+ const ranked = [...dedup.values()].sort((a, b) => b.score - a.score);
206
+ if (ranked.length) { if (ranked.length > 1 && ranked[0].score === ranked[1].score && ranked[0].target.bounds !== ranked[1].target.bounds) throw new Error(`multiple clickable targets equally match ${JSON.stringify(step.label ?? step.selector ?? '')}`); return { ...ranked[0], scrolls: attempt }; }
207
+ if (attempt < limit) { const area = (n: UiNode): number => { const v = n.bounds.match(/\d+/g)?.map(Number) ?? []; return v.length === 4 ? (v[2] - v[0]) * (v[3] - v[1]) : 0; }; const scrollable = nodes.filter((n) => n.scrollable && n.center).sort((a, b) => area(b) - area(a))[0]; this.swipe('up', .5, 350, scrollable?.bounds); }
208
+ }
209
+ return null;
210
+ }
211
+ replay(step: PlanStep): JsonObject {
212
+ const action = String(step.action ?? 'tap').toLocaleLowerCase();
213
+ if (['launch', 'default', 'noop', 'none'].includes(action)) return { action, status: 'no-op' };
214
+ if (action === 'start-activity') {
215
+ if (!/^[\w.]+\/[\w.$]+$/.test(step.component ?? '')) throw new Error('start-activity requires a source-proven component');
216
+ this.shell(['am', 'start', '-n', step.component!]); sleep(this.settleMs);
217
+ return { action, status: 'ok', component: step.component };
218
+ }
219
+ if (['back', 'navigate-back'].includes(action)) { this.shell(['input', 'keyevent', 'KEYCODE_BACK']); sleep(this.settleMs); return { action, status: 'ok' }; }
220
+ if (action === 'home') { this.shell(['input', 'keyevent', 'KEYCODE_HOME']); sleep(this.settleMs); return { action, status: 'ok' }; }
221
+ if (action === 'swipe') { this.swipe(step.direction ?? 'up', step.distance ?? .45, step.duration_ms ?? 350); return { action, status: 'ok', direction: step.direction ?? 'up' }; }
222
+ if (['deep-link', 'deeplink', 'app-link'].includes(action)) { const uri = step.label; if (!uri || !/^[a-z][a-z0-9+.-]*:/i.test(uri)) throw new Error(`deep-link step has no URI: ${step.target_id ?? ''}`); this.shell(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', uri]); sleep(this.settleMs); return { action, status: 'ok', uri }; }
223
+ const isLong = ['long-press', 'longpress', 'long-tap'].includes(action); if (!(isLong || ['tap', 'click', 'open', 'navigate', 'select'].includes(action))) throw new Error(`unsupported deterministic ADB action: ${action}`);
224
+ if (step.label_ambiguous) throw new Error(`ambiguous label cannot be replayed safely: ${step.label ?? step.target_id ?? ''}`); const explicit = step.selector && Object.keys(step.selector).length > 0;
225
+ if (!step.label && !explicit) throw new Error(`ADB ${action} step has no deterministic selector: ${step.target_id ?? ''}; nl_hint is not executable`);
226
+ const found = this.findTarget(step); let center: [number, number], bounds = ''; let matched: JsonObject = {};
227
+ if (found) { center = found.target.center!; bounds = found.target.bounds; matched = { semantic_bounds: found.semantic.bounds, text: found.semantic.text, content_desc: found.semantic.desc, resource_id: found.semantic.rid, scrolls: found.scrolls }; }
228
+ else if (step.selector?.normalized_point) { if (!this.allowCoordinateFallback) throw new Error('normalized_point selector requires --allow-coordinate-fallback'); const [nx, ny] = step.selector.normalized_point; if (!(nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1)) throw new Error('normalized_point values must be in [0,1]'); center = [Math.round(nx * this.screen.width), Math.round(ny * this.screen.height)]; matched = { normalized_point: [nx, ny] }; }
229
+ else throw new Error(`no UIAutomator target matches ${JSON.stringify(step.selector ?? step.label)}`);
230
+ const [x, y] = center; if (isLong) this.shell(['input', 'swipe', String(x), String(y), String(x), String(y), '1000']); else this.shell(['input', 'tap', String(x), String(y)]); sleep(this.settleMs);
231
+ return { action, status: 'ok', selector: step.selector ?? step.label, bounds, center, matched };
232
+ }
233
+ screenshot(): Buffer { const raw = this.run(['exec-out', 'screencap', '-p'], true, 30), magic = Buffer.from([0x89, 0x50, 0x4e, 0x47]), at = raw.indexOf(magic); if (at < 0) throw new Error('adb screencap output contains no PNG'); return raw.subarray(at); }
234
+ }
235
+
236
+ function parseArgs(argv: string[]): CliArgs {
237
+ const args: CliArgs = { pageJson: '', outputWorkspace: '', packageName: '', device: null, adbPath: 'adb', only: null, satisfiedState: new Set(), dryRun: false, allowPartial: false, allowCoordinateFallback: false, settleMs: 800, maxScrolls: 4 };
238
+ for (let i = 0; i < argv.length; i += 1) {
239
+ const flag = argv[i], next = (): string => { if (i + 1 >= argv.length) throw new Error(`missing value for ${flag}`); return argv[++i]; };
240
+ if (flag === '--page-json') args.pageJson = next(); else if (flag === '--output-workspace') args.outputWorkspace = next(); else if (flag === '--package') args.packageName = next(); else if (flag === '--device') args.device = next(); else if (flag === '--adb-path') args.adbPath = next();
241
+ else if (flag === '--only') args.only = new Set(next().split(',').map((v) => v.trim()).filter(Boolean)); else if (flag === '--satisfied-state') args.satisfiedState = new Set(next().split(',').map((v) => v.trim()).filter(Boolean));
242
+ else if (flag === '--settle-ms') args.settleMs = Number.parseInt(next(), 10); else if (flag === '--max-scrolls') args.maxScrolls = Number.parseInt(next(), 10); else if (flag === '--dry-run') args.dryRun = true; else if (flag === '--allow-partial') args.allowPartial = true; else if (flag === '--allow-coordinate-fallback') args.allowCoordinateFallback = true;
243
+ else if (flag === '--help' || flag === '-h') { console.log('Usage: node capture_from_page_json.ts --page-json <page.json> --output-workspace <dir> [--package <pkg>] [--device <serial>] [--adb-path <adb>] [--only <id,...>] [--satisfied-state <kind,...>] [--max-scrolls 4] [--settle-ms 800] [--allow-coordinate-fallback] [--dry-run] [--allow-partial]'); process.exit(0); }
244
+ else throw new Error(`unknown argument: ${flag}`);
245
+ }
246
+ if (!args.pageJson || !args.outputWorkspace) throw new Error('--page-json and --output-workspace are required'); if (!Number.isFinite(args.settleMs) || args.settleMs < 0 || !Number.isInteger(args.maxScrolls) || args.maxScrolls < 0) throw new Error('--settle-ms/--max-scrolls must be non-negative integers'); return args;
247
+ }
248
+ function requiredStates(page: PlanPage): string[] { return [...new Set([...page.required_state, ...page.steps.map((s) => s.guard?.kind).filter((v): v is string => Boolean(v))])]; }
249
+ function replaceCaptureDir(temp: string, finalDir: string): void {
250
+ const backup = `${finalDir}.previous.${process.pid}`; if (fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true }); if (fs.existsSync(finalDir)) fs.renameSync(finalDir, backup);
251
+ try { fs.renameSync(temp, finalDir); if (fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true }); } catch (error) { if (!fs.existsSync(finalDir) && fs.existsSync(backup)) fs.renameSync(backup, finalDir); throw error; }
252
+ }
253
+ function main(): number {
254
+ const args = parseArgs(process.argv.slice(2)), planFile = path.resolve(args.pageJson), plan = validatePlan(planFile, readJson(planFile)), eligible = plan.pages.filter((p) => p.capture);
255
+ if (args.only) { const known = new Set(eligible.map((p) => p.node_id)), missing = [...args.only].filter((id) => !known.has(id)); if (missing.length) throw new Error(`--only contains nodes not eligible for capture: ${missing.join(', ')}`); }
256
+ const selected = eligible.filter((p) => args.only === null || args.only.has(p.node_id)); if (!selected.length && !args.dryRun) throw new Error('page.json selects no runtime pages');
257
+ const packageName = args.packageName || plan.source.package; if (!packageName) throw new Error('Android package is missing; pass --package or set graph.package');
258
+ const preview = { page_json: planFile, output_workspace: path.resolve(args.outputWorkspace), package: packageName, expected_version: plan.source.version_name, capture_mode: 'adb-deterministic-replay', selected_count: selected.length, nodes: selected.map((p) => p.node_id), required_states: Object.fromEntries(selected.map((p) => [p.node_id, requiredStates(p)])) };
259
+ if (args.dryRun) { console.log(JSON.stringify(preview, null, 2)); return 0; }
260
+ const workspace = path.resolve(args.outputWorkspace); fs.mkdirSync(workspace, { recursive: true }); const adb = new Adb(args.adbPath, args.device, args.settleMs, args.maxScrolls, args.allowCoordinateFallback); adb.selectDevice();
261
+ const deviceInfo = adb.deviceInfo(), packageInfo = adb.packageInfo(packageName); if (plan.source.version_name && packageInfo.version_name !== plan.source.version_name) throw new Error(`installed package version ${String(packageInfo.version_name)} does not match analyzed source version ${plan.source.version_name}`);
262
+ const selectedIds = new Set(selected.map((p) => p.node_id)), outcomes = new Map<string, JsonObject>(), runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}`;
263
+ for (const page of selected) {
264
+ const trace: JsonObject[] = [], missingState = requiredStates(page).filter((s) => !args.satisfiedState.has(s)); if (missingState.length) { outcomes.set(page.node_id, { node_id: page.node_id, status: 'blocked', reason: `state requirements not asserted: ${missingState.join(', ')}`, required_state: missingState }); continue; }
265
+ let tempDir = '';
266
+ try {
267
+ adb.launch(packageName); for (const step of page.steps) trace.push(adb.replay(step)); const activity = adb.activityInfo(); if (!Object.values(activity).some((v) => v.includes(packageName))) throw new Error(`target package is not foreground after replay: ${packageName}`);
268
+ const xml = adb.dumpXml(), verified = verification(page.expected, xml, activity); if (!verified.ok) throw new Error(`${verified.reason}: ${JSON.stringify(verified.checks)}`); const screenshot = adb.screenshot();
269
+ tempDir = fs.mkdtempSync(path.join(workspace, `._capture_${page.capture_key}_`)); fs.writeFileSync(path.join(tempDir, 'screenshot.png'), screenshot); fs.writeFileSync(path.join(tempDir, 'view.xml'), xml, 'utf-8');
270
+ atomicWriteJson(path.join(tempDir, 'meta.json'), { schema_version: 'd2h-adb-page-capture/v2', run_id: runId, node_id: page.node_id, label: page.label, kind: page.kind, capture_mode: 'adb-deterministic-replay', captured_at: new Date().toISOString(), breadcrumb: page.path_text, ui_sequence: page.ui_sequence, required_state: requiredStates(page), expected: page.expected, verification: verified, screenshot: `${page.capture_key}/screenshot.png`, screenshots: [`${page.capture_key}/screenshot.png`], view_xml: `${page.capture_key}/view.xml`, view_xmls: [`${page.capture_key}/view.xml`], activity_info: activity, device_info: deviceInfo, package_info: packageInfo, replay_trace: trace });
271
+ const finalDir = path.join(workspace, page.capture_key!); replaceCaptureDir(tempDir, finalDir); tempDir = ''; outcomes.set(page.node_id, { node_id: page.node_id, status: 'captured', page_dir: finalDir, verification: verified, screenshot: path.join(finalDir, 'screenshot.png'), view_tree: path.join(finalDir, 'view.xml'), metadata: path.join(finalDir, 'meta.json') });
272
+ } catch (error) { if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); const stale = page.capture_key ? path.join(workspace, page.capture_key) : ''; outcomes.set(page.node_id, { node_id: page.node_id, status: 'failed', reason: error instanceof Error ? error.message : String(error), replay_trace: trace, ...(stale && fs.existsSync(stale) ? { stale_artifact_dir: stale } : {}) }); }
273
+ }
274
+ const results: JsonObject[] = [], counts = { captured: 0, failed: 0, blocked: 0, skipped: 0, not_requested: 0 };
275
+ for (const page of plan.pages) { let result: JsonObject; if (!page.capture) result = { node_id: page.node_id, status: 'skipped', reason: page.skip_reason }; else if (!selectedIds.has(page.node_id)) result = { node_id: page.node_id, status: 'not_requested' }; else result = outcomes.get(page.node_id)!; counts[result.status as keyof typeof counts] += 1; results.push(result); }
276
+ const capturedPages = selected.filter((p) => outcomes.get(p.node_id)?.status === 'captured').map((p) => ({ page_dir: String(outcomes.get(p.node_id)!.page_dir), ui_sequence: p.ui_sequence })); atomicWriteJson(path.join(workspace, 'pages.json'), { run_id: runId, pages: capturedPages });
277
+ const report = { schema_version: 'd2h-page-capture-report/v2', run_id: runId, status: counts.failed === 0 && counts.blocked === 0 ? 'complete' : 'partial', capture_mode: 'adb-deterministic-replay', workspace, device: adb.serial, device_info: deviceInfo, package_info: packageInfo, summary: counts, pages: results }; atomicWriteJson(path.join(workspace, 'capture-report.json'), report);
278
+ console.log(JSON.stringify(counts)); console.log(path.join(workspace, 'capture-report.json')); return (counts.failed > 0 || counts.blocked > 0) && !args.allowPartial ? 1 : 0;
279
+ }
280
+ try { process.exitCode = main(); } catch (error) { console.error(`ERROR: ${error instanceof Error ? error.message : error}`); process.exitCode = 2; }