@harmonyos-arkts/d2h 0.1.11 → 0.1.13

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 (126) hide show
  1. package/.claude-plugin/marketplace.json +15 -13
  2. package/.claude-plugin/plugin.json +13 -7
  3. package/AGENTS.md +5 -5
  4. package/README.md +212 -212
  5. package/agents/android-to-hmos-00-orchestrator.md +318 -318
  6. package/agents/d2h.md +157 -157
  7. package/dist/bin/install-opencode.js +30 -30
  8. package/docs/environment-check.md +86 -86
  9. package/install-opencode.sh +160 -160
  10. package/opencode/agents.json +16 -16
  11. package/opencode/vscode-d2h-package.json +7 -7
  12. package/opencode/vscode-d2h-plugin.mjs +29 -29
  13. package/package.json +54 -54
  14. package/schemas/android-source-manifest.schema.json +25 -25
  15. package/schemas/checkpoint-provenance.schema.json +67 -67
  16. package/schemas/final-acceptance.schema.json +29 -29
  17. package/schemas/managed-evidence-index.schema.json +15 -15
  18. package/schemas/managed-evidence.schema.json +114 -114
  19. package/schemas/migration-config.schema.json +51 -51
  20. package/schemas/migration-report-index.schema.json +39 -39
  21. package/schemas/migration-report-item.schema.json +51 -51
  22. package/schemas/migration-report-summary.schema.json +25 -25
  23. package/schemas/migration-status.schema.json +202 -202
  24. package/schemas/preflight.schema.json +60 -60
  25. package/schemas/source-order-audit.schema.json +16 -16
  26. package/schemas/source-provenance-event.schema.json +19 -19
  27. package/schemas/spec-app-shard.schema.json +45 -45
  28. package/schemas/spec-fact-corrections-shard.schema.json +13 -13
  29. package/schemas/spec-features-shard.schema.json +43 -43
  30. package/schemas/spec-index.schema.json +26 -26
  31. package/schemas/spec-interactions-shard.schema.json +40 -40
  32. package/schemas/spec-page.schema.json +98 -98
  33. package/schemas/spec-pages-index.schema.json +1 -1
  34. package/schemas/spec-unresolved-shard.schema.json +1 -1
  35. package/schemas/task-envelope.schema.json +109 -109
  36. package/schemas/task-plan.schema.json +341 -341
  37. package/scripts/check-environment.mjs +320 -320
  38. package/skills/android2hmos_resources_convert/SKILL.md +180 -180
  39. package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
  40. package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
  41. package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
  42. package/skills/arkts-app-identity/SKILL.md +238 -238
  43. package/skills/arkts-i18n/SKILL.md +496 -496
  44. package/skills/arkts-i18n/evals/evals.json +84 -84
  45. package/skills/arkts-i18n/references/code-examples.md +302 -302
  46. package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
  47. package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
  48. package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
  49. package/skills/arkts-i18n/references/language-codes.md +104 -104
  50. package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
  51. package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
  52. package/skills/arkts-i18n/references/v1-compat.md +244 -244
  53. package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
  54. package/skills/arkts-icon-sizing/SKILL.md +211 -211
  55. package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
  56. package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
  57. package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
  58. package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
  59. package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
  60. package/skills/d2h-hypium-forge/SKILL.md +309 -309
  61. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
  62. package/skills/d2h-hypium-forge/references/official-api.md +135 -135
  63. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
  64. package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
  65. package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
  66. package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
  67. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
  68. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
  69. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
  70. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
  71. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
  72. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
  73. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
  74. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
  75. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
  76. package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
  77. package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
  78. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
  79. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
  80. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
  81. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
  82. package/skills/d2h-page-develop/SKILL.md +112 -112
  83. package/skills/d2h-page-develop/agents/openai.yaml +7 -7
  84. package/skills/d2h-page-spec-generate/SKILL.md +206 -206
  85. package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
  86. package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
  87. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
  88. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
  89. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
  90. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
  91. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
  92. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
  93. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
  94. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
  95. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
  96. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
  97. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
  98. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
  99. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
  100. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
  101. package/skills/d2h-utg-static-generate/SKILL.md +73 -73
  102. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
  103. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
  104. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
  105. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
  106. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
  107. package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
  108. package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
  109. package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
  110. package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
  111. package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
  112. package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
  113. package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
  114. package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
  115. package/skills/maintaining-migration-report/SKILL.md +144 -144
  116. package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
  117. package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
  118. package/skills/managing-d2h-toolchain/SKILL.md +24 -24
  119. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
  120. package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
  121. package/skills/managing-d2h-toolchain/toolchain.json +16 -16
  122. package/skills/native-library-substitution/SKILL.md +384 -384
  123. package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
  124. package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
  125. package/skills/preparing-migration-workspace/SKILL.md +131 -131
  126. package/skills/reviewing-migration-process/SKILL.md +62 -62
@@ -1,280 +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; }
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; }