@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,369 +1,369 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import json
5
- import subprocess
6
- import sys
7
- import tempfile
8
- import unittest
9
- from pathlib import Path
10
-
11
- SKILL = Path(__file__).resolve().parents[1]
12
- BUILD = SKILL / "scripts" / "build_utg.py"
13
- VALIDATE = SKILL / "scripts" / "validate_utg.py"
14
- BUILD_PAGE = SKILL / "scripts" / "build_page_json.py"
15
- CAPTURE = SKILL / "scripts" / "capture_from_page_json.ts"
16
-
17
-
18
- class PipelineTest(unittest.TestCase):
19
- def test_build_and_validate(self) -> None:
20
- with tempfile.TemporaryDirectory() as temp:
21
- root = Path(temp)
22
- project = root / "project"
23
- workspace = root / "workspace"
24
- project.mkdir()
25
- workspace.mkdir()
26
- (project / "App.kt").write_text(
27
- "fun root() = Unit\nfun openHome() = Unit\nfun openDialog() = Unit\n",
28
- encoding="utf-8",
29
- )
30
- facts = {
31
- "schema_version": "d2h-utg-facts/v1",
32
- "graph": {
33
- "app": "Demo",
34
- "package": "com.example.demo",
35
- "architecture": "compose",
36
- "root": "MainActivity",
37
- "start_destination": "home",
38
- },
39
- "nodes": [
40
- {
41
- "id": "MainActivity", "kind": "activity", "label": "Demo",
42
- "declared_at": "App.kt:1", "extra": {"launcher": True},
43
- "expected": {"activity_any": ["com.example.demo"]},
44
- },
45
- {
46
- "id": "home", "kind": "compose-route", "label": "Home",
47
- "declared_at": "App.kt:2", "expected": {"activity_any": ["com.example.demo"]},
48
- },
49
- {
50
- "id": "overlay:Help", "kind": "dialog", "label": "Help",
51
- "declared_at": "App.kt:3", "expected": {"text_any": ["Help"]},
52
- },
53
- ],
54
- "edges": [
55
- {
56
- "source": "MainActivity", "target": "home",
57
- "mechanism": "navhost-start", "trigger": "App.kt:2",
58
- "args": {"action": "default", "label": "Demo", "label_source": "string"},
59
- "confidence": "high",
60
- },
61
- {
62
- "source": "home", "target": "overlay:Help",
63
- "mechanism": "open-overlay", "trigger": "App.kt:3",
64
- "args": {"action": "tap", "label": "Help", "label_source": "string"},
65
- "confidence": 0.95,
66
- },
67
- ],
68
- "unresolved": [],
69
- }
70
- facts_path = workspace / "utg-facts.json"
71
- facts_path.write_text(json.dumps(facts), encoding="utf-8")
72
- built = subprocess.run(
73
- [sys.executable, str(BUILD), "--facts", str(facts_path),
74
- "--project-root", str(project), "--workspace", str(workspace)],
75
- check=False, capture_output=True, text=True,
76
- )
77
- self.assertEqual(built.returncode, 0, built.stderr)
78
- checked = subprocess.run(
79
- [sys.executable, str(VALIDATE), "--utg", str(workspace / "utg.json"),
80
- "--click-paths", str(workspace / "click_paths.json"),
81
- "--page-json", str(workspace / "page.json"),
82
- "--project-root", str(project)],
83
- check=False, capture_output=True, text=True,
84
- )
85
- self.assertEqual(checked.returncode, 0, checked.stderr)
86
- click_paths = json.loads((workspace / "click_paths.json").read_text())
87
- self.assertEqual(click_paths["paths"]["overlay:Help"]["depth"], 2)
88
- self.assertEqual(click_paths["paths"]["overlay:Help"]["path_text"], "Demo > Help")
89
- page_plan = json.loads((workspace / "page.json").read_text())
90
- self.assertEqual(page_plan["schema_version"], "d2h-page-plan/v1")
91
- self.assertEqual(page_plan["source"]["version_name"], "")
92
- self.assertEqual(page_plan["summary"]["capture_planned"], 2)
93
- self.assertEqual(
94
- [page["node_id"] for page in page_plan["pages"]],
95
- ["MainActivity", "home", "overlay:Help"],
96
- )
97
- self.assertEqual(page_plan["pages"][2]["steps"], click_paths["paths"]["overlay:Help"]["steps"])
98
- capture_keys = [page["capture_key"] for page in page_plan["pages"] if page["capture"]]
99
- self.assertEqual(len(set(capture_keys)), 2)
100
- self.assertFalse(page_plan["pages"][0]["capture"])
101
- self.assertEqual(page_plan["pages"][0]["skip_reason"], "container-node:activity")
102
- report = json.loads((workspace / "generation-report.json").read_text())
103
- self.assertEqual(report["normalizations"], ["edges[0].confidence: 'high' -> 0.9"])
104
- self.assertEqual(report["page_plan"]["capture_planned"], 2)
105
-
106
- dry_run = subprocess.run(
107
- ["node", str(CAPTURE), "--page-json", str(workspace / "page.json"),
108
- "--output-workspace", str(workspace / "ui_info"), "--dry-run"],
109
- check=False, capture_output=True, text=True,
110
- )
111
- self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
112
- preview = json.loads(dry_run.stdout)
113
- self.assertEqual(preview["nodes"], ["home", "overlay:Help"])
114
-
115
- mutated = dict(page_plan)
116
- mutated["summary"] = dict(page_plan["summary"])
117
- mutated["summary"]["capture_planned"] = 99
118
- mutated_path = workspace / "page-mutated.json"
119
- mutated_path.write_text(json.dumps(mutated), encoding="utf-8")
120
- rejected = subprocess.run(
121
- ["node", str(CAPTURE), "--page-json", str(mutated_path),
122
- "--output-workspace", str(workspace / "ui_info"), "--dry-run"],
123
- check=False, capture_output=True, text=True,
124
- )
125
- self.assertEqual(rejected.returncode, 2)
126
- self.assertIn("not the deterministic plan", rejected.stderr)
127
-
128
- rebuilt = workspace / "page-rebuilt.json"
129
- built_page = subprocess.run(
130
- [sys.executable, str(BUILD_PAGE), "--utg", str(workspace / "utg.json"),
131
- "--click-paths", str(workspace / "click_paths.json"),
132
- "--output", str(rebuilt), "--project-root", str(project)],
133
- check=False, capture_output=True, text=True,
134
- )
135
- self.assertEqual(built_page.returncode, 0, built_page.stderr)
136
- rebuilt_plan = json.loads(rebuilt.read_text())
137
- self.assertEqual(rebuilt_plan["pages"], page_plan["pages"])
138
-
139
- # Runtime orchestration contract without a real device: a fake ADB
140
- # implements only the commands used by the standalone TS capture.
141
- fake_adb = root / "fake-adb.py"
142
- fake_adb.write_text(
143
- """#!/usr/bin/env python3
144
- import sys
145
- from pathlib import Path
146
- a=sys.argv[1:]
147
- if len(a)>=2 and a[0]=='-s': a=a[2:]
148
- state=Path(__file__).with_name('fake-adb-scrolled')
149
- if a==['devices']:
150
- print('List of devices attached\\nfake\\tdevice')
151
- elif a[:2]==['shell','pm']:
152
- print('package:/data/app/demo.apk')
153
- elif a[:3]==['shell','dumpsys','window']:
154
- print('mCurrentFocus=Window{1 u0 com.example.demo/.MainActivity}')
155
- elif a[:3]==['shell','dumpsys','activity']:
156
- print('topResumedActivity=ActivityRecord{1 com.example.demo/.MainActivity}')
157
- elif a[:3]==['shell','dumpsys','package']:
158
- print('versionCode=1 versionName=')
159
- elif a[:3]==['shell','wm','density']:
160
- print('Physical density: 480')
161
- elif a[:3]==['shell','wm','size']:
162
- print('Physical size: 1080x1920')
163
- elif a[:3]==['shell','uiautomator','dump']:
164
- print('UI hierchary dumped')
165
- elif a[:2]==['shell','cat']:
166
- if state.exists():
167
- print('<hierarchy><node text="" content-desc="" resource-id="" clickable="true" long-clickable="false" scrollable="true" enabled="true" bounds="[0,0][200,100]"><node text="Help" content-desc="" resource-id="" clickable="false" long-clickable="false" enabled="true" bounds="[10,10][90,90]"/><node text="" content-desc="Help" resource-id="" clickable="false" long-clickable="false" enabled="true" bounds="[100,10][190,90]"/></node></hierarchy>')
168
- else:
169
- print('<hierarchy><node text="" content-desc="" resource-id="" clickable="false" long-clickable="false" scrollable="true" enabled="true" bounds="[0,0][200,100]"/></hierarchy>')
170
- elif a[:3]==['shell','input','swipe']:
171
- state.write_text('scrolled')
172
- print('OK')
173
- elif a[:2]==['exec-out','screencap']:
174
- sys.stdout.buffer.write(b'\\x89PNG\\r\\n\\x1a\\nFAKE')
175
- else:
176
- print('OK')
177
- """,
178
- encoding="utf-8",
179
- )
180
- fake_adb.chmod(0o755)
181
- # Windows cannot execute a Python shebang. Intercept only this
182
- # exact fake path in the test process; production ADB is unchanged.
183
- hook = root / 'fake-adb-hook.mjs'
184
- hook.write_text(
185
- "import cp from 'node:child_process';\n"
186
- "import { syncBuiltinESMExports } from 'node:module';\n"
187
- "const original = cp.spawnSync;\n"
188
- f"const fake = {json.dumps(str(fake_adb))};\n"
189
- f"const python = {json.dumps(sys.executable)};\n"
190
- "cp.spawnSync = (file, args, options) => file === fake "
191
- "? original(python, [fake, ...args], options) : original(file, args, options);\n"
192
- "syncBuiltinESMExports();\n", encoding='utf-8',
193
- )
194
- runtime_workspace = workspace / "runtime"
195
- captured = subprocess.run(
196
- ["node", "--import", hook.as_uri(), str(CAPTURE), "--page-json", str(workspace / "page.json"),
197
- "--output-workspace", str(runtime_workspace),
198
- "--device", "fake", "--adb-path", str(fake_adb), "--settle-ms", "0"],
199
- check=False, capture_output=True, text=True,
200
- )
201
- self.assertEqual(captured.returncode, 0, captured.stderr)
202
- capture_report = json.loads((runtime_workspace / "capture-report.json").read_text())
203
- self.assertEqual(capture_report["status"], "complete")
204
- self.assertEqual(capture_report["summary"]["captured"], 2)
205
- self.assertEqual(capture_report["summary"]["failed"], 0)
206
- self.assertEqual(capture_report["summary"]["blocked"], 0)
207
- self.assertTrue((runtime_workspace / "pages.json").is_file())
208
- for result in capture_report["pages"]:
209
- if result["status"] != "captured":
210
- continue
211
- self.assertTrue(Path(result["screenshot"]).is_file())
212
- self.assertTrue(Path(result["view_tree"]).is_file())
213
- self.assertTrue(Path(result["metadata"]).is_file())
214
- metadata = json.loads(Path(result["metadata"]).read_text())
215
- self.assertTrue(metadata["verification"]["ok"])
216
- self.assertTrue((runtime_workspace / metadata["screenshot"]).is_file())
217
- self.assertTrue((runtime_workspace / metadata["view_xml"]).is_file())
218
- if result["node_id"] == "overlay:Help":
219
- self.assertEqual(metadata["replay_trace"][-1]["matched"]["scrolls"], 1)
220
-
221
- def test_rejects_missing_evidence(self) -> None:
222
- with tempfile.TemporaryDirectory() as temp:
223
- root = Path(temp)
224
- project = root / "project"
225
- workspace = root / "workspace"
226
- project.mkdir()
227
- workspace.mkdir()
228
- facts = {
229
- "schema_version": "d2h-utg-facts/v1",
230
- "graph": {"app": "Demo", "architecture": "compose", "root": "root"},
231
- "nodes": [
232
- {"id": "root", "kind": "activity", "label": "Root", "declared_at": "Missing.kt:1"}
233
- ],
234
- "edges": [],
235
- "unresolved": [],
236
- }
237
- facts_path = workspace / "utg-facts.json"
238
- facts_path.write_text(json.dumps(facts), encoding="utf-8")
239
- built = subprocess.run(
240
- [sys.executable, str(BUILD), "--facts", str(facts_path),
241
- "--project-root", str(project), "--workspace", str(workspace)],
242
- check=False, capture_output=True, text=True,
243
- )
244
- self.assertEqual(built.returncode, 2)
245
- self.assertIn("source file does not exist", built.stderr)
246
-
247
- def test_rejects_capture_page_without_arrival_rule(self) -> None:
248
- with tempfile.TemporaryDirectory() as temp:
249
- root = Path(temp)
250
- project = root / "project"
251
- workspace = root / "workspace"
252
- project.mkdir()
253
- workspace.mkdir()
254
- (project / "App.kt").write_text("root\n", encoding="utf-8")
255
- facts = {
256
- "schema_version": "d2h-utg-facts/v1",
257
- "graph": {
258
- "app": "Demo",
259
- "package": "com.example.demo",
260
- "architecture": "views",
261
- "root": "root",
262
- },
263
- "nodes": [
264
- {
265
- "id": "root",
266
- "kind": "activity",
267
- "label": "Root",
268
- "declared_at": "App.kt:1",
269
- "expected": {},
270
- }
271
- ],
272
- "edges": [],
273
- "unresolved": [],
274
- }
275
- facts_path = workspace / "utg-facts.json"
276
- facts_path.write_text(json.dumps(facts), encoding="utf-8")
277
- built = subprocess.run(
278
- [
279
- sys.executable,
280
- str(BUILD),
281
- "--facts",
282
- str(facts_path),
283
- "--project-root",
284
- str(project),
285
- "--workspace",
286
- str(workspace),
287
- ],
288
- check=False,
289
- capture_output=True,
290
- text=True,
291
- )
292
- self.assertEqual(built.returncode, 2)
293
- self.assertIn("supported non-empty arrival rule", built.stderr)
294
-
295
- def test_page_plan_keeps_unreachable_and_skips_nav_graph(self) -> None:
296
- with tempfile.TemporaryDirectory() as temp:
297
- root = Path(temp)
298
- project = root / "project"
299
- workspace = root / "workspace"
300
- project.mkdir()
301
- workspace.mkdir()
302
- (project / "App.kt").write_text("root\ngraph\nhidden\n", encoding="utf-8")
303
- facts = {
304
- "schema_version": "d2h-utg-facts/v1",
305
- "graph": {"app": "Demo", "package": "com.example.demo", "architecture": "compose", "root": "root"},
306
- "nodes": [
307
- {"id": "root", "kind": "activity", "label": "Root", "declared_at": "App.kt:1"},
308
- {"id": "graph", "kind": "nav-graph", "label": "Graph", "declared_at": "App.kt:2"},
309
- {"id": "hidden", "kind": "dialog", "label": "Hidden", "declared_at": "App.kt:3"},
310
- ],
311
- "edges": [
312
- {"source": "root", "target": "graph", "mechanism": "navhost-start", "trigger": "App.kt:2", "args": {"action": "default"}, "confidence": 1.0}
313
- ],
314
- "unresolved": [],
315
- }
316
- facts_path = workspace / "utg-facts.json"
317
- facts_path.write_text(json.dumps(facts), encoding="utf-8")
318
- built = subprocess.run(
319
- [sys.executable, str(BUILD), "--facts", str(facts_path),
320
- "--project-root", str(project), "--workspace", str(workspace)],
321
- check=False, capture_output=True, text=True,
322
- )
323
- self.assertEqual(built.returncode, 0, built.stderr)
324
- plan = json.loads((workspace / "page.json").read_text())
325
- by_id = {page["node_id"]: page for page in plan["pages"]}
326
- self.assertFalse(by_id["root"]["capture"])
327
- self.assertEqual(by_id["root"]["skip_reason"], "container-node:activity")
328
- self.assertFalse(by_id["graph"]["capture"])
329
- self.assertEqual(by_id["graph"]["skip_reason"], "non-capture-kind:nav-graph")
330
- self.assertFalse(by_id["hidden"]["reachable"])
331
- self.assertEqual(by_id["hidden"]["skip_reason"], "unreachable-from-root")
332
-
333
- def test_click_path_prefers_unguarded_route(self) -> None:
334
- with tempfile.TemporaryDirectory() as temp:
335
- root = Path(temp)
336
- project = root / "project"
337
- workspace = root / "workspace"
338
- project.mkdir()
339
- workspace.mkdir()
340
- (project / "App.kt").write_text("root\nmid\ntarget\n", encoding="utf-8")
341
- facts = {
342
- "schema_version": "d2h-utg-facts/v1",
343
- "graph": {"app": "Demo", "package": "com.example.demo", "architecture": "compose", "root": "root"},
344
- "nodes": [
345
- {"id": "root", "kind": "activity", "label": "Root", "declared_at": "App.kt:1", "expected": {"activity_any": ["com.example.demo"]}},
346
- {"id": "mid", "kind": "compose-route", "label": "Mid", "declared_at": "App.kt:2", "expected": {"text_any": ["Mid"]}},
347
- {"id": "target", "kind": "compose-route", "label": "Target", "declared_at": "App.kt:3", "expected": {"text_any": ["Target"]}},
348
- ],
349
- "edges": [
350
- {"source": "root", "target": "target", "mechanism": "guarded-shortcut", "trigger": "App.kt:3", "guard": {"kind": "setting", "text": "optional start"}, "args": {"action": "default", "label": "Target"}, "confidence": 1.0},
351
- {"source": "root", "target": "mid", "mechanism": "tap", "trigger": "App.kt:2", "args": {"action": "tap", "label": "Mid"}, "confidence": 1.0},
352
- {"source": "mid", "target": "target", "mechanism": "tap", "trigger": "App.kt:3", "args": {"action": "tap", "label": "Target"}, "confidence": 1.0},
353
- ],
354
- "unresolved": [],
355
- }
356
- facts_path = workspace / "utg-facts.json"
357
- facts_path.write_text(json.dumps(facts), encoding="utf-8")
358
- built = subprocess.run(
359
- [sys.executable, str(BUILD), "--facts", str(facts_path), "--project-root", str(project), "--workspace", str(workspace)],
360
- check=False, capture_output=True, text=True,
361
- )
362
- self.assertEqual(built.returncode, 0, built.stderr)
363
- paths = json.loads((workspace / "click_paths.json").read_text())
364
- self.assertEqual(paths["paths"]["target"]["depth"], 2)
365
- self.assertEqual([step["target_id"] for step in paths["paths"]["target"]["steps"]], ["root", "mid", "target"])
366
-
367
-
368
- if __name__ == "__main__":
369
- unittest.main()
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ SKILL = Path(__file__).resolve().parents[1]
12
+ BUILD = SKILL / "scripts" / "build_utg.py"
13
+ VALIDATE = SKILL / "scripts" / "validate_utg.py"
14
+ BUILD_PAGE = SKILL / "scripts" / "build_page_json.py"
15
+ CAPTURE = SKILL / "scripts" / "capture_from_page_json.ts"
16
+
17
+
18
+ class PipelineTest(unittest.TestCase):
19
+ def test_build_and_validate(self) -> None:
20
+ with tempfile.TemporaryDirectory() as temp:
21
+ root = Path(temp)
22
+ project = root / "project"
23
+ workspace = root / "workspace"
24
+ project.mkdir()
25
+ workspace.mkdir()
26
+ (project / "App.kt").write_text(
27
+ "fun root() = Unit\nfun openHome() = Unit\nfun openDialog() = Unit\n",
28
+ encoding="utf-8",
29
+ )
30
+ facts = {
31
+ "schema_version": "d2h-utg-facts/v1",
32
+ "graph": {
33
+ "app": "Demo",
34
+ "package": "com.example.demo",
35
+ "architecture": "compose",
36
+ "root": "MainActivity",
37
+ "start_destination": "home",
38
+ },
39
+ "nodes": [
40
+ {
41
+ "id": "MainActivity", "kind": "activity", "label": "Demo",
42
+ "declared_at": "App.kt:1", "extra": {"launcher": True},
43
+ "expected": {"activity_any": ["com.example.demo"]},
44
+ },
45
+ {
46
+ "id": "home", "kind": "compose-route", "label": "Home",
47
+ "declared_at": "App.kt:2", "expected": {"activity_any": ["com.example.demo"]},
48
+ },
49
+ {
50
+ "id": "overlay:Help", "kind": "dialog", "label": "Help",
51
+ "declared_at": "App.kt:3", "expected": {"text_any": ["Help"]},
52
+ },
53
+ ],
54
+ "edges": [
55
+ {
56
+ "source": "MainActivity", "target": "home",
57
+ "mechanism": "navhost-start", "trigger": "App.kt:2",
58
+ "args": {"action": "default", "label": "Demo", "label_source": "string"},
59
+ "confidence": "high",
60
+ },
61
+ {
62
+ "source": "home", "target": "overlay:Help",
63
+ "mechanism": "open-overlay", "trigger": "App.kt:3",
64
+ "args": {"action": "tap", "label": "Help", "label_source": "string"},
65
+ "confidence": 0.95,
66
+ },
67
+ ],
68
+ "unresolved": [],
69
+ }
70
+ facts_path = workspace / "utg-facts.json"
71
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
72
+ built = subprocess.run(
73
+ [sys.executable, str(BUILD), "--facts", str(facts_path),
74
+ "--project-root", str(project), "--workspace", str(workspace)],
75
+ check=False, capture_output=True, text=True,
76
+ )
77
+ self.assertEqual(built.returncode, 0, built.stderr)
78
+ checked = subprocess.run(
79
+ [sys.executable, str(VALIDATE), "--utg", str(workspace / "utg.json"),
80
+ "--click-paths", str(workspace / "click_paths.json"),
81
+ "--page-json", str(workspace / "page.json"),
82
+ "--project-root", str(project)],
83
+ check=False, capture_output=True, text=True,
84
+ )
85
+ self.assertEqual(checked.returncode, 0, checked.stderr)
86
+ click_paths = json.loads((workspace / "click_paths.json").read_text())
87
+ self.assertEqual(click_paths["paths"]["overlay:Help"]["depth"], 2)
88
+ self.assertEqual(click_paths["paths"]["overlay:Help"]["path_text"], "Demo > Help")
89
+ page_plan = json.loads((workspace / "page.json").read_text())
90
+ self.assertEqual(page_plan["schema_version"], "d2h-page-plan/v1")
91
+ self.assertEqual(page_plan["source"]["version_name"], "")
92
+ self.assertEqual(page_plan["summary"]["capture_planned"], 2)
93
+ self.assertEqual(
94
+ [page["node_id"] for page in page_plan["pages"]],
95
+ ["MainActivity", "home", "overlay:Help"],
96
+ )
97
+ self.assertEqual(page_plan["pages"][2]["steps"], click_paths["paths"]["overlay:Help"]["steps"])
98
+ capture_keys = [page["capture_key"] for page in page_plan["pages"] if page["capture"]]
99
+ self.assertEqual(len(set(capture_keys)), 2)
100
+ self.assertFalse(page_plan["pages"][0]["capture"])
101
+ self.assertEqual(page_plan["pages"][0]["skip_reason"], "container-node:activity")
102
+ report = json.loads((workspace / "generation-report.json").read_text())
103
+ self.assertEqual(report["normalizations"], ["edges[0].confidence: 'high' -> 0.9"])
104
+ self.assertEqual(report["page_plan"]["capture_planned"], 2)
105
+
106
+ dry_run = subprocess.run(
107
+ ["node", str(CAPTURE), "--page-json", str(workspace / "page.json"),
108
+ "--output-workspace", str(workspace / "ui_info"), "--dry-run"],
109
+ check=False, capture_output=True, text=True,
110
+ )
111
+ self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
112
+ preview = json.loads(dry_run.stdout)
113
+ self.assertEqual(preview["nodes"], ["home", "overlay:Help"])
114
+
115
+ mutated = dict(page_plan)
116
+ mutated["summary"] = dict(page_plan["summary"])
117
+ mutated["summary"]["capture_planned"] = 99
118
+ mutated_path = workspace / "page-mutated.json"
119
+ mutated_path.write_text(json.dumps(mutated), encoding="utf-8")
120
+ rejected = subprocess.run(
121
+ ["node", str(CAPTURE), "--page-json", str(mutated_path),
122
+ "--output-workspace", str(workspace / "ui_info"), "--dry-run"],
123
+ check=False, capture_output=True, text=True,
124
+ )
125
+ self.assertEqual(rejected.returncode, 2)
126
+ self.assertIn("not the deterministic plan", rejected.stderr)
127
+
128
+ rebuilt = workspace / "page-rebuilt.json"
129
+ built_page = subprocess.run(
130
+ [sys.executable, str(BUILD_PAGE), "--utg", str(workspace / "utg.json"),
131
+ "--click-paths", str(workspace / "click_paths.json"),
132
+ "--output", str(rebuilt), "--project-root", str(project)],
133
+ check=False, capture_output=True, text=True,
134
+ )
135
+ self.assertEqual(built_page.returncode, 0, built_page.stderr)
136
+ rebuilt_plan = json.loads(rebuilt.read_text())
137
+ self.assertEqual(rebuilt_plan["pages"], page_plan["pages"])
138
+
139
+ # Runtime orchestration contract without a real device: a fake ADB
140
+ # implements only the commands used by the standalone TS capture.
141
+ fake_adb = root / "fake-adb.py"
142
+ fake_adb.write_text(
143
+ """#!/usr/bin/env python3
144
+ import sys
145
+ from pathlib import Path
146
+ a=sys.argv[1:]
147
+ if len(a)>=2 and a[0]=='-s': a=a[2:]
148
+ state=Path(__file__).with_name('fake-adb-scrolled')
149
+ if a==['devices']:
150
+ print('List of devices attached\\nfake\\tdevice')
151
+ elif a[:2]==['shell','pm']:
152
+ print('package:/data/app/demo.apk')
153
+ elif a[:3]==['shell','dumpsys','window']:
154
+ print('mCurrentFocus=Window{1 u0 com.example.demo/.MainActivity}')
155
+ elif a[:3]==['shell','dumpsys','activity']:
156
+ print('topResumedActivity=ActivityRecord{1 com.example.demo/.MainActivity}')
157
+ elif a[:3]==['shell','dumpsys','package']:
158
+ print('versionCode=1 versionName=')
159
+ elif a[:3]==['shell','wm','density']:
160
+ print('Physical density: 480')
161
+ elif a[:3]==['shell','wm','size']:
162
+ print('Physical size: 1080x1920')
163
+ elif a[:3]==['shell','uiautomator','dump']:
164
+ print('UI hierchary dumped')
165
+ elif a[:2]==['shell','cat']:
166
+ if state.exists():
167
+ print('<hierarchy><node text="" content-desc="" resource-id="" clickable="true" long-clickable="false" scrollable="true" enabled="true" bounds="[0,0][200,100]"><node text="Help" content-desc="" resource-id="" clickable="false" long-clickable="false" enabled="true" bounds="[10,10][90,90]"/><node text="" content-desc="Help" resource-id="" clickable="false" long-clickable="false" enabled="true" bounds="[100,10][190,90]"/></node></hierarchy>')
168
+ else:
169
+ print('<hierarchy><node text="" content-desc="" resource-id="" clickable="false" long-clickable="false" scrollable="true" enabled="true" bounds="[0,0][200,100]"/></hierarchy>')
170
+ elif a[:3]==['shell','input','swipe']:
171
+ state.write_text('scrolled')
172
+ print('OK')
173
+ elif a[:2]==['exec-out','screencap']:
174
+ sys.stdout.buffer.write(b'\\x89PNG\\r\\n\\x1a\\nFAKE')
175
+ else:
176
+ print('OK')
177
+ """,
178
+ encoding="utf-8",
179
+ )
180
+ fake_adb.chmod(0o755)
181
+ # Windows cannot execute a Python shebang. Intercept only this
182
+ # exact fake path in the test process; production ADB is unchanged.
183
+ hook = root / 'fake-adb-hook.mjs'
184
+ hook.write_text(
185
+ "import cp from 'node:child_process';\n"
186
+ "import { syncBuiltinESMExports } from 'node:module';\n"
187
+ "const original = cp.spawnSync;\n"
188
+ f"const fake = {json.dumps(str(fake_adb))};\n"
189
+ f"const python = {json.dumps(sys.executable)};\n"
190
+ "cp.spawnSync = (file, args, options) => file === fake "
191
+ "? original(python, [fake, ...args], options) : original(file, args, options);\n"
192
+ "syncBuiltinESMExports();\n", encoding='utf-8',
193
+ )
194
+ runtime_workspace = workspace / "runtime"
195
+ captured = subprocess.run(
196
+ ["node", "--import", hook.as_uri(), str(CAPTURE), "--page-json", str(workspace / "page.json"),
197
+ "--output-workspace", str(runtime_workspace),
198
+ "--device", "fake", "--adb-path", str(fake_adb), "--settle-ms", "0"],
199
+ check=False, capture_output=True, text=True,
200
+ )
201
+ self.assertEqual(captured.returncode, 0, captured.stderr)
202
+ capture_report = json.loads((runtime_workspace / "capture-report.json").read_text())
203
+ self.assertEqual(capture_report["status"], "complete")
204
+ self.assertEqual(capture_report["summary"]["captured"], 2)
205
+ self.assertEqual(capture_report["summary"]["failed"], 0)
206
+ self.assertEqual(capture_report["summary"]["blocked"], 0)
207
+ self.assertTrue((runtime_workspace / "pages.json").is_file())
208
+ for result in capture_report["pages"]:
209
+ if result["status"] != "captured":
210
+ continue
211
+ self.assertTrue(Path(result["screenshot"]).is_file())
212
+ self.assertTrue(Path(result["view_tree"]).is_file())
213
+ self.assertTrue(Path(result["metadata"]).is_file())
214
+ metadata = json.loads(Path(result["metadata"]).read_text())
215
+ self.assertTrue(metadata["verification"]["ok"])
216
+ self.assertTrue((runtime_workspace / metadata["screenshot"]).is_file())
217
+ self.assertTrue((runtime_workspace / metadata["view_xml"]).is_file())
218
+ if result["node_id"] == "overlay:Help":
219
+ self.assertEqual(metadata["replay_trace"][-1]["matched"]["scrolls"], 1)
220
+
221
+ def test_rejects_missing_evidence(self) -> None:
222
+ with tempfile.TemporaryDirectory() as temp:
223
+ root = Path(temp)
224
+ project = root / "project"
225
+ workspace = root / "workspace"
226
+ project.mkdir()
227
+ workspace.mkdir()
228
+ facts = {
229
+ "schema_version": "d2h-utg-facts/v1",
230
+ "graph": {"app": "Demo", "architecture": "compose", "root": "root"},
231
+ "nodes": [
232
+ {"id": "root", "kind": "activity", "label": "Root", "declared_at": "Missing.kt:1"}
233
+ ],
234
+ "edges": [],
235
+ "unresolved": [],
236
+ }
237
+ facts_path = workspace / "utg-facts.json"
238
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
239
+ built = subprocess.run(
240
+ [sys.executable, str(BUILD), "--facts", str(facts_path),
241
+ "--project-root", str(project), "--workspace", str(workspace)],
242
+ check=False, capture_output=True, text=True,
243
+ )
244
+ self.assertEqual(built.returncode, 2)
245
+ self.assertIn("source file does not exist", built.stderr)
246
+
247
+ def test_rejects_capture_page_without_arrival_rule(self) -> None:
248
+ with tempfile.TemporaryDirectory() as temp:
249
+ root = Path(temp)
250
+ project = root / "project"
251
+ workspace = root / "workspace"
252
+ project.mkdir()
253
+ workspace.mkdir()
254
+ (project / "App.kt").write_text("root\n", encoding="utf-8")
255
+ facts = {
256
+ "schema_version": "d2h-utg-facts/v1",
257
+ "graph": {
258
+ "app": "Demo",
259
+ "package": "com.example.demo",
260
+ "architecture": "views",
261
+ "root": "root",
262
+ },
263
+ "nodes": [
264
+ {
265
+ "id": "root",
266
+ "kind": "activity",
267
+ "label": "Root",
268
+ "declared_at": "App.kt:1",
269
+ "expected": {},
270
+ }
271
+ ],
272
+ "edges": [],
273
+ "unresolved": [],
274
+ }
275
+ facts_path = workspace / "utg-facts.json"
276
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
277
+ built = subprocess.run(
278
+ [
279
+ sys.executable,
280
+ str(BUILD),
281
+ "--facts",
282
+ str(facts_path),
283
+ "--project-root",
284
+ str(project),
285
+ "--workspace",
286
+ str(workspace),
287
+ ],
288
+ check=False,
289
+ capture_output=True,
290
+ text=True,
291
+ )
292
+ self.assertEqual(built.returncode, 2)
293
+ self.assertIn("supported non-empty arrival rule", built.stderr)
294
+
295
+ def test_page_plan_keeps_unreachable_and_skips_nav_graph(self) -> None:
296
+ with tempfile.TemporaryDirectory() as temp:
297
+ root = Path(temp)
298
+ project = root / "project"
299
+ workspace = root / "workspace"
300
+ project.mkdir()
301
+ workspace.mkdir()
302
+ (project / "App.kt").write_text("root\ngraph\nhidden\n", encoding="utf-8")
303
+ facts = {
304
+ "schema_version": "d2h-utg-facts/v1",
305
+ "graph": {"app": "Demo", "package": "com.example.demo", "architecture": "compose", "root": "root"},
306
+ "nodes": [
307
+ {"id": "root", "kind": "activity", "label": "Root", "declared_at": "App.kt:1"},
308
+ {"id": "graph", "kind": "nav-graph", "label": "Graph", "declared_at": "App.kt:2"},
309
+ {"id": "hidden", "kind": "dialog", "label": "Hidden", "declared_at": "App.kt:3"},
310
+ ],
311
+ "edges": [
312
+ {"source": "root", "target": "graph", "mechanism": "navhost-start", "trigger": "App.kt:2", "args": {"action": "default"}, "confidence": 1.0}
313
+ ],
314
+ "unresolved": [],
315
+ }
316
+ facts_path = workspace / "utg-facts.json"
317
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
318
+ built = subprocess.run(
319
+ [sys.executable, str(BUILD), "--facts", str(facts_path),
320
+ "--project-root", str(project), "--workspace", str(workspace)],
321
+ check=False, capture_output=True, text=True,
322
+ )
323
+ self.assertEqual(built.returncode, 0, built.stderr)
324
+ plan = json.loads((workspace / "page.json").read_text())
325
+ by_id = {page["node_id"]: page for page in plan["pages"]}
326
+ self.assertFalse(by_id["root"]["capture"])
327
+ self.assertEqual(by_id["root"]["skip_reason"], "container-node:activity")
328
+ self.assertFalse(by_id["graph"]["capture"])
329
+ self.assertEqual(by_id["graph"]["skip_reason"], "non-capture-kind:nav-graph")
330
+ self.assertFalse(by_id["hidden"]["reachable"])
331
+ self.assertEqual(by_id["hidden"]["skip_reason"], "unreachable-from-root")
332
+
333
+ def test_click_path_prefers_unguarded_route(self) -> None:
334
+ with tempfile.TemporaryDirectory() as temp:
335
+ root = Path(temp)
336
+ project = root / "project"
337
+ workspace = root / "workspace"
338
+ project.mkdir()
339
+ workspace.mkdir()
340
+ (project / "App.kt").write_text("root\nmid\ntarget\n", encoding="utf-8")
341
+ facts = {
342
+ "schema_version": "d2h-utg-facts/v1",
343
+ "graph": {"app": "Demo", "package": "com.example.demo", "architecture": "compose", "root": "root"},
344
+ "nodes": [
345
+ {"id": "root", "kind": "activity", "label": "Root", "declared_at": "App.kt:1", "expected": {"activity_any": ["com.example.demo"]}},
346
+ {"id": "mid", "kind": "compose-route", "label": "Mid", "declared_at": "App.kt:2", "expected": {"text_any": ["Mid"]}},
347
+ {"id": "target", "kind": "compose-route", "label": "Target", "declared_at": "App.kt:3", "expected": {"text_any": ["Target"]}},
348
+ ],
349
+ "edges": [
350
+ {"source": "root", "target": "target", "mechanism": "guarded-shortcut", "trigger": "App.kt:3", "guard": {"kind": "setting", "text": "optional start"}, "args": {"action": "default", "label": "Target"}, "confidence": 1.0},
351
+ {"source": "root", "target": "mid", "mechanism": "tap", "trigger": "App.kt:2", "args": {"action": "tap", "label": "Mid"}, "confidence": 1.0},
352
+ {"source": "mid", "target": "target", "mechanism": "tap", "trigger": "App.kt:3", "args": {"action": "tap", "label": "Target"}, "confidence": 1.0},
353
+ ],
354
+ "unresolved": [],
355
+ }
356
+ facts_path = workspace / "utg-facts.json"
357
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
358
+ built = subprocess.run(
359
+ [sys.executable, str(BUILD), "--facts", str(facts_path), "--project-root", str(project), "--workspace", str(workspace)],
360
+ check=False, capture_output=True, text=True,
361
+ )
362
+ self.assertEqual(built.returncode, 0, built.stderr)
363
+ paths = json.loads((workspace / "click_paths.json").read_text())
364
+ self.assertEqual(paths["paths"]["target"]["depth"], 2)
365
+ self.assertEqual([step["target_id"] for step in paths["paths"]["target"]["steps"]], ["root", "mid", "target"])
366
+
367
+
368
+ if __name__ == "__main__":
369
+ unittest.main()