taphound 0.2.0-dev.1

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 (176) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +200 -0
  3. package/README.zh-CN.md +200 -0
  4. package/assets/brand/taphound-mark.svg +9 -0
  5. package/assets/skills/taphound-ai-journey/GUIDE.md +862 -0
  6. package/assets/skills/taphound-ai-journey/SKILL.md +377 -0
  7. package/assets/skills/taphound-ai-journey/prompts/analyze-project.md +526 -0
  8. package/assets/skills/taphound-ai-journey/prompts/check-completion.md +52 -0
  9. package/assets/skills/taphound-ai-journey/prompts/generate-step.md +109 -0
  10. package/assets/skills/taphound-ai-journey/schemas/observe-output.json +42 -0
  11. package/assets/skills/taphound-ai-journey/schemas/project-context.json +81 -0
  12. package/assets/skills/taphound-ai-journey/schemas/proposed-step-envelope.json +267 -0
  13. package/assets/skills/taphound-ai-journey/templates/project-context.example.json +49 -0
  14. package/dist/adapters/adb/adb-adapter.d.ts +23 -0
  15. package/dist/adapters/adb/adb-adapter.js +218 -0
  16. package/dist/adapters/android-cli/android-cli-adapter.d.ts +11 -0
  17. package/dist/adapters/android-cli/android-cli-adapter.js +64 -0
  18. package/dist/adapters/android-cli/layout-parser.d.ts +3 -0
  19. package/dist/adapters/android-cli/layout-parser.js +174 -0
  20. package/dist/adapters/clock/system-clock.d.ts +5 -0
  21. package/dist/adapters/clock/system-clock.js +13 -0
  22. package/dist/adapters/filesystem/artifact-store.d.ts +4 -0
  23. package/dist/adapters/filesystem/artifact-store.js +73 -0
  24. package/dist/adapters/filesystem/generation-meta-writer.d.ts +16 -0
  25. package/dist/adapters/filesystem/generation-meta-writer.js +34 -0
  26. package/dist/adapters/filesystem/generation-session-store.d.ts +68 -0
  27. package/dist/adapters/filesystem/generation-session-store.js +1603 -0
  28. package/dist/adapters/filesystem/journey-writer.d.ts +16 -0
  29. package/dist/adapters/filesystem/journey-writer.js +34 -0
  30. package/dist/adapters/filesystem/project-bound-file.d.ts +6 -0
  31. package/dist/adapters/filesystem/project-bound-file.js +240 -0
  32. package/dist/adapters/filesystem/project-file-inspector.d.ts +8 -0
  33. package/dist/adapters/filesystem/project-file-inspector.js +198 -0
  34. package/dist/adapters/filesystem/skill-installer.d.ts +10 -0
  35. package/dist/adapters/filesystem/skill-installer.js +39 -0
  36. package/dist/adapters/process/node-process-runner.d.ts +8 -0
  37. package/dist/adapters/process/node-process-runner.js +155 -0
  38. package/dist/adapters/prompt/inquirer-generation-prompt.d.ts +55 -0
  39. package/dist/adapters/prompt/inquirer-generation-prompt.js +213 -0
  40. package/dist/adapters/prompt/inquirer-init-prompt.d.ts +32 -0
  41. package/dist/adapters/prompt/inquirer-init-prompt.js +55 -0
  42. package/dist/adapters/prompt/inquirer-recorder-prompt.d.ts +47 -0
  43. package/dist/adapters/prompt/inquirer-recorder-prompt.js +157 -0
  44. package/dist/application/assertion/expectation-evaluator.d.ts +61 -0
  45. package/dist/application/assertion/expectation-evaluator.js +207 -0
  46. package/dist/application/collector/logcat-collector.d.ts +39 -0
  47. package/dist/application/collector/logcat-collector.js +107 -0
  48. package/dist/application/context/context-validator.d.ts +27 -0
  49. package/dist/application/context/context-validator.js +120 -0
  50. package/dist/application/doctor/doctor-service.d.ts +35 -0
  51. package/dist/application/doctor/doctor-service.js +178 -0
  52. package/dist/application/generation/focused-input.d.ts +2 -0
  53. package/dist/application/generation/focused-input.js +9 -0
  54. package/dist/application/generation/generation-confirmation-service.d.ts +76 -0
  55. package/dist/application/generation/generation-confirmation-service.js +383 -0
  56. package/dist/application/generation/generation-finalizer.d.ts +67 -0
  57. package/dist/application/generation/generation-finalizer.js +646 -0
  58. package/dist/application/generation/generation-publisher.d.ts +46 -0
  59. package/dist/application/generation/generation-publisher.js +178 -0
  60. package/dist/application/generation/generation-starter.d.ts +31 -0
  61. package/dist/application/generation/generation-starter.js +102 -0
  62. package/dist/application/generation/generation-step-executor.d.ts +52 -0
  63. package/dist/application/generation/generation-step-executor.js +668 -0
  64. package/dist/application/generation/proposed-step-validator.d.ts +11 -0
  65. package/dist/application/generation/proposed-step-validator.js +88 -0
  66. package/dist/application/generation/risk-evaluator.d.ts +9 -0
  67. package/dist/application/generation/risk-evaluator.js +14 -0
  68. package/dist/application/generation/runtime-observer.d.ts +39 -0
  69. package/dist/application/generation/runtime-observer.js +157 -0
  70. package/dist/application/init/init-service.d.ts +17 -0
  71. package/dist/application/init/init-service.js +44 -0
  72. package/dist/application/interaction/action-executor.d.ts +25 -0
  73. package/dist/application/interaction/action-executor.js +103 -0
  74. package/dist/application/interaction/fallback-resolver.d.ts +24 -0
  75. package/dist/application/interaction/fallback-resolver.js +48 -0
  76. package/dist/application/interaction/scroll-to-executor.d.ts +40 -0
  77. package/dist/application/interaction/scroll-to-executor.js +191 -0
  78. package/dist/application/locator/locator-resolver.d.ts +20 -0
  79. package/dist/application/locator/locator-resolver.js +89 -0
  80. package/dist/application/project/project-describer.d.ts +14 -0
  81. package/dist/application/project/project-describer.js +8 -0
  82. package/dist/application/recorder/locator-selector.d.ts +10 -0
  83. package/dist/application/recorder/locator-selector.js +80 -0
  84. package/dist/application/recorder/recorder-service.d.ts +41 -0
  85. package/dist/application/recorder/recorder-service.js +331 -0
  86. package/dist/application/report/report-writer.d.ts +10 -0
  87. package/dist/application/report/report-writer.js +39 -0
  88. package/dist/application/runtime/activity-waiter.d.ts +33 -0
  89. package/dist/application/runtime/activity-waiter.js +73 -0
  90. package/dist/application/runtime/launch-failure.d.ts +2 -0
  91. package/dist/application/runtime/launch-failure.js +20 -0
  92. package/dist/application/runtime/process-waiter.d.ts +27 -0
  93. package/dist/application/runtime/process-waiter.js +73 -0
  94. package/dist/application/runtime/step-runner.d.ts +50 -0
  95. package/dist/application/runtime/step-runner.js +429 -0
  96. package/dist/application/runtime/verify-runtime.d.ts +44 -0
  97. package/dist/application/runtime/verify-runtime.js +350 -0
  98. package/dist/application/wait/idle-waiter.d.ts +30 -0
  99. package/dist/application/wait/idle-waiter.js +90 -0
  100. package/dist/cli/commands/context.d.ts +3 -0
  101. package/dist/cli/commands/context.js +94 -0
  102. package/dist/cli/commands/doctor.d.ts +3 -0
  103. package/dist/cli/commands/doctor.js +51 -0
  104. package/dist/cli/commands/generation.d.ts +3 -0
  105. package/dist/cli/commands/generation.js +518 -0
  106. package/dist/cli/commands/init.d.ts +3 -0
  107. package/dist/cli/commands/init.js +104 -0
  108. package/dist/cli/commands/project.d.ts +3 -0
  109. package/dist/cli/commands/project.js +58 -0
  110. package/dist/cli/commands/record.d.ts +3 -0
  111. package/dist/cli/commands/record.js +88 -0
  112. package/dist/cli/commands/verify.d.ts +3 -0
  113. package/dist/cli/commands/verify.js +106 -0
  114. package/dist/cli/dependencies.d.ts +66 -0
  115. package/dist/cli/dependencies.js +204 -0
  116. package/dist/cli/main.d.ts +8 -0
  117. package/dist/cli/main.js +73 -0
  118. package/dist/cli/output.d.ts +16 -0
  119. package/dist/cli/output.js +22 -0
  120. package/dist/cli/program.d.ts +3 -0
  121. package/dist/cli/program.js +43 -0
  122. package/dist/domain/activity.d.ts +7 -0
  123. package/dist/domain/activity.js +65 -0
  124. package/dist/domain/app-process.d.ts +7 -0
  125. package/dist/domain/app-process.js +10 -0
  126. package/dist/domain/config.d.ts +15 -0
  127. package/dist/domain/config.js +17 -0
  128. package/dist/domain/failure.d.ts +4 -0
  129. package/dist/domain/failure.js +43 -0
  130. package/dist/domain/generation.d.ts +912 -0
  131. package/dist/domain/generation.js +342 -0
  132. package/dist/domain/init.d.ts +42 -0
  133. package/dist/domain/init.js +114 -0
  134. package/dist/domain/journey.d.ts +673 -0
  135. package/dist/domain/journey.js +109 -0
  136. package/dist/domain/layout.d.ts +35 -0
  137. package/dist/domain/layout.js +33 -0
  138. package/dist/domain/locator.d.ts +8 -0
  139. package/dist/domain/locator.js +5 -0
  140. package/dist/domain/project-context.d.ts +101 -0
  141. package/dist/domain/project-context.js +64 -0
  142. package/dist/domain/proposed-step.d.ts +341 -0
  143. package/dist/domain/proposed-step.js +87 -0
  144. package/dist/domain/report.d.ts +349 -0
  145. package/dist/domain/report.js +135 -0
  146. package/dist/domain/runtime-snapshot.d.ts +16 -0
  147. package/dist/domain/runtime-snapshot.js +46 -0
  148. package/dist/ports/adb.d.ts +42 -0
  149. package/dist/ports/adb.js +1 -0
  150. package/dist/ports/android-cli.d.ts +21 -0
  151. package/dist/ports/android-cli.js +1 -0
  152. package/dist/ports/artifact-store.d.ts +12 -0
  153. package/dist/ports/artifact-store.js +1 -0
  154. package/dist/ports/clock.d.ts +4 -0
  155. package/dist/ports/clock.js +1 -0
  156. package/dist/ports/generation-meta-writer.d.ts +10 -0
  157. package/dist/ports/generation-meta-writer.js +1 -0
  158. package/dist/ports/generation-prompt.d.ts +19 -0
  159. package/dist/ports/generation-prompt.js +6 -0
  160. package/dist/ports/generation-session-store.d.ts +32 -0
  161. package/dist/ports/generation-session-store.js +26 -0
  162. package/dist/ports/init-prompt.d.ts +8 -0
  163. package/dist/ports/init-prompt.js +6 -0
  164. package/dist/ports/journey-writer.d.ts +10 -0
  165. package/dist/ports/journey-writer.js +1 -0
  166. package/dist/ports/process-runner.d.ts +32 -0
  167. package/dist/ports/process-runner.js +1 -0
  168. package/dist/ports/project-bound-file.d.ts +8 -0
  169. package/dist/ports/project-bound-file.js +1 -0
  170. package/dist/ports/project-file-inspector.d.ts +33 -0
  171. package/dist/ports/project-file-inspector.js +1 -0
  172. package/dist/ports/recorder-prompt.d.ts +29 -0
  173. package/dist/ports/recorder-prompt.js +1 -0
  174. package/dist/ports/skill-installer.d.ts +8 -0
  175. package/dist/ports/skill-installer.js +1 -0
  176. package/package.json +64 -0
