taphound 0.2.0-dev.11 → 0.2.0-dev.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 (58) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/assets/skills/taphound-case-suite/SKILL.md +8 -6
  4. package/assets/skills/taphound-case-suite/scripts/ledger.mjs +84 -19
  5. package/assets/skills/taphound-journey-brief-author/SKILL.md +8 -3
  6. package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.md +3 -1
  7. package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.zh-CN.md +1 -1
  8. package/assets/skills/taphound-journey-generator/SKILL.md +21 -5
  9. package/assets/skills/taphound-journey-generator/prompts/consume-journey-brief.md +3 -1
  10. package/assets/skills/taphound-journey-generator/prompts/generate-step.md +9 -0
  11. package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +80 -10
  12. package/assets/skills/taphound-verify-change/references/preserve.md +15 -7
  13. package/assets/skills/taphound-verify-change/scripts/ui-refactor.mjs +1 -1
  14. package/dist/adapters/appium/appium-ui-snapshot-provider.d.ts +8 -0
  15. package/dist/adapters/appium/appium-ui-snapshot-provider.js +123 -54
  16. package/dist/adapters/filesystem/diagnostics-journal.d.ts +17 -0
  17. package/dist/adapters/filesystem/diagnostics-journal.js +130 -0
  18. package/dist/application/diagnostics/diagnostics-exporter.d.ts +29 -0
  19. package/dist/application/diagnostics/diagnostics-exporter.js +283 -0
  20. package/dist/application/diagnostics/ui-capture-telemetry.d.ts +16 -0
  21. package/dist/application/diagnostics/ui-capture-telemetry.js +81 -0
  22. package/dist/application/generation/proposed-step-validator.js +5 -1
  23. package/dist/application/journey/journey-check-service.d.ts +7 -1
  24. package/dist/application/journey/journey-check-service.js +16 -1
  25. package/dist/application/report/report-writer.d.ts +5 -0
  26. package/dist/application/report/report-writer.js +20 -0
  27. package/dist/application/runtime/verify-runtime.js +32 -2
  28. package/dist/application/ui/observed-ui-snapshot-provider.d.ts +15 -0
  29. package/dist/application/ui/observed-ui-snapshot-provider.js +66 -0
  30. package/dist/application/ui/ui-stability-probe.d.ts +6 -0
  31. package/dist/application/ui/ui-stability-probe.js +21 -5
  32. package/dist/application/wait/idle-waiter.js +29 -6
  33. package/dist/cli/commands/diagnose.d.ts +3 -0
  34. package/dist/cli/commands/diagnose.js +84 -0
  35. package/dist/cli/commands/generation/session-commands.js +4 -1
  36. package/dist/cli/commands/journey.js +3 -1
  37. package/dist/cli/commands/verify.js +55 -3
  38. package/dist/cli/dependencies.d.ts +11 -0
  39. package/dist/cli/dependencies.js +58 -5
  40. package/dist/cli/diagnostics-recorder.d.ts +31 -0
  41. package/dist/cli/diagnostics-recorder.js +82 -0
  42. package/dist/cli/main.d.ts +1 -1
  43. package/dist/cli/main.js +58 -1
  44. package/dist/cli/program.js +3 -1
  45. package/dist/domain/contract.d.ts +6 -6
  46. package/dist/domain/diagnostics.d.ts +1396 -0
  47. package/dist/domain/diagnostics.js +193 -0
  48. package/dist/domain/failure.js +1 -1
  49. package/dist/domain/report.d.ts +11 -3
  50. package/dist/domain/report.js +1 -0
  51. package/dist/domain/verify-receipt.d.ts +17 -0
  52. package/dist/domain/verify-receipt.js +17 -0
  53. package/dist/domain/workspace.d.ts +11 -0
  54. package/dist/domain/workspace.js +14 -0
  55. package/dist/ports/diagnostics.d.ts +15 -0
  56. package/dist/ports/diagnostics.js +1 -0
  57. package/package.json +4 -3
  58. package/scripts/feedback-pack.mjs +399 -0
