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.
- package/LICENSE +202 -0
- package/README.md +200 -0
- package/README.zh-CN.md +200 -0
- package/assets/brand/taphound-mark.svg +9 -0
- package/assets/skills/taphound-ai-journey/GUIDE.md +862 -0
- package/assets/skills/taphound-ai-journey/SKILL.md +377 -0
- package/assets/skills/taphound-ai-journey/prompts/analyze-project.md +526 -0
- package/assets/skills/taphound-ai-journey/prompts/check-completion.md +52 -0
- package/assets/skills/taphound-ai-journey/prompts/generate-step.md +109 -0
- package/assets/skills/taphound-ai-journey/schemas/observe-output.json +42 -0
- package/assets/skills/taphound-ai-journey/schemas/project-context.json +81 -0
- package/assets/skills/taphound-ai-journey/schemas/proposed-step-envelope.json +267 -0
- package/assets/skills/taphound-ai-journey/templates/project-context.example.json +49 -0
- package/dist/adapters/adb/adb-adapter.d.ts +23 -0
- package/dist/adapters/adb/adb-adapter.js +218 -0
- package/dist/adapters/android-cli/android-cli-adapter.d.ts +11 -0
- package/dist/adapters/android-cli/android-cli-adapter.js +64 -0
- package/dist/adapters/android-cli/layout-parser.d.ts +3 -0
- package/dist/adapters/android-cli/layout-parser.js +174 -0
- package/dist/adapters/clock/system-clock.d.ts +5 -0
- package/dist/adapters/clock/system-clock.js +13 -0
- package/dist/adapters/filesystem/artifact-store.d.ts +4 -0
- package/dist/adapters/filesystem/artifact-store.js +73 -0
- package/dist/adapters/filesystem/generation-meta-writer.d.ts +16 -0
- package/dist/adapters/filesystem/generation-meta-writer.js +34 -0
- package/dist/adapters/filesystem/generation-session-store.d.ts +68 -0
- package/dist/adapters/filesystem/generation-session-store.js +1603 -0
- package/dist/adapters/filesystem/journey-writer.d.ts +16 -0
- package/dist/adapters/filesystem/journey-writer.js +34 -0
- package/dist/adapters/filesystem/project-bound-file.d.ts +6 -0
- package/dist/adapters/filesystem/project-bound-file.js +240 -0
- package/dist/adapters/filesystem/project-file-inspector.d.ts +8 -0
- package/dist/adapters/filesystem/project-file-inspector.js +198 -0
- package/dist/adapters/filesystem/skill-installer.d.ts +10 -0
- package/dist/adapters/filesystem/skill-installer.js +39 -0
- package/dist/adapters/process/node-process-runner.d.ts +8 -0
- package/dist/adapters/process/node-process-runner.js +155 -0
- package/dist/adapters/prompt/inquirer-generation-prompt.d.ts +55 -0
- package/dist/adapters/prompt/inquirer-generation-prompt.js +213 -0
- package/dist/adapters/prompt/inquirer-init-prompt.d.ts +32 -0
- package/dist/adapters/prompt/inquirer-init-prompt.js +55 -0
- package/dist/adapters/prompt/inquirer-recorder-prompt.d.ts +47 -0
- package/dist/adapters/prompt/inquirer-recorder-prompt.js +157 -0
- package/dist/application/assertion/expectation-evaluator.d.ts +61 -0
- package/dist/application/assertion/expectation-evaluator.js +207 -0
- package/dist/application/collector/logcat-collector.d.ts +39 -0
- package/dist/application/collector/logcat-collector.js +107 -0
- package/dist/application/context/context-validator.d.ts +27 -0
- package/dist/application/context/context-validator.js +120 -0
- package/dist/application/doctor/doctor-service.d.ts +35 -0
- package/dist/application/doctor/doctor-service.js +178 -0
- package/dist/application/generation/focused-input.d.ts +2 -0
- package/dist/application/generation/focused-input.js +9 -0
- package/dist/application/generation/generation-confirmation-service.d.ts +76 -0
- package/dist/application/generation/generation-confirmation-service.js +383 -0
- package/dist/application/generation/generation-finalizer.d.ts +67 -0
- package/dist/application/generation/generation-finalizer.js +646 -0
- package/dist/application/generation/generation-publisher.d.ts +46 -0
- package/dist/application/generation/generation-publisher.js +178 -0
- package/dist/application/generation/generation-starter.d.ts +31 -0
- package/dist/application/generation/generation-starter.js +102 -0
- package/dist/application/generation/generation-step-executor.d.ts +52 -0
- package/dist/application/generation/generation-step-executor.js +668 -0
- package/dist/application/generation/proposed-step-validator.d.ts +11 -0
- package/dist/application/generation/proposed-step-validator.js +88 -0
- package/dist/application/generation/risk-evaluator.d.ts +9 -0
- package/dist/application/generation/risk-evaluator.js +14 -0
- package/dist/application/generation/runtime-observer.d.ts +39 -0
- package/dist/application/generation/runtime-observer.js +157 -0
- package/dist/application/init/init-service.d.ts +17 -0
- package/dist/application/init/init-service.js +44 -0
- package/dist/application/interaction/action-executor.d.ts +25 -0
- package/dist/application/interaction/action-executor.js +103 -0
- package/dist/application/interaction/fallback-resolver.d.ts +24 -0
- package/dist/application/interaction/fallback-resolver.js +48 -0
- package/dist/application/interaction/scroll-to-executor.d.ts +40 -0
- package/dist/application/interaction/scroll-to-executor.js +191 -0
- package/dist/application/locator/locator-resolver.d.ts +20 -0
- package/dist/application/locator/locator-resolver.js +89 -0
- package/dist/application/project/project-describer.d.ts +14 -0
- package/dist/application/project/project-describer.js +8 -0
- package/dist/application/recorder/locator-selector.d.ts +10 -0
- package/dist/application/recorder/locator-selector.js +80 -0
- package/dist/application/recorder/recorder-service.d.ts +41 -0
- package/dist/application/recorder/recorder-service.js +331 -0
- package/dist/application/report/report-writer.d.ts +10 -0
- package/dist/application/report/report-writer.js +39 -0
- package/dist/application/runtime/activity-waiter.d.ts +33 -0
- package/dist/application/runtime/activity-waiter.js +73 -0
- package/dist/application/runtime/launch-failure.d.ts +2 -0
- package/dist/application/runtime/launch-failure.js +20 -0
- package/dist/application/runtime/process-waiter.d.ts +27 -0
- package/dist/application/runtime/process-waiter.js +73 -0
- package/dist/application/runtime/step-runner.d.ts +50 -0
- package/dist/application/runtime/step-runner.js +429 -0
- package/dist/application/runtime/verify-runtime.d.ts +44 -0
- package/dist/application/runtime/verify-runtime.js +350 -0
- package/dist/application/wait/idle-waiter.d.ts +30 -0
- package/dist/application/wait/idle-waiter.js +90 -0
- package/dist/cli/commands/context.d.ts +3 -0
- package/dist/cli/commands/context.js +94 -0
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +51 -0
- package/dist/cli/commands/generation.d.ts +3 -0
- package/dist/cli/commands/generation.js +518 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.js +104 -0
- package/dist/cli/commands/project.d.ts +3 -0
- package/dist/cli/commands/project.js +58 -0
- package/dist/cli/commands/record.d.ts +3 -0
- package/dist/cli/commands/record.js +88 -0
- package/dist/cli/commands/verify.d.ts +3 -0
- package/dist/cli/commands/verify.js +106 -0
- package/dist/cli/dependencies.d.ts +66 -0
- package/dist/cli/dependencies.js +204 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +73 -0
- package/dist/cli/output.d.ts +16 -0
- package/dist/cli/output.js +22 -0
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.js +43 -0
- package/dist/domain/activity.d.ts +7 -0
- package/dist/domain/activity.js +65 -0
- package/dist/domain/app-process.d.ts +7 -0
- package/dist/domain/app-process.js +10 -0
- package/dist/domain/config.d.ts +15 -0
- package/dist/domain/config.js +17 -0
- package/dist/domain/failure.d.ts +4 -0
- package/dist/domain/failure.js +43 -0
- package/dist/domain/generation.d.ts +912 -0
- package/dist/domain/generation.js +342 -0
- package/dist/domain/init.d.ts +42 -0
- package/dist/domain/init.js +114 -0
- package/dist/domain/journey.d.ts +673 -0
- package/dist/domain/journey.js +109 -0
- package/dist/domain/layout.d.ts +35 -0
- package/dist/domain/layout.js +33 -0
- package/dist/domain/locator.d.ts +8 -0
- package/dist/domain/locator.js +5 -0
- package/dist/domain/project-context.d.ts +101 -0
- package/dist/domain/project-context.js +64 -0
- package/dist/domain/proposed-step.d.ts +341 -0
- package/dist/domain/proposed-step.js +87 -0
- package/dist/domain/report.d.ts +349 -0
- package/dist/domain/report.js +135 -0
- package/dist/domain/runtime-snapshot.d.ts +16 -0
- package/dist/domain/runtime-snapshot.js +46 -0
- package/dist/ports/adb.d.ts +42 -0
- package/dist/ports/adb.js +1 -0
- package/dist/ports/android-cli.d.ts +21 -0
- package/dist/ports/android-cli.js +1 -0
- package/dist/ports/artifact-store.d.ts +12 -0
- package/dist/ports/artifact-store.js +1 -0
- package/dist/ports/clock.d.ts +4 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/generation-meta-writer.d.ts +10 -0
- package/dist/ports/generation-meta-writer.js +1 -0
- package/dist/ports/generation-prompt.d.ts +19 -0
- package/dist/ports/generation-prompt.js +6 -0
- package/dist/ports/generation-session-store.d.ts +32 -0
- package/dist/ports/generation-session-store.js +26 -0
- package/dist/ports/init-prompt.d.ts +8 -0
- package/dist/ports/init-prompt.js +6 -0
- package/dist/ports/journey-writer.d.ts +10 -0
- package/dist/ports/journey-writer.js +1 -0
- package/dist/ports/process-runner.d.ts +32 -0
- package/dist/ports/process-runner.js +1 -0
- package/dist/ports/project-bound-file.d.ts +8 -0
- package/dist/ports/project-bound-file.js +1 -0
- package/dist/ports/project-file-inspector.d.ts +33 -0
- package/dist/ports/project-file-inspector.js +1 -0
- package/dist/ports/recorder-prompt.d.ts +29 -0
- package/dist/ports/recorder-prompt.js +1 -0
- package/dist/ports/skill-installer.d.ts +8 -0
- package/dist/ports/skill-installer.js +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { parseObservedActivityComponent } from "../../domain/activity.js";
|
|
2
|
+
import { isAppProcessName } from "../../domain/app-process.js";
|
|
3
|
+
const PROCESS_ROW = /^(\d+)\s+(\S+)$/;
|
|
4
|
+
function deviceArgs(deviceSerial) {
|
|
5
|
+
return deviceSerial === undefined ? [] : ["-s", deviceSerial];
|
|
6
|
+
}
|
|
7
|
+
function assertCommandUsable(result, operation) {
|
|
8
|
+
if (result.spawnError !== undefined) {
|
|
9
|
+
throw new Error(`ADB ${operation} failed: ${result.spawnError}`);
|
|
10
|
+
}
|
|
11
|
+
if (result.timedOut) {
|
|
12
|
+
throw new Error(`ADB ${operation} timed out`);
|
|
13
|
+
}
|
|
14
|
+
if (result.cancelled) {
|
|
15
|
+
throw new Error(`ADB ${operation} was cancelled`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function quoteRemoteShellArgument(value) {
|
|
19
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
20
|
+
}
|
|
21
|
+
function inputTextChunks(text) {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let start = 0;
|
|
24
|
+
for (let index = 0; index < text.length - 1; index += 1) {
|
|
25
|
+
if (text[index] === "%" && text[index + 1] === "s") {
|
|
26
|
+
chunks.push(text.slice(start, index + 1));
|
|
27
|
+
start = index + 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
chunks.push(text.slice(start));
|
|
31
|
+
return chunks;
|
|
32
|
+
}
|
|
33
|
+
export class AdbAdapter {
|
|
34
|
+
runner;
|
|
35
|
+
constructor(runner) {
|
|
36
|
+
this.runner = runner;
|
|
37
|
+
}
|
|
38
|
+
run(args, signal, timeoutMs) {
|
|
39
|
+
return this.runner.run({
|
|
40
|
+
executable: "adb",
|
|
41
|
+
args,
|
|
42
|
+
...(signal === undefined ? {} : { signal }),
|
|
43
|
+
...(timeoutMs === undefined ? {} : { timeoutMs })
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async devices(signal) {
|
|
47
|
+
const result = await this.run(["devices"], signal);
|
|
48
|
+
return result.stdout
|
|
49
|
+
.split(/\r?\n/)
|
|
50
|
+
.slice(1)
|
|
51
|
+
.map((line) => line.trim())
|
|
52
|
+
.filter((line) => line.length > 0)
|
|
53
|
+
.map((line) => {
|
|
54
|
+
const [serial, status] = line.split(/\s+/, 2);
|
|
55
|
+
if (serial === undefined || status === undefined) {
|
|
56
|
+
throw new Error(`Invalid adb devices line: ${line}`);
|
|
57
|
+
}
|
|
58
|
+
return { serial, status };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async foregroundComponent(identity) {
|
|
62
|
+
const result = await this.run([
|
|
63
|
+
...deviceArgs(identity.deviceSerial),
|
|
64
|
+
"shell",
|
|
65
|
+
"dumpsys",
|
|
66
|
+
"activity",
|
|
67
|
+
"activities"
|
|
68
|
+
], identity.signal, identity.timeoutMs);
|
|
69
|
+
const resumed = result.stdout.split(/\r?\n/).find((line) => /\b(?:mResumedActivity|topResumedActivity|ResumedActivity)\s*[:=]/
|
|
70
|
+
.test(line));
|
|
71
|
+
const component = resumed === undefined
|
|
72
|
+
? null
|
|
73
|
+
: /\b([A-Za-z_$][\w.$]*)\/(\.?[A-Za-z_$][\w.$]*)\b/.exec(resumed);
|
|
74
|
+
if (component?.[1] === undefined || component[2] === undefined) {
|
|
75
|
+
throw new Error("ADB did not report a resumed Activity");
|
|
76
|
+
}
|
|
77
|
+
return parseObservedActivityComponent(`${component[1]}/${component[2]}`);
|
|
78
|
+
}
|
|
79
|
+
async currentActivity(identity) {
|
|
80
|
+
return (await this.foregroundComponent(identity)).activity;
|
|
81
|
+
}
|
|
82
|
+
async isInstalled(identity) {
|
|
83
|
+
const result = await this.run([
|
|
84
|
+
...deviceArgs(identity.deviceSerial),
|
|
85
|
+
"shell",
|
|
86
|
+
"pm",
|
|
87
|
+
"path",
|
|
88
|
+
identity.packageName
|
|
89
|
+
], identity.signal, identity.timeoutMs);
|
|
90
|
+
assertCommandUsable(result, "pm path");
|
|
91
|
+
return /^package:/m.test(result.stdout);
|
|
92
|
+
}
|
|
93
|
+
launchActivity(options) {
|
|
94
|
+
return this.run([
|
|
95
|
+
...deviceArgs(options.deviceSerial),
|
|
96
|
+
"shell",
|
|
97
|
+
"am",
|
|
98
|
+
"start",
|
|
99
|
+
"-W",
|
|
100
|
+
"-n",
|
|
101
|
+
`${options.packageName}/${options.activity}`
|
|
102
|
+
], options.signal, options.timeoutMs);
|
|
103
|
+
}
|
|
104
|
+
forceStop(identity) {
|
|
105
|
+
return this.run([
|
|
106
|
+
...deviceArgs(identity.deviceSerial),
|
|
107
|
+
"shell",
|
|
108
|
+
"am",
|
|
109
|
+
"force-stop",
|
|
110
|
+
identity.packageName
|
|
111
|
+
], identity.signal, identity.timeoutMs);
|
|
112
|
+
}
|
|
113
|
+
async appProcesses(identity) {
|
|
114
|
+
const result = await this.run([
|
|
115
|
+
...deviceArgs(identity.deviceSerial),
|
|
116
|
+
"shell",
|
|
117
|
+
"ps",
|
|
118
|
+
"-A",
|
|
119
|
+
"-o",
|
|
120
|
+
"PID,NAME"
|
|
121
|
+
], identity.signal, identity.timeoutMs);
|
|
122
|
+
assertCommandUsable(result, "ps");
|
|
123
|
+
if (result.exitCode !== 0) {
|
|
124
|
+
throw new Error(`ADB ps failed: ${result.stderr.trim() || `exit ${String(result.exitCode)}`}`);
|
|
125
|
+
}
|
|
126
|
+
return result.stdout
|
|
127
|
+
.split(/\r?\n/)
|
|
128
|
+
.flatMap((line) => {
|
|
129
|
+
const row = PROCESS_ROW.exec(line.trim());
|
|
130
|
+
if (row?.[1] === undefined || row[2] === undefined) {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
if (!isAppProcessName(row[2], identity.packageName)) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const pid = Number(row[1]);
|
|
137
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
138
|
+
throw new Error(`Invalid PID from adb ps: ${row[1]}`);
|
|
139
|
+
}
|
|
140
|
+
return [{ pid, name: row[2] }];
|
|
141
|
+
})
|
|
142
|
+
.sort((left, right) => left.pid - right.pid);
|
|
143
|
+
}
|
|
144
|
+
tap(point, deviceSerial, signal) {
|
|
145
|
+
return this.run([
|
|
146
|
+
...deviceArgs(deviceSerial),
|
|
147
|
+
"shell",
|
|
148
|
+
"input",
|
|
149
|
+
"tap",
|
|
150
|
+
String(point.x),
|
|
151
|
+
String(point.y)
|
|
152
|
+
], signal);
|
|
153
|
+
}
|
|
154
|
+
longClick(point, durationMs, deviceSerial, signal) {
|
|
155
|
+
return this.swipe(point, point, durationMs, deviceSerial, signal);
|
|
156
|
+
}
|
|
157
|
+
swipe(from, to, durationMs, deviceSerial, signal) {
|
|
158
|
+
return this.run([
|
|
159
|
+
...deviceArgs(deviceSerial),
|
|
160
|
+
"shell",
|
|
161
|
+
"input",
|
|
162
|
+
"swipe",
|
|
163
|
+
String(from.x),
|
|
164
|
+
String(from.y),
|
|
165
|
+
String(to.x),
|
|
166
|
+
String(to.y),
|
|
167
|
+
String(durationMs)
|
|
168
|
+
], signal);
|
|
169
|
+
}
|
|
170
|
+
back(deviceSerial, signal) {
|
|
171
|
+
return this.run([
|
|
172
|
+
...deviceArgs(deviceSerial),
|
|
173
|
+
"shell",
|
|
174
|
+
"input",
|
|
175
|
+
"keyevent",
|
|
176
|
+
"BACK"
|
|
177
|
+
], signal);
|
|
178
|
+
}
|
|
179
|
+
async inputText(text, deviceSerial, signal) {
|
|
180
|
+
let result;
|
|
181
|
+
for (const chunk of inputTextChunks(text)) {
|
|
182
|
+
result = await this.run([
|
|
183
|
+
...deviceArgs(deviceSerial),
|
|
184
|
+
"shell",
|
|
185
|
+
"input",
|
|
186
|
+
"text",
|
|
187
|
+
quoteRemoteShellArgument(chunk)
|
|
188
|
+
], signal);
|
|
189
|
+
if (result.exitCode !== 0
|
|
190
|
+
|| result.timedOut
|
|
191
|
+
|| result.cancelled
|
|
192
|
+
|| result.spawnError !== undefined) {
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (result === undefined) {
|
|
197
|
+
throw new Error("ADB input text produced no command");
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
startLogcat(options) {
|
|
202
|
+
return this.runner.start({
|
|
203
|
+
executable: "adb",
|
|
204
|
+
args: [
|
|
205
|
+
...deviceArgs(options.deviceSerial),
|
|
206
|
+
"logcat",
|
|
207
|
+
"-v",
|
|
208
|
+
"threadtime"
|
|
209
|
+
],
|
|
210
|
+
...(options.signal === undefined ? {} : { signal: options.signal })
|
|
211
|
+
}, {
|
|
212
|
+
onStdoutLine: options.onStdoutLine,
|
|
213
|
+
...(options.onStderrLine === undefined
|
|
214
|
+
? {}
|
|
215
|
+
: { onStderrLine: options.onStderrLine })
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AndroidCliPort, CaptureScreenOptions, DeviceCommandOptions, Point } from "../../ports/android-cli.js";
|
|
2
|
+
import type { CommandResult, ProcessRunner } from "../../ports/process-runner.js";
|
|
3
|
+
import { parseLayout, parseLayoutDiff } from "./layout-parser.js";
|
|
4
|
+
export declare class AndroidCliAdapter implements AndroidCliPort {
|
|
5
|
+
private readonly runner;
|
|
6
|
+
constructor(runner: ProcessRunner);
|
|
7
|
+
layout(options: DeviceCommandOptions): Promise<ReturnType<typeof parseLayout>>;
|
|
8
|
+
layoutDiff(options: DeviceCommandOptions): Promise<ReturnType<typeof parseLayoutDiff>>;
|
|
9
|
+
captureScreen(options: CaptureScreenOptions): Promise<CommandResult>;
|
|
10
|
+
resolveScreen(screenshotPath: string, label: string, signal?: AbortSignal): Promise<Point>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { parseLayout, parseLayoutDiff } from "./layout-parser.js";
|
|
2
|
+
function commandSpec(args, signal, timeoutMs) {
|
|
3
|
+
return {
|
|
4
|
+
executable: "android",
|
|
5
|
+
args,
|
|
6
|
+
...(signal === undefined ? {} : { signal }),
|
|
7
|
+
...(timeoutMs === undefined ? {} : { timeoutMs })
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function assertSuccess(result, operation) {
|
|
11
|
+
if (result.exitCode !== 0) {
|
|
12
|
+
throw new Error(`Android CLI ${operation} failed: ${result.stderr.trim()}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class AndroidCliAdapter {
|
|
16
|
+
runner;
|
|
17
|
+
constructor(runner) {
|
|
18
|
+
this.runner = runner;
|
|
19
|
+
}
|
|
20
|
+
async layout(options) {
|
|
21
|
+
const result = await this.runner.run(commandSpec([
|
|
22
|
+
"layout",
|
|
23
|
+
`--device=${options.deviceSerial}`
|
|
24
|
+
], options.signal, options.timeoutMs));
|
|
25
|
+
assertSuccess(result, "layout");
|
|
26
|
+
return parseLayout(result.stdout);
|
|
27
|
+
}
|
|
28
|
+
async layoutDiff(options) {
|
|
29
|
+
const result = await this.runner.run(commandSpec([
|
|
30
|
+
"layout",
|
|
31
|
+
"--diff",
|
|
32
|
+
`--device=${options.deviceSerial}`
|
|
33
|
+
], options.signal, options.timeoutMs));
|
|
34
|
+
assertSuccess(result, "layout --diff");
|
|
35
|
+
return parseLayoutDiff(result.stdout);
|
|
36
|
+
}
|
|
37
|
+
captureScreen(options) {
|
|
38
|
+
return this.runner.run(commandSpec([
|
|
39
|
+
"screen",
|
|
40
|
+
"capture",
|
|
41
|
+
`--output=${options.outputPath}`,
|
|
42
|
+
...(options.annotate === true ? ["--annotate"] : []),
|
|
43
|
+
`--device=${options.deviceSerial}`
|
|
44
|
+
], options.signal, options.timeoutMs));
|
|
45
|
+
}
|
|
46
|
+
async resolveScreen(screenshotPath, label, signal) {
|
|
47
|
+
const result = await this.runner.run(commandSpec([
|
|
48
|
+
"screen",
|
|
49
|
+
"resolve",
|
|
50
|
+
`--screenshot=${screenshotPath}`,
|
|
51
|
+
`--string=${label}`
|
|
52
|
+
], signal));
|
|
53
|
+
assertSuccess(result, "screen resolve");
|
|
54
|
+
const coordinates = result.stdout.match(/-?\d+/g)?.map(Number);
|
|
55
|
+
if (coordinates?.length !== 2) {
|
|
56
|
+
throw new Error("Android CLI screen resolve returned invalid coordinates");
|
|
57
|
+
}
|
|
58
|
+
const [x, y] = coordinates;
|
|
59
|
+
if (x === undefined || y === undefined || x < 0 || y < 0) {
|
|
60
|
+
throw new Error("Android CLI screen resolve returned invalid coordinates");
|
|
61
|
+
}
|
|
62
|
+
return { x, y };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { BoundsSchema, LayoutPointSchema } from "../../domain/layout.js";
|
|
2
|
+
function parseJson(stdout, label) {
|
|
3
|
+
try {
|
|
4
|
+
return JSON.parse(stdout);
|
|
5
|
+
}
|
|
6
|
+
catch (error) {
|
|
7
|
+
throw new Error(`Invalid Android ${label} JSON`, { cause: error });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function asRecord(value) {
|
|
11
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
12
|
+
throw new Error("Invalid Android Layout element");
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function optionalString(record, ...keys) {
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
const value = record[key];
|
|
19
|
+
if (typeof value === "string") {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
function optionalBoolean(record, key) {
|
|
26
|
+
const value = record[key];
|
|
27
|
+
if (typeof value === "boolean") {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
if (value === "true") {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (value === "false") {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function parseBounds(value) {
|
|
39
|
+
if (value === undefined) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === "string") {
|
|
43
|
+
const match = /^\[(\d+),(\d+)]\[(\d+),(\d+)]$/.exec(value);
|
|
44
|
+
if (match === null) {
|
|
45
|
+
throw new Error("Invalid Android Layout bounds");
|
|
46
|
+
}
|
|
47
|
+
return BoundsSchema.parse({
|
|
48
|
+
left: Number(match[1]),
|
|
49
|
+
top: Number(match[2]),
|
|
50
|
+
right: Number(match[3]),
|
|
51
|
+
bottom: Number(match[4])
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return BoundsSchema.parse(value);
|
|
55
|
+
}
|
|
56
|
+
function parseCenter(value) {
|
|
57
|
+
if (value === undefined) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (typeof value === "string") {
|
|
61
|
+
const match = /^[[(]\s*(\d+)\s*,\s*(\d+)\s*[)\]]/.exec(value);
|
|
62
|
+
if (match === null) {
|
|
63
|
+
throw new Error("Invalid Android Layout center");
|
|
64
|
+
}
|
|
65
|
+
return LayoutPointSchema.parse({
|
|
66
|
+
x: Number(match[1]),
|
|
67
|
+
y: Number(match[2])
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return LayoutPointSchema.parse(value);
|
|
71
|
+
}
|
|
72
|
+
function stringSet(record, key) {
|
|
73
|
+
const value = record[key];
|
|
74
|
+
if (value === undefined) {
|
|
75
|
+
return new Set();
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
78
|
+
throw new Error(`Invalid Android Layout ${key}`);
|
|
79
|
+
}
|
|
80
|
+
return new Set(value);
|
|
81
|
+
}
|
|
82
|
+
function elementId(record, path) {
|
|
83
|
+
const explicitId = record.id;
|
|
84
|
+
if (typeof explicitId === "string" || typeof explicitId === "number") {
|
|
85
|
+
return String(explicitId);
|
|
86
|
+
}
|
|
87
|
+
const serializerKey = record.key;
|
|
88
|
+
return typeof serializerKey === "string" || typeof serializerKey === "number"
|
|
89
|
+
? `${String(serializerKey)}:${path}`
|
|
90
|
+
: path;
|
|
91
|
+
}
|
|
92
|
+
function parseElement(value, path) {
|
|
93
|
+
const record = asRecord(value);
|
|
94
|
+
if (optionalBoolean(record, "off-screen") === true) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const childrenValue = record.children ?? [];
|
|
98
|
+
if (!Array.isArray(childrenValue)) {
|
|
99
|
+
throw new Error("Invalid Android Layout children");
|
|
100
|
+
}
|
|
101
|
+
const resourceId = optionalString(record, "resourceId", "resource-id");
|
|
102
|
+
const text = optionalString(record, "text");
|
|
103
|
+
const contentDescription = optionalString(record, "contentDescription", "content-desc");
|
|
104
|
+
const interactions = stringSet(record, "interactions");
|
|
105
|
+
const clickable = optionalBoolean(record, "clickable")
|
|
106
|
+
?? (interactions.has("clickable") ? true : undefined);
|
|
107
|
+
const longClickable = optionalBoolean(record, "longClickable")
|
|
108
|
+
?? optionalBoolean(record, "long-clickable")
|
|
109
|
+
?? (interactions.has("long-clickable") ? true : undefined);
|
|
110
|
+
const scrollable = optionalBoolean(record, "scrollable")
|
|
111
|
+
?? (interactions.has("scrollable") ? true : undefined);
|
|
112
|
+
const focusable = interactions.has("focusable") ? true : undefined;
|
|
113
|
+
const state = stringSet(record, "state");
|
|
114
|
+
const focused = state.has("focused") ? true : undefined;
|
|
115
|
+
const bounds = parseBounds(record.bounds);
|
|
116
|
+
const parsedCenter = parseCenter(record.center);
|
|
117
|
+
const center = parsedCenter ?? (bounds === undefined
|
|
118
|
+
? undefined
|
|
119
|
+
: {
|
|
120
|
+
x: Math.round((bounds.left + bounds.right) / 2),
|
|
121
|
+
y: Math.round((bounds.top + bounds.bottom) / 2)
|
|
122
|
+
});
|
|
123
|
+
if (center === undefined) {
|
|
124
|
+
throw new Error("Android Layout element has no visible center or bounds");
|
|
125
|
+
}
|
|
126
|
+
const children = childrenValue.flatMap((child, index) => {
|
|
127
|
+
const parsed = parseElement(child, `${path}/${String(index)}`);
|
|
128
|
+
return parsed === undefined ? [] : [parsed];
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
id: elementId(record, path),
|
|
132
|
+
...(resourceId === undefined ? {} : { resourceId }),
|
|
133
|
+
...(text === undefined ? {} : { text }),
|
|
134
|
+
...(contentDescription === undefined ? {} : { contentDescription }),
|
|
135
|
+
...(clickable === undefined ? {} : { clickable }),
|
|
136
|
+
...(longClickable === undefined ? {} : { longClickable }),
|
|
137
|
+
...(scrollable === undefined ? {} : { scrollable }),
|
|
138
|
+
...(focusable === undefined ? {} : { focusable }),
|
|
139
|
+
...(focused === undefined ? {} : { focused }),
|
|
140
|
+
enabled: optionalBoolean(record, "enabled") ?? true,
|
|
141
|
+
center,
|
|
142
|
+
...(bounds === undefined ? {} : { bounds }),
|
|
143
|
+
children
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function parseLayout(stdout) {
|
|
147
|
+
const parsed = parseJson(stdout, "Layout");
|
|
148
|
+
try {
|
|
149
|
+
if (Array.isArray(parsed)) {
|
|
150
|
+
return parsed.flatMap((element, index) => {
|
|
151
|
+
const normalized = parseElement(element, String(index));
|
|
152
|
+
return normalized === undefined ? [] : [normalized];
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const normalized = parseElement(parsed, "0");
|
|
156
|
+
return normalized === undefined ? [] : [normalized];
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw new Error("Invalid Android Layout structure", { cause: error });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export function parseLayoutDiff(stdout) {
|
|
163
|
+
const parsed = parseJson(stdout, "Layout Diff");
|
|
164
|
+
if (Array.isArray(parsed)) {
|
|
165
|
+
return parsed;
|
|
166
|
+
}
|
|
167
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
168
|
+
const record = parsed;
|
|
169
|
+
const added = Array.isArray(record.added) ? record.added : [];
|
|
170
|
+
const modified = Array.isArray(record.modified) ? record.modified : [];
|
|
171
|
+
return [...added, ...modified];
|
|
172
|
+
}
|
|
173
|
+
throw new Error("Invalid Android Layout Diff structure");
|
|
174
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
export class SystemClock {
|
|
4
|
+
now = () => performance.now();
|
|
5
|
+
sleep = (durationMs, signal) => {
|
|
6
|
+
if (signal?.aborted === true) {
|
|
7
|
+
return Promise.reject(signal.reason instanceof Error
|
|
8
|
+
? signal.reason
|
|
9
|
+
: new Error("Operation aborted"));
|
|
10
|
+
}
|
|
11
|
+
return delay(durationMs, undefined, { signal });
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { access, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
function assertRunDirectoryName(name) {
|
|
4
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
5
|
+
throw new Error("Invalid artifact run directory name");
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function safePath(root, relativePath) {
|
|
9
|
+
const candidate = resolve(root, relativePath);
|
|
10
|
+
const fromRoot = relative(root, candidate);
|
|
11
|
+
if (fromRoot.length === 0
|
|
12
|
+
|| fromRoot.startsWith("..")
|
|
13
|
+
|| isAbsolute(fromRoot)) {
|
|
14
|
+
throw new Error(`Invalid artifact path: ${relativePath}`);
|
|
15
|
+
}
|
|
16
|
+
return candidate;
|
|
17
|
+
}
|
|
18
|
+
async function exists(path) {
|
|
19
|
+
try {
|
|
20
|
+
await access(path);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
class FileSystemArtifactSession {
|
|
28
|
+
temporaryDirectory;
|
|
29
|
+
finalDirectory;
|
|
30
|
+
published = false;
|
|
31
|
+
discarded = false;
|
|
32
|
+
constructor(temporaryDirectory, finalDirectory) {
|
|
33
|
+
this.temporaryDirectory = temporaryDirectory;
|
|
34
|
+
this.finalDirectory = finalDirectory;
|
|
35
|
+
}
|
|
36
|
+
path = (relativePath) => (safePath(this.published ? this.finalDirectory : this.temporaryDirectory, relativePath));
|
|
37
|
+
writeText = async (relativePath, content) => {
|
|
38
|
+
this.assertWritable();
|
|
39
|
+
const outputPath = safePath(this.temporaryDirectory, relativePath);
|
|
40
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
41
|
+
await writeFile(outputPath, content, "utf8");
|
|
42
|
+
};
|
|
43
|
+
writeJson = (relativePath, value) => this.writeText(relativePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
44
|
+
publish = async () => {
|
|
45
|
+
this.assertWritable();
|
|
46
|
+
if (await exists(this.finalDirectory)) {
|
|
47
|
+
throw new Error(`Artifact run directory already exists: ${this.finalDirectory}`);
|
|
48
|
+
}
|
|
49
|
+
await rename(this.temporaryDirectory, this.finalDirectory);
|
|
50
|
+
this.published = true;
|
|
51
|
+
return this.finalDirectory;
|
|
52
|
+
};
|
|
53
|
+
discard = async () => {
|
|
54
|
+
if (!this.published && !this.discarded) {
|
|
55
|
+
await rm(this.temporaryDirectory, { recursive: true, force: true });
|
|
56
|
+
this.discarded = true;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
assertWritable() {
|
|
60
|
+
if (this.published || this.discarded) {
|
|
61
|
+
throw new Error("Artifact session is no longer writable");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export class FileSystemArtifactStore {
|
|
66
|
+
begin = async (baseDirectory, runDirectoryName) => {
|
|
67
|
+
assertRunDirectoryName(runDirectoryName);
|
|
68
|
+
await mkdir(baseDirectory, { recursive: true });
|
|
69
|
+
const temporaryDirectory = await mkdtemp(join(baseDirectory, `.${runDirectoryName}.tmp-`));
|
|
70
|
+
await mkdir(join(temporaryDirectory, "steps"), { recursive: true });
|
|
71
|
+
return new FileSystemArtifactSession(temporaryDirectory, join(baseDirectory, runDirectoryName));
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type GenerationMeta } from "../../domain/generation.js";
|
|
2
|
+
import type { ProjectBoundGenerationMetaWriterPort } from "../../ports/generation-meta-writer.js";
|
|
3
|
+
import type { ProjectBoundPath } from "../../ports/project-bound-file.js";
|
|
4
|
+
import { type ProjectBoundFileHooks } from "./project-bound-file.js";
|
|
5
|
+
export interface FileSystemGenerationMetaWriterOptions {
|
|
6
|
+
beforeBoundInstall?: ProjectBoundFileHooks["beforeInstall"];
|
|
7
|
+
}
|
|
8
|
+
export declare class FileSystemGenerationMetaWriter implements ProjectBoundGenerationMetaWriterPort {
|
|
9
|
+
private readonly options;
|
|
10
|
+
constructor(options?: FileSystemGenerationMetaWriterOptions);
|
|
11
|
+
write(outputPath: string, input: GenerationMeta): Promise<void>;
|
|
12
|
+
writeProjectBound(input: ProjectBoundPath & {
|
|
13
|
+
meta: GenerationMeta;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
readProjectBound(input: ProjectBoundPath): Promise<Buffer>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { GenerationMetaSchema } from "../../domain/generation.js";
|
|
5
|
+
import { readProjectBoundFile, writeProjectBoundText } from "./project-bound-file.js";
|
|
6
|
+
export class FileSystemGenerationMetaWriter {
|
|
7
|
+
options;
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
}
|
|
11
|
+
async write(outputPath, input) {
|
|
12
|
+
const meta = GenerationMetaSchema.parse(input);
|
|
13
|
+
const directory = dirname(outputPath);
|
|
14
|
+
const temporaryPath = join(directory, `.${basename(outputPath)}.${randomUUID()}.tmp`);
|
|
15
|
+
await mkdir(directory, { recursive: true });
|
|
16
|
+
try {
|
|
17
|
+
await writeFile(temporaryPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
18
|
+
await rename(temporaryPath, outputPath);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
await rm(temporaryPath, { force: true });
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async writeProjectBound(input) {
|
|
26
|
+
const meta = GenerationMetaSchema.parse(input.meta);
|
|
27
|
+
await writeProjectBoundText(input, `${JSON.stringify(meta, null, 2)}\n`, this.options.beforeBoundInstall === undefined
|
|
28
|
+
? {}
|
|
29
|
+
: { beforeInstall: this.options.beforeBoundInstall });
|
|
30
|
+
}
|
|
31
|
+
async readProjectBound(input) {
|
|
32
|
+
return readProjectBoundFile(input);
|
|
33
|
+
}
|
|
34
|
+
}
|