@@ -0,0 +1,331 @@
1
+ import { parse } from "node:path";
2
+ import { normalizeActivity } from "../../domain/activity.js";
3
+ import { primaryAppPid } from "../../domain/app-process.js";
4
+ import { JourneySchema, JourneyStepSchema } from "../../domain/journey.js";
5
+ import { ActionExecutor } from "../interaction/action-executor.js";
6
+ import { resolveLocator } from "../locator/locator-resolver.js";
7
+ import { launchFailure } from "../runtime/launch-failure.js";
8
+ import { ProcessWaiter } from "../runtime/process-waiter.js";
9
+ import { IdleWaiter } from "../wait/idle-waiter.js";
10
+ import { listLocatableTargets, listRecorderTargets } from "./locator-selector.js";
11
+ function failedCommand(result) {
12
+ return result.exitCode !== 0
13
+ || result.timedOut
14
+ || result.cancelled
15
+ || result.spawnError !== undefined;
16
+ }
17
+ function commandMessage(result, fallback) {
18
+ return result.stderr.trim() || result.spawnError || fallback;
19
+ }
20
+ function annotatedPath(outputPath) {
21
+ const output = parse(outputPath);
22
+ return `${output.dir}/${output.name}.annotated.png`;
23
+ }
24
+ function actionTarget(target) {
25
+ if (target === undefined) {
26
+ return undefined;
27
+ }
28
+ const bounds = target.element.bounds;
29
+ const point = target.element.center ?? (bounds === undefined
30
+ ? undefined
31
+ : {
32
+ x: Math.round((bounds.left + bounds.right) / 2),
33
+ y: Math.round((bounds.top + bounds.bottom) / 2)
34
+ });
35
+ if (point === undefined) {
36
+ return undefined;
37
+ }
38
+ return {
39
+ point,
40
+ ...(bounds === undefined ? {} : { bounds })
41
+ };
42
+ }
43
+ export class RecorderService {
44
+ dependencies;
45
+ constructor(dependencies) {
46
+ this.dependencies = dependencies;
47
+ }
48
+ async record(input) {
49
+ const launchActivity = normalizeActivity(input.config.run.packageName, input.config.run.activity);
50
+ const identity = {
51
+ packageName: input.config.run.packageName,
52
+ deviceSerial: input.deviceSerial,
53
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
54
+ timeoutMs: input.config.idle.timeoutMs
55
+ };
56
+ if (!await this.dependencies.adb.isInstalled(identity)) {
57
+ return {
58
+ status: "failed",
59
+ stepsRecorded: 0,
60
+ message: `Package ${input.config.run.packageName} is not installed on ${input.deviceSerial}`
61
+ };
62
+ }
63
+ const stopped = await this.dependencies.adb.forceStop(identity);
64
+ if (failedCommand(stopped)) {
65
+ return {
66
+ status: "failed",
67
+ stepsRecorded: 0,
68
+ message: commandMessage(stopped, "App reset failed")
69
+ };
70
+ }
71
+ const launchError = launchFailure(await this.dependencies.adb.launchActivity({
72
+ packageName: input.config.run.packageName,
73
+ activity: launchActivity,
74
+ deviceSerial: input.deviceSerial,
75
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
76
+ timeoutMs: input.config.idle.timeoutMs
77
+ }));
78
+ if (launchError !== undefined) {
79
+ return {
80
+ status: "failed",
81
+ stepsRecorded: 0,
82
+ message: launchError
83
+ };
84
+ }
85
+ const processReadiness = await new ProcessWaiter(this.dependencies.adb, this.dependencies.clock).wait({
86
+ packageName: input.config.run.packageName,
87
+ deviceSerial: input.deviceSerial,
88
+ pollIntervalMs: input.config.idle.pollIntervalMs,
89
+ timeoutMs: input.config.idle.timeoutMs,
90
+ ...(input.signal === undefined ? {} : { signal: input.signal })
91
+ });
92
+ if (processReadiness.status === "cancelled") {
93
+ return { status: "cancelled", stepsRecorded: 0 };
94
+ }
95
+ if (processReadiness.status === "timeout") {
96
+ return {
97
+ status: "failed",
98
+ stepsRecorded: 0,
99
+ message: "App process was not found after launch"
100
+ };
101
+ }
102
+ const idleWaiter = new IdleWaiter(this.dependencies.androidCli, this.dependencies.clock, input.deviceSerial);
103
+ await this.dependencies.androidCli.layout({
104
+ deviceSerial: input.deviceSerial,
105
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
106
+ timeoutMs: input.config.idle.timeoutMs
107
+ });
108
+ const startupIdle = await idleWaiter.waitUntilIdle(input.config.idle, input.signal);
109
+ if (startupIdle.status === "cancelled") {
110
+ return { status: "cancelled", stepsRecorded: 0 };
111
+ }
112
+ if (startupIdle.status === "timeout") {
113
+ return {
114
+ status: "failed",
115
+ stepsRecorded: 0,
116
+ message: "Layout did not become stable before timeout"
117
+ };
118
+ }
119
+ const steps = [];
120
+ const executor = new ActionExecutor(this.dependencies.adb, input.deviceSerial);
121
+ for (;;) {
122
+ const layout = await this.dependencies.androidCli.layout({
123
+ deviceSerial: input.deviceSerial,
124
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
125
+ timeoutMs: input.config.idle.timeoutMs
126
+ });
127
+ const action = await this.dependencies.prompt.selectAction();
128
+ if (action === "cancel") {
129
+ return { status: "cancelled", stepsRecorded: steps.length };
130
+ }
131
+ if (action === "finish") {
132
+ if (steps.length === 0) {
133
+ return {
134
+ status: "failed",
135
+ stepsRecorded: 0,
136
+ message: "Journey requires at least one successful step"
137
+ };
138
+ }
139
+ const journey = JourneySchema.parse({
140
+ version: 1,
141
+ name: input.journeyName,
142
+ steps
143
+ });
144
+ await this.dependencies.journeyWriter.write(input.outputPath, journey);
145
+ return { status: "completed", stepsRecorded: steps.length, journey };
146
+ }
147
+ const prepared = await this.prepareAction(action, layout, input);
148
+ if (prepared === undefined) {
149
+ continue;
150
+ }
151
+ // scrollTo captures `before` post-scroll by design: it does not navigate, so before==after in practice (replay checks `before` pre-scroll but the Activity is unchanged).
152
+ const before = normalizeActivity(input.config.run.packageName, await this.dependencies.adb.currentActivity(identity));
153
+ if (prepared.draft.action !== "scrollTo") {
154
+ const execution = await executor.execute(prepared.draft, actionTarget(prepared.target), input.signal);
155
+ if (execution.status === "failed") {
156
+ await this.dependencies.prompt.notifyFailure(execution.message);
157
+ continue;
158
+ }
159
+ const idle = await idleWaiter.waitUntilIdle(input.config.idle, input.signal);
160
+ if (idle.status !== "stable") {
161
+ if (idle.status === "cancelled") {
162
+ return { status: "cancelled", stepsRecorded: steps.length };
163
+ }
164
+ return {
165
+ status: "failed",
166
+ stepsRecorded: steps.length,
167
+ message: "Layout did not become stable before timeout"
168
+ };
169
+ }
170
+ }
171
+ const processes = await this.dependencies.adb.appProcesses(identity);
172
+ if (primaryAppPid(processes, input.config.run.packageName) === null) {
173
+ return {
174
+ status: "failed",
175
+ stepsRecorded: steps.length,
176
+ message: "App process crashed after the recorded Action"
177
+ };
178
+ }
179
+ const after = normalizeActivity(input.config.run.packageName, await this.dependencies.adb.currentActivity(identity));
180
+ steps.push(JourneyStepSchema.parse({
181
+ ...prepared.draft,
182
+ activity: { before, after }
183
+ }));
184
+ }
185
+ }
186
+ async prepareAction(action, layout, input) {
187
+ if (action === "scrollTo") {
188
+ return this.prepareScrollTo(layout, input);
189
+ }
190
+ if (action === "inputText") {
191
+ return {
192
+ draft: { action, text: await this.dependencies.prompt.inputText() }
193
+ };
194
+ }
195
+ if (action === "back" || action === "wait") {
196
+ return { draft: { action } };
197
+ }
198
+ const targets = listRecorderTargets(layout, action);
199
+ if (targets.length === 0) {
200
+ await this.dependencies.prompt.notifyFailure("No enabled element has a unique deterministic Locator");
201
+ return undefined;
202
+ }
203
+ const selectedId = await this.dependencies.prompt.selectTarget(targets.map((target) => ({
204
+ id: target.element.id,
205
+ label: target.label
206
+ })));
207
+ const target = targets.find((candidate) => candidate.element.id === selectedId);
208
+ if (target === undefined) {
209
+ await this.dependencies.prompt.notifyFailure("Selected Layout element is unavailable");
210
+ return undefined;
211
+ }
212
+ if (action === "swipe") {
213
+ const options = await this.dependencies.prompt.swipeOptions();
214
+ return {
215
+ target,
216
+ draft: {
217
+ action,
218
+ locator: target.locator,
219
+ direction: await this.dependencies.prompt.selectSwipeDirection(),
220
+ ...options
221
+ }
222
+ };
223
+ }
224
+ let fallback;
225
+ const screenshotPath = annotatedPath(input.outputPath);
226
+ const capture = await this.dependencies.androidCli.captureScreen({
227
+ outputPath: screenshotPath,
228
+ annotate: true,
229
+ deviceSerial: input.deviceSerial,
230
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
231
+ timeoutMs: input.config.idle.timeoutMs
232
+ });
233
+ if (!failedCommand(capture)) {
234
+ const label = await this.dependencies.prompt.selectFallbackLabel(screenshotPath);
235
+ if (label !== undefined) {
236
+ fallback = { type: "annotatedLabel", label };
237
+ }
238
+ }
239
+ if (action === "click") {
240
+ return {
241
+ target,
242
+ draft: {
243
+ action,
244
+ locator: target.locator,
245
+ ...(fallback === undefined ? {} : { fallback })
246
+ }
247
+ };
248
+ }
249
+ return {
250
+ target,
251
+ draft: {
252
+ action,
253
+ locator: target.locator,
254
+ durationMs: await this.dependencies.prompt.longClickDuration(),
255
+ ...(fallback === undefined ? {} : { fallback })
256
+ }
257
+ };
258
+ }
259
+ async prepareScrollTo(layout, input) {
260
+ const containers = listRecorderTargets(layout, "swipe");
261
+ if (containers.length === 0) {
262
+ await this.dependencies.prompt.notifyFailure("No scrollable element has a unique deterministic Locator");
263
+ return undefined;
264
+ }
265
+ const containerId = await this.dependencies.prompt.selectScrollContainer(containers.map((c) => ({ id: c.element.id, label: c.label })));
266
+ const container = containers.find((c) => c.element.id === containerId);
267
+ if (container === undefined) {
268
+ await this.dependencies.prompt.notifyFailure("Selected container is unavailable");
269
+ return undefined;
270
+ }
271
+ const direction = await this.dependencies.prompt.selectSwipeDirection();
272
+ const options = await this.dependencies.prompt.swipeOptions();
273
+ const executor = new ActionExecutor(this.dependencies.adb, input.deviceSerial);
274
+ const idleWaiter = new IdleWaiter(this.dependencies.androidCli, this.dependencies.clock, input.deviceSerial);
275
+ let currentLayout = layout;
276
+ let swipesUsed = 0;
277
+ for (;;) {
278
+ const targets = listLocatableTargets(currentLayout);
279
+ const decision = await this.dependencies.prompt.scrollTargetDecision(targets.map((t) => ({ id: t.element.id, label: t.label })));
280
+ if (decision.kind === "cancel") {
281
+ return undefined;
282
+ }
283
+ if (decision.kind === "select") {
284
+ const target = targets.find((t) => t.element.id === decision.id);
285
+ if (target === undefined) {
286
+ await this.dependencies.prompt.notifyFailure("Selected target is unavailable");
287
+ return undefined;
288
+ }
289
+ return {
290
+ target,
291
+ draft: {
292
+ action: "scrollTo",
293
+ locator: target.locator,
294
+ container: container.locator,
295
+ direction,
296
+ maxSwipes: Math.min(30, swipesUsed + 5),
297
+ distancePercent: options.distancePercent,
298
+ durationMs: options.durationMs
299
+ }
300
+ };
301
+ }
302
+ const resolved = resolveLocator(currentLayout, container.locator, {
303
+ requireEnabled: false
304
+ });
305
+ if (resolved.status !== "found" || resolved.element.bounds === undefined) {
306
+ await this.dependencies.prompt.notifyFailure("Scroll container disappeared");
307
+ return undefined;
308
+ }
309
+ if (swipesUsed >= 30) {
310
+ await this.dependencies.prompt.notifyFailure("scrollTo reached the 30-swipe recording cap; the Journey would not replay");
311
+ return undefined;
312
+ }
313
+ const swipe = await executor.swipeBounds(resolved.element.bounds, direction, options.distancePercent, options.durationMs, input.signal);
314
+ if (swipe.status === "failed") {
315
+ await this.dependencies.prompt.notifyFailure(swipe.message);
316
+ return undefined;
317
+ }
318
+ const idle = await idleWaiter.waitUntilIdle(input.config.idle, input.signal);
319
+ if (idle.status !== "stable") {
320
+ await this.dependencies.prompt.notifyFailure("Layout did not become stable before timeout");
321
+ return undefined;
322
+ }
323
+ swipesUsed += 1;
324
+ currentLayout = await this.dependencies.androidCli.layout({
325
+ deviceSerial: input.deviceSerial,
326
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
327
+ timeoutMs: input.config.idle.timeoutMs
328
+ });
329
+ }
330
+ }
331
+ }
@@ -0,0 +1,10 @@
1
+ import { type TapHoundReport } from "../../domain/report.js";
2
+ import type { ArtifactSession } from "../../ports/artifact-store.js";
3
+ export interface PublishedReport {
4
+ directory: string;
5
+ reportPath: string;
6
+ summaryPath: string;
7
+ }
8
+ export declare class ReportWriter {
9
+ writeAndPublish(session: ArtifactSession, input: TapHoundReport): Promise<PublishedReport>;
10
+ }
@@ -0,0 +1,39 @@
1
+ import { join } from "node:path";
2
+ import { TapHoundReportSchema } from "../../domain/report.js";
3
+ function renderSummary(report) {
4
+ const lines = [
5
+ `TapHound run ${report.runId}: ${report.status.toUpperCase()}`,
6
+ `Journey: ${report.journey.name}`,
7
+ `Package: ${report.project.packageName}`,
8
+ `Device: ${report.environment.deviceSerial}`,
9
+ "",
10
+ "Layers:",
11
+ ...Object.entries(report.layers).map(([layer, status]) => `- ${layer}: ${status}`)
12
+ ];
13
+ if (report.primaryFailure !== undefined) {
14
+ lines.push("", `Primary failure: ${report.primaryFailure.code}: ${report.primaryFailure.message}`);
15
+ }
16
+ if (report.secondaryErrors.length > 0) {
17
+ lines.push("", "Secondary errors:", ...report.secondaryErrors.map((error) => `- ${error.code}: ${error.message}`));
18
+ }
19
+ return `${lines.join("\n")}\n`;
20
+ }
21
+ export class ReportWriter {
22
+ async writeAndPublish(session, input) {
23
+ const report = TapHoundReportSchema.parse(input);
24
+ try {
25
+ await session.writeJson("report.json", report);
26
+ await session.writeText("summary.txt", renderSummary(report));
27
+ const directory = await session.publish();
28
+ return {
29
+ directory,
30
+ reportPath: join(directory, "report.json"),
31
+ summaryPath: join(directory, "summary.txt")
32
+ };
33
+ }
34
+ catch (error) {
35
+ await session.discard();
36
+ throw error;
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,33 @@
1
+ import type { AdbPort } from "../../ports/adb.js";
2
+ import type { Clock } from "../../ports/clock.js";
3
+ export interface ActivityWaitOptions {
4
+ packageName: string;
5
+ deviceSerial: string;
6
+ expectedActivity: string;
7
+ pollIntervalMs: number;
8
+ timeoutMs: number;
9
+ signal?: AbortSignal | undefined;
10
+ }
11
+ export type ActivityWaitResult = {
12
+ status: "ready";
13
+ actual: string;
14
+ durationMs: number;
15
+ } | {
16
+ status: "timeout";
17
+ actual?: string | undefined;
18
+ durationMs: number;
19
+ } | {
20
+ status: "processMissing";
21
+ actual?: string | undefined;
22
+ durationMs: number;
23
+ } | {
24
+ status: "cancelled";
25
+ actual?: string | undefined;
26
+ durationMs: number;
27
+ };
28
+ export declare class ActivityWaiter {
29
+ private readonly adb;
30
+ private readonly clock;
31
+ constructor(adb: AdbPort, clock: Clock);
32
+ wait(options: ActivityWaitOptions): Promise<ActivityWaitResult>;
33
+ }
@@ -0,0 +1,73 @@
1
+ import { primaryAppPid } from "../../domain/app-process.js";
2
+ function isAborted(signal) {
3
+ return signal?.aborted === true;
4
+ }
5
+ export class ActivityWaiter {
6
+ adb;
7
+ clock;
8
+ constructor(adb, clock) {
9
+ this.adb = adb;
10
+ this.clock = clock;
11
+ }
12
+ async wait(options) {
13
+ const startedAt = this.clock.now();
14
+ let actual;
15
+ for (;;) {
16
+ if (isAborted(options.signal)) {
17
+ return {
18
+ status: "cancelled",
19
+ durationMs: this.clock.now() - startedAt,
20
+ ...(actual === undefined ? {} : { actual })
21
+ };
22
+ }
23
+ const elapsed = this.clock.now() - startedAt;
24
+ if (elapsed >= options.timeoutMs) {
25
+ return {
26
+ status: "timeout",
27
+ durationMs: elapsed,
28
+ ...(actual === undefined ? {} : { actual })
29
+ };
30
+ }
31
+ const commandTimeoutMs = Math.max(1, options.timeoutMs - elapsed);
32
+ const identity = {
33
+ packageName: options.packageName,
34
+ deviceSerial: options.deviceSerial,
35
+ timeoutMs: commandTimeoutMs,
36
+ ...(options.signal === undefined ? {} : { signal: options.signal })
37
+ };
38
+ const processes = await this.adb.appProcesses(identity);
39
+ if (primaryAppPid(processes, options.packageName) === null) {
40
+ return {
41
+ status: "processMissing",
42
+ durationMs: this.clock.now() - startedAt,
43
+ ...(actual === undefined ? {} : { actual })
44
+ };
45
+ }
46
+ actual = await this.adb.currentActivity(identity);
47
+ if (actual === options.expectedActivity) {
48
+ return {
49
+ status: "ready",
50
+ actual,
51
+ durationMs: this.clock.now() - startedAt
52
+ };
53
+ }
54
+ const elapsedAfterPoll = this.clock.now() - startedAt;
55
+ if (elapsedAfterPoll >= options.timeoutMs) {
56
+ return { status: "timeout", actual, durationMs: elapsedAfterPoll };
57
+ }
58
+ try {
59
+ await this.clock.sleep(Math.min(options.pollIntervalMs, options.timeoutMs - elapsedAfterPoll), options.signal);
60
+ }
61
+ catch (error) {
62
+ if (isAborted(options.signal)) {
63
+ return {
64
+ status: "cancelled",
65
+ actual,
66
+ durationMs: this.clock.now() - startedAt
67
+ };
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandResult } from "../../ports/process-runner.js";
2
+ export declare function launchFailure(result: CommandResult): string | undefined;
@@ -0,0 +1,20 @@
1
+ const AM_START_ERROR = /^Error(:| type)/m;
2
+ export function launchFailure(result) {
3
+ if (result.spawnError !== undefined) {
4
+ return result.spawnError;
5
+ }
6
+ if (result.timedOut) {
7
+ return "App launch timed out";
8
+ }
9
+ if (result.cancelled) {
10
+ return "App launch was cancelled";
11
+ }
12
+ if (result.exitCode !== 0) {
13
+ return result.stderr.trim()
14
+ || `App launch exited with code ${String(result.exitCode)}`;
15
+ }
16
+ const reported = result.stdout
17
+ .split(/\r?\n/)
18
+ .find((line) => AM_START_ERROR.test(line.trim()));
19
+ return reported?.trim();
20
+ }
@@ -0,0 +1,27 @@
1
+ import type { AdbPort } from "../../ports/adb.js";
2
+ import type { Clock } from "../../ports/clock.js";
3
+ export interface ProcessWaitOptions {
4
+ packageName: string;
5
+ deviceSerial: string;
6
+ pollIntervalMs: number;
7
+ timeoutMs: number;
8
+ signal?: AbortSignal | undefined;
9
+ }
10
+ export type ProcessWaitResult = {
11
+ status: "ready";
12
+ pid: number;
13
+ pids: readonly number[];
14
+ durationMs: number;
15
+ } | {
16
+ status: "timeout";
17
+ durationMs: number;
18
+ } | {
19
+ status: "cancelled";
20
+ durationMs: number;
21
+ };
22
+ export declare class ProcessWaiter {
23
+ private readonly adb;
24
+ private readonly clock;
25
+ constructor(adb: AdbPort, clock: Clock);
26
+ wait(options: ProcessWaitOptions): Promise<ProcessWaitResult>;
27
+ }
@@ -0,0 +1,73 @@
1
+ import { appProcessPids, primaryAppPid } from "../../domain/app-process.js";
2
+ function isAborted(signal) {
3
+ return signal?.aborted === true;
4
+ }
5
+ export class ProcessWaiter {
6
+ adb;
7
+ clock;
8
+ constructor(adb, clock) {
9
+ this.adb = adb;
10
+ this.clock = clock;
11
+ }
12
+ async wait(options) {
13
+ const startedAt = this.clock.now();
14
+ for (;;) {
15
+ if (isAborted(options.signal)) {
16
+ return {
17
+ status: "cancelled",
18
+ durationMs: this.clock.now() - startedAt
19
+ };
20
+ }
21
+ const elapsed = this.clock.now() - startedAt;
22
+ const remainingMs = options.timeoutMs - elapsed;
23
+ if (remainingMs <= 0) {
24
+ return { status: "timeout", durationMs: elapsed };
25
+ }
26
+ let pid;
27
+ let pids;
28
+ try {
29
+ const processes = await this.adb.appProcesses({
30
+ packageName: options.packageName,
31
+ deviceSerial: options.deviceSerial,
32
+ timeoutMs: remainingMs,
33
+ ...(options.signal === undefined ? {} : { signal: options.signal })
34
+ });
35
+ pid = primaryAppPid(processes, options.packageName);
36
+ pids = appProcessPids(processes);
37
+ }
38
+ catch (error) {
39
+ if (isAborted(options.signal)) {
40
+ return {
41
+ status: "cancelled",
42
+ durationMs: this.clock.now() - startedAt
43
+ };
44
+ }
45
+ throw error;
46
+ }
47
+ if (pid !== null) {
48
+ return {
49
+ status: "ready",
50
+ pid,
51
+ pids,
52
+ durationMs: this.clock.now() - startedAt
53
+ };
54
+ }
55
+ const elapsedAfterPoll = this.clock.now() - startedAt;
56
+ if (elapsedAfterPoll >= options.timeoutMs) {
57
+ return { status: "timeout", durationMs: elapsedAfterPoll };
58
+ }
59
+ try {
60
+ await this.clock.sleep(Math.min(options.pollIntervalMs, options.timeoutMs - elapsedAfterPoll), options.signal);
61
+ }
62
+ catch (error) {
63
+ if (isAborted(options.signal)) {
64
+ return {
65
+ status: "cancelled",
66
+ durationMs: this.clock.now() - startedAt
67
+ };
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,50 @@
1
+ import type { JourneyStep } from "../../domain/journey.js";
2
+ import type { ReportFailure, StepReport } from "../../domain/report.js";
3
+ import type { AdbPort } from "../../ports/adb.js";
4
+ import type { AndroidCliPort } from "../../ports/android-cli.js";
5
+ import type { ArtifactSession } from "../../ports/artifact-store.js";
6
+ import type { Clock } from "../../ports/clock.js";
7
+ import type { LogcatCollector } from "../collector/logcat-collector.js";
8
+ import { type IdleConfig } from "../wait/idle-waiter.js";
9
+ export interface StepRunnerOptions {
10
+ adb: AdbPort;
11
+ androidCli: AndroidCliPort;
12
+ clock: Clock;
13
+ logcat: LogcatCollector;
14
+ artifacts: ArtifactSession;
15
+ packageName: string;
16
+ deviceSerial: string;
17
+ idle: IdleConfig;
18
+ requireFocusedInput?: boolean;
19
+ generatedReplayPolicy?: boolean | undefined;
20
+ }
21
+ export type StepRunResult = {
22
+ status: "passed";
23
+ report: StepReport;
24
+ } | {
25
+ status: "failed";
26
+ report: StepReport;
27
+ failure: ReportFailure;
28
+ } | {
29
+ status: "cancelled";
30
+ report: StepReport;
31
+ };
32
+ export declare class StepRunner {
33
+ private readonly options;
34
+ private readonly actionExecutor;
35
+ private readonly fallbackResolver;
36
+ private readonly idleWaiter;
37
+ private readonly expectationEvaluator;
38
+ private readonly scrollToExecutor;
39
+ constructor(options: StepRunnerOptions);
40
+ private readonly identity;
41
+ private readonly primaryPid;
42
+ private generatedForeground;
43
+ private assertGeneratedForeground;
44
+ private currentExpectedActivity;
45
+ private currentSignal;
46
+ private observeExpectedActivity;
47
+ private observeExpectedLayout;
48
+ private captureGeneratedLayout;
49
+ run(step: JourneyStep, index: number, signal?: AbortSignal): Promise<StepRunResult>;
50
+ }