@@ -14,6 +14,14 @@ function loopbackEndpoint(value) {
14
14
  }
15
15
  return endpoint;
16
16
  }
17
+ export class AppiumHttpError extends Error {
18
+ status;
19
+ constructor(status) {
20
+ super(`Appium HTTP ${String(status)}`);
21
+ this.status = status;
22
+ this.name = "AppiumHttpError";
23
+ }
24
+ }
17
25
  export class FetchAppiumHttpClient {
18
26
  endpoint;
19
27
  constructor(endpoint) {
@@ -36,7 +44,7 @@ export class FetchAppiumHttpClient {
36
44
  });
37
45
  const payload = await response.json();
38
46
  if (!response.ok) {
39
- throw new Error(`Appium HTTP ${String(response.status)}`);
47
+ throw new AppiumHttpError(response.status);
40
48
  }
41
49
  return { value: payload.value };
42
50
  }
@@ -47,18 +55,33 @@ function objectValue(value) {
47
55
  }
48
56
  return value;
49
57
  }
58
+ function errorText(error) {
59
+ return error instanceof Error ? error.message : String(error);
60
+ }
61
+ /**
62
+ * A page source request that timed out or hit a session Appium no longer
63
+ * knows (HTTP 404) points at a degraded UiAutomator2 session, not at the app.
64
+ */
65
+ function degradedSession(error) {
66
+ return (error instanceof DOMException && error.name === "TimeoutError")
67
+ || (error instanceof AppiumHttpError && error.status === 404);
68
+ }
50
69
  class AppiumUiSnapshotProvider {
51
70
  http;
52
71
  sessionId;
72
+ createSession;
53
73
  environment;
54
74
  descriptor;
75
+ onSessionRecovery;
55
76
  closePromise;
56
77
  lastSourceSignature;
57
- constructor(http, sessionId, environment, descriptor) {
78
+ constructor(http, sessionId, createSession, environment, descriptor, onSessionRecovery) {
58
79
  this.http = http;
59
80
  this.sessionId = sessionId;
81
+ this.createSession = createSession;
60
82
  this.environment = environment;
61
83
  this.descriptor = descriptor;
84
+ this.onSessionRecovery = onSessionRecovery;
62
85
  }
63
86
  reset() {
64
87
  this.lastSourceSignature = undefined;
@@ -90,15 +113,24 @@ class AppiumUiSnapshotProvider {
90
113
  const startedAt = performance.now();
91
114
  let source;
92
115
  try {
93
- source = (await this.http.request({
94
- method: "GET",
95
- path: `/session/${this.sessionId}/source`,
96
- timeoutMs: options.timeoutMs,
97
- ...(options.signal === undefined ? {} : { signal: options.signal })
98
- })).value;
116
+ source = await this.pageSource(options);
99
117
  }
100
118
  catch (error) {
101
- throw new UiSnapshotError("UI_SNAPSHOT_FAILED", this.descriptor.id, `Appium page source capture failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error, terminal: true });
119
+ if (options.signal?.aborted === true || !degradedSession(error)) {
120
+ throw this.captureFailure(errorText(error), error);
121
+ }
122
+ // Capture is read-only, so one retry on a fresh session cannot change
123
+ // what Replay observes; it only stops a degraded session from turning
124
+ // into a false verification failure.
125
+ try {
126
+ await this.recreateSession(options.signal);
127
+ source = await this.pageSource(options);
128
+ this.onSessionRecovery?.(true);
129
+ }
130
+ catch (retryError) {
131
+ this.onSessionRecovery?.(false);
132
+ throw this.captureFailure(`${errorText(error)}; retry on a recreated session failed: ${errorText(retryError)}`, retryError);
133
+ }
102
134
  }
103
135
  if (typeof source !== "string") {
104
136
  throw new UiSnapshotError("UI_SNAPSHOT_INVALID", this.descriptor.id, "Appium returned a non-string page source");
@@ -121,6 +153,34 @@ class AppiumUiSnapshotProvider {
121
153
  timing: {}
122
154
  });
123
155
  }
156
+ async pageSource(options) {
157
+ return (await this.http.request({
158
+ method: "GET",
159
+ path: `/session/${this.sessionId}/source`,
160
+ timeoutMs: options.timeoutMs,
161
+ ...(options.signal === undefined ? {} : { signal: options.signal })
162
+ })).value;
163
+ }
164
+ captureFailure(detail, cause) {
165
+ return new UiSnapshotError("UI_SNAPSHOT_FAILED", this.descriptor.id, `Appium page source capture failed: ${detail}`, { cause, terminal: true });
166
+ }
167
+ async recreateSession(signal) {
168
+ await this.http.request({
169
+ method: "DELETE",
170
+ path: `/session/${this.sessionId}`,
171
+ timeoutMs: 2000
172
+ }).catch(() => undefined);
173
+ const sessionId = await this.createSession(signal);
174
+ if (this.closePromise !== undefined) {
175
+ await this.http.request({
176
+ method: "DELETE",
177
+ path: `/session/${sessionId}`,
178
+ timeoutMs: 5000
179
+ }).catch(() => undefined);
180
+ throw new Error("Appium UI snapshot provider is closed");
181
+ }
182
+ this.sessionId = sessionId;
183
+ }
124
184
  close() {
125
185
  this.closePromise ??= this.http.request({
126
186
  method: "DELETE",
@@ -135,6 +195,7 @@ export class AppiumUiSnapshotProviderFactory {
135
195
  endpoint;
136
196
  http;
137
197
  settings;
198
+ onSessionRecovery;
138
199
  constructor(runner, http, options = {}) {
139
200
  this.runner = runner;
140
201
  this.endpoint = loopbackEndpoint(options.endpoint ?? "http://127.0.0.1:4723/");
@@ -142,6 +203,7 @@ export class AppiumUiSnapshotProviderFactory {
142
203
  this.settings = {
143
204
  mapTestTagToResourceId: options.mapTestTagToResourceId ?? false
144
205
  };
206
+ this.onSessionRecovery = options.onSessionRecovery;
145
207
  }
146
208
  async probe(timeoutMs = 2000) {
147
209
  try {
@@ -158,7 +220,7 @@ export class AppiumUiSnapshotProviderFactory {
158
220
  }
159
221
  async open(options) {
160
222
  const environment = await readDeviceUiEnvironment(this.runner, "appium-uiautomator2", options);
161
- let provisionalSessionId;
223
+ let provider;
162
224
  try {
163
225
  const status = objectValue((await this.http.request({
164
226
  method: "GET",
@@ -170,41 +232,8 @@ export class AppiumUiSnapshotProviderFactory {
170
232
  const engineVersion = typeof build.version === "string"
171
233
  ? build.version
172
234
  : "unknown";
173
- const created = objectValue((await this.http.request({
174
- method: "POST",
175
- path: "/session",
176
- timeoutMs: options.timeoutMs,
177
- body: {
178
- capabilities: {
179
- alwaysMatch: {
180
- platformName: "Android",
181
- "appium:automationName": "UiAutomator2",
182
- "appium:udid": options.deviceSerial,
183
- "appium:noReset": true,
184
- "appium:autoLaunch": false,
185
- "appium:autoGrantPermissions": false,
186
- "appium:fullReset": false,
187
- "appium:shouldTerminateApp": false
188
- },
189
- firstMatch: [{}]
190
- }
191
- },
192
- ...(options.signal === undefined ? {} : { signal: options.signal })
193
- })).value);
194
- const sessionId = typeof created.sessionId === "string"
195
- ? created.sessionId
196
- : undefined;
197
- if (sessionId === undefined) {
198
- throw new Error("Appium did not return a session id");
199
- }
200
- provisionalSessionId = sessionId;
201
- await this.http.request({
202
- method: "POST",
203
- path: `/session/${sessionId}/appium/settings`,
204
- timeoutMs: options.timeoutMs,
205
- body: { settings: this.settings },
206
- ...(options.signal === undefined ? {} : { signal: options.signal })
207
- });
235
+ const createSession = (signal) => this.createSession(options.deviceSerial, options.timeoutMs, signal);
236
+ const sessionId = await createSession(options.signal);
208
237
  const descriptor = {
209
238
  id: "appium-uiautomator2",
210
239
  adapterVersion: "appium-uiautomator2-v1",
@@ -215,26 +244,66 @@ export class AppiumUiSnapshotProviderFactory {
215
244
  capabilitiesVersion: 1
216
245
  })).digest("hex")
217
246
  };
218
- const provider = new AppiumUiSnapshotProvider(this.http, sessionId, environment, descriptor);
247
+ provider = new AppiumUiSnapshotProvider(this.http, sessionId, createSession, environment, descriptor, this.onSessionRecovery);
219
248
  await provider.capture({
220
249
  reason: "evidence",
221
250
  timeoutMs: options.timeoutMs,
222
251
  ...(options.signal === undefined ? {} : { signal: options.signal })
223
252
  });
224
- provisionalSessionId = undefined;
225
253
  return provider;
226
254
  }
227
255
  catch (error) {
228
- if (provisionalSessionId !== undefined) {
229
- await this.http.request({
230
- method: "DELETE",
231
- path: `/session/${provisionalSessionId}`,
232
- timeoutMs: 5000
233
- }).catch(() => undefined);
234
- }
256
+ await provider?.close().catch(() => undefined);
235
257
  if (error instanceof UiSnapshotError)
236
258
  throw error;
237
259
  throw new UiSnapshotError("UI_BACKEND_UNAVAILABLE", "appium-uiautomator2", "Appium UiAutomator2 session could not be opened", { cause: error });
238
260
  }
239
261
  }
262
+ async createSession(deviceSerial, timeoutMs, signal) {
263
+ const created = objectValue((await this.http.request({
264
+ method: "POST",
265
+ path: "/session",
266
+ timeoutMs,
267
+ body: {
268
+ capabilities: {
269
+ alwaysMatch: {
270
+ platformName: "Android",
271
+ "appium:automationName": "UiAutomator2",
272
+ "appium:udid": deviceSerial,
273
+ "appium:noReset": true,
274
+ "appium:autoLaunch": false,
275
+ "appium:autoGrantPermissions": false,
276
+ "appium:fullReset": false,
277
+ "appium:shouldTerminateApp": false
278
+ },
279
+ firstMatch: [{}]
280
+ }
281
+ },
282
+ ...(signal === undefined ? {} : { signal })
283
+ })).value);
284
+ const sessionId = typeof created.sessionId === "string"
285
+ ? created.sessionId
286
+ : undefined;
287
+ if (sessionId === undefined) {
288
+ throw new Error("Appium did not return a session id");
289
+ }
290
+ try {
291
+ await this.http.request({
292
+ method: "POST",
293
+ path: `/session/${sessionId}/appium/settings`,
294
+ timeoutMs,
295
+ body: { settings: this.settings },
296
+ ...(signal === undefined ? {} : { signal })
297
+ });
298
+ }
299
+ catch (error) {
300
+ await this.http.request({
301
+ method: "DELETE",
302
+ path: `/session/${sessionId}`,
303
+ timeoutMs: 5000
304
+ }).catch(() => undefined);
305
+ throw error;
306
+ }
307
+ return sessionId;
308
+ }
240
309
  }
@@ -0,0 +1,17 @@
1
+ import { type CommandEvent } from "../../domain/diagnostics.js";
2
+ import type { DiagnosticsJournal } from "../../ports/diagnostics.js";
3
+ /**
4
+ * Appends one JSON line per invocation under `.taphound/build/log/`. It only
5
+ * writes where a command already initialized the ignored build layout (the
6
+ * build directory and `.taphound/.gitignore`), so read-only commands never
7
+ * create project files, and keeps the current file under `maxBytes` with one
8
+ * rotated predecessor.
9
+ */
10
+ export declare class FileSystemDiagnosticsJournal implements DiagnosticsJournal {
11
+ private readonly maxBytes;
12
+ constructor(maxBytes?: number);
13
+ append(projectRoot: string, event: CommandEvent): Promise<void>;
14
+ readLines(projectRoot: string): Promise<readonly string[]>;
15
+ salt(projectRoot: string): Promise<Buffer>;
16
+ private ensureLogDirectory;
17
+ }
@@ -0,0 +1,130 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readFile, rename } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { CommandEventSchema, DIAGNOSTICS_EVENTS_FILE, DIAGNOSTICS_EVENTS_ROTATED_FILE, DIAGNOSTICS_SALT_FILE } from "../../domain/diagnostics.js";
6
+ import { BUILD_DIR, BUILD_IGNORE_FILE, DIAGNOSTICS_LOG_DIR, TAPHOUND_DIR } from "../../domain/workspace.js";
7
+ import { ensureBuildLayout } from "./workspace-layout.js";
8
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
9
+ function isMissing(error) {
10
+ return error?.code === "ENOENT";
11
+ }
12
+ async function isDirectory(path) {
13
+ try {
14
+ const stats = await lstat(path);
15
+ return stats.isDirectory() && !stats.isSymbolicLink();
16
+ }
17
+ catch (error) {
18
+ if (isMissing(error))
19
+ return false;
20
+ throw error;
21
+ }
22
+ }
23
+ async function sizeOf(path) {
24
+ try {
25
+ return (await lstat(path)).size;
26
+ }
27
+ catch (error) {
28
+ if (isMissing(error))
29
+ return 0;
30
+ throw error;
31
+ }
32
+ }
33
+ async function readOptional(path) {
34
+ try {
35
+ return await readFile(path, "utf8");
36
+ }
37
+ catch (error) {
38
+ if (isMissing(error))
39
+ return "";
40
+ throw error;
41
+ }
42
+ }
43
+ async function isFile(path) {
44
+ try {
45
+ const stats = await lstat(path);
46
+ return stats.isFile();
47
+ }
48
+ catch (error) {
49
+ if (isMissing(error))
50
+ return false;
51
+ throw error;
52
+ }
53
+ }
54
+ /**
55
+ * Appends one JSON line per invocation under `.taphound/build/log/`. It only
56
+ * writes where a command already initialized the ignored build layout (the
57
+ * build directory and `.taphound/.gitignore`), so read-only commands never
58
+ * create project files, and keeps the current file under `maxBytes` with one
59
+ * rotated predecessor.
60
+ */
61
+ export class FileSystemDiagnosticsJournal {
62
+ maxBytes;
63
+ constructor(maxBytes = DEFAULT_MAX_BYTES) {
64
+ this.maxBytes = maxBytes;
65
+ }
66
+ async append(projectRoot, event) {
67
+ if (!await isDirectory(join(projectRoot, BUILD_DIR))
68
+ || !await isFile(join(projectRoot, BUILD_IGNORE_FILE))) {
69
+ return;
70
+ }
71
+ const line = `${JSON.stringify(CommandEventSchema.parse(event))}\n`;
72
+ const directory = await this.ensureLogDirectory(projectRoot);
73
+ const current = join(directory, DIAGNOSTICS_EVENTS_FILE);
74
+ if (await sizeOf(current) + Buffer.byteLength(line) > this.maxBytes) {
75
+ await rename(current, join(directory, DIAGNOSTICS_EVENTS_ROTATED_FILE))
76
+ .catch((error) => {
77
+ if (!isMissing(error))
78
+ throw error;
79
+ });
80
+ }
81
+ const handle = await open(current, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
82
+ try {
83
+ await handle.writeFile(line, "utf8");
84
+ }
85
+ finally {
86
+ await handle.close();
87
+ }
88
+ }
89
+ async readLines(projectRoot) {
90
+ const directory = join(projectRoot, DIAGNOSTICS_LOG_DIR);
91
+ const text = await readOptional(join(directory, DIAGNOSTICS_EVENTS_ROTATED_FILE))
92
+ + await readOptional(join(directory, DIAGNOSTICS_EVENTS_FILE));
93
+ return text.split("\n").filter((line) => line.trim().length > 0);
94
+ }
95
+ async salt(projectRoot) {
96
+ if (!await isDirectory(join(projectRoot, TAPHOUND_DIR))) {
97
+ throw Object.assign(new Error(`Not a TapHound project (no ${TAPHOUND_DIR}/ directory): ${projectRoot}`), { code: "CONFIG_INVALID" });
98
+ }
99
+ await ensureBuildLayout(projectRoot);
100
+ const path = join(await this.ensureLogDirectory(projectRoot), DIAGNOSTICS_SALT_FILE);
101
+ try {
102
+ const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
103
+ const salt = randomBytes(32);
104
+ try {
105
+ await handle.writeFile(salt);
106
+ }
107
+ finally {
108
+ await handle.close();
109
+ }
110
+ return salt;
111
+ }
112
+ catch (error) {
113
+ if (error.code !== "EEXIST")
114
+ throw error;
115
+ }
116
+ const salt = await readFile(path);
117
+ if (salt.length < 16) {
118
+ throw new Error(`Diagnostics salt is unreadable: ${DIAGNOSTICS_LOG_DIR}/${DIAGNOSTICS_SALT_FILE}`);
119
+ }
120
+ return salt;
121
+ }
122
+ async ensureLogDirectory(projectRoot) {
123
+ const directory = join(projectRoot, DIAGNOSTICS_LOG_DIR);
124
+ await mkdir(directory, { recursive: true });
125
+ if (!await isDirectory(directory)) {
126
+ throw new Error(`${DIAGNOSTICS_LOG_DIR} is not a directory`);
127
+ }
128
+ return directory;
129
+ }
130
+ }
@@ -0,0 +1,29 @@
1
+ import { type DiagnosticsBundle } from "../../domain/diagnostics.js";
2
+ import type { DiagnosticsJournal } from "../../ports/diagnostics.js";
3
+ export interface DiagnosticsExporterDependencies {
4
+ journal: DiagnosticsJournal;
5
+ readJson: (path: string) => Promise<unknown>;
6
+ now: () => Date;
7
+ taphoundVersion: string;
8
+ host: DiagnosticsBundle["host"];
9
+ }
10
+ export interface DiagnosticsExportInput {
11
+ projectRoot: string;
12
+ /** Most recent journal events to include. */
13
+ eventLimit: number;
14
+ /** Most recent referenced runs to summarize. */
15
+ runLimit: number;
16
+ }
17
+ /**
18
+ * Builds a feedback bundle from the local journal and the reports it
19
+ * references. Only allowlisted structural facts survive: identifying strings
20
+ * become ordinal aliases or salted digests, and everything else is dropped.
21
+ * The result is validated against the strict bundle schema before return.
22
+ */
23
+ export declare class DiagnosticsExporter {
24
+ private readonly dependencies;
25
+ constructor(dependencies: DiagnosticsExporterDependencies);
26
+ export(input: DiagnosticsExportInput): Promise<DiagnosticsBundle>;
27
+ private readConfig;
28
+ private readReport;
29
+ }