taphound 0.2.0-dev.11 → 0.2.0-dev.12
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/assets/skills/taphound-journey-generator/SKILL.md +7 -0
- package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +18 -1
- package/assets/skills/taphound-verify-change/references/preserve.md +15 -7
- package/assets/skills/taphound-verify-change/scripts/ui-refactor.mjs +1 -1
- package/dist/adapters/appium/appium-ui-snapshot-provider.d.ts +8 -0
- package/dist/adapters/appium/appium-ui-snapshot-provider.js +123 -54
- package/dist/adapters/filesystem/diagnostics-journal.d.ts +17 -0
- package/dist/adapters/filesystem/diagnostics-journal.js +130 -0
- package/dist/application/diagnostics/diagnostics-exporter.d.ts +29 -0
- package/dist/application/diagnostics/diagnostics-exporter.js +283 -0
- package/dist/application/diagnostics/ui-capture-telemetry.d.ts +16 -0
- package/dist/application/diagnostics/ui-capture-telemetry.js +81 -0
- package/dist/application/report/report-writer.d.ts +5 -0
- package/dist/application/report/report-writer.js +16 -0
- package/dist/application/runtime/verify-runtime.js +23 -1
- package/dist/application/ui/observed-ui-snapshot-provider.d.ts +15 -0
- package/dist/application/ui/observed-ui-snapshot-provider.js +66 -0
- package/dist/application/ui/ui-stability-probe.d.ts +6 -0
- package/dist/application/ui/ui-stability-probe.js +21 -5
- package/dist/application/wait/idle-waiter.js +19 -4
- package/dist/cli/commands/diagnose.d.ts +3 -0
- package/dist/cli/commands/diagnose.js +84 -0
- package/dist/cli/commands/verify.js +55 -3
- package/dist/cli/dependencies.d.ts +11 -0
- package/dist/cli/dependencies.js +58 -5
- package/dist/cli/diagnostics-recorder.d.ts +31 -0
- package/dist/cli/diagnostics-recorder.js +82 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +58 -1
- package/dist/cli/program.js +3 -1
- package/dist/domain/contract.d.ts +6 -6
- package/dist/domain/diagnostics.d.ts +1396 -0
- package/dist/domain/diagnostics.js +193 -0
- package/dist/domain/failure.js +1 -1
- package/dist/domain/report.d.ts +3 -3
- package/dist/domain/verify-receipt.d.ts +17 -0
- package/dist/domain/verify-receipt.js +17 -0
- package/dist/domain/workspace.d.ts +2 -0
- package/dist/domain/workspace.js +2 -0
- package/dist/ports/diagnostics.d.ts +15 -0
- package/dist/ports/diagnostics.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ taphound init --agent claude,codex,cursor,droid
|
|
|
90
90
|
- [Principles](https://github.com/caikaidev/TapHound/blob/main/docs/principles.md) · [Workflow Skills and development scenarios](https://github.com/caikaidev/TapHound/blob/main/docs/workflow-skills.md) · [Agent integration](https://github.com/caikaidev/TapHound/blob/main/docs/agent-integration.md) · [Journey Generator guide](https://github.com/caikaidev/TapHound/blob/main/docs/journey-generator-guide.md)
|
|
91
91
|
- [Acceptance Contracts](https://github.com/caikaidev/TapHound/blob/main/docs/contract-schema.md) · [Baselines & regression](https://github.com/caikaidev/TapHound/blob/main/docs/checkpoint-regression.md) · [Failure classification](https://github.com/caikaidev/TapHound/blob/main/docs/failure-classification.md)
|
|
92
92
|
- [Semantic Anchors](https://github.com/caikaidev/TapHound/blob/main/docs/semantic-anchor.md) · [Capability matrix](https://github.com/caikaidev/TapHound/blob/main/docs/capability-matrix.md) · [`observe`](https://github.com/caikaidev/TapHound/blob/main/docs/observe.md)
|
|
93
|
-
- [Runtime backends](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [Local development & testing](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [Releasing](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md)
|
|
93
|
+
- [Runtime backends](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [Local development & testing](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [Releasing](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md) · [Diagnostics and feedback](https://github.com/caikaidev/TapHound/blob/main/docs/diagnostics.md)
|
|
94
94
|
|
|
95
95
|
## Current Limitations
|
|
96
96
|
|
package/README.zh-CN.md
CHANGED
|
@@ -90,7 +90,7 @@ taphound init --agent claude,codex,cursor,droid
|
|
|
90
90
|
- [设计原则](https://github.com/caikaidev/TapHound/blob/main/docs/principles.md) · [Workflow Skill 与开发场景](https://github.com/caikaidev/TapHound/blob/main/docs/workflow-skills.md) · [Agent 集成](https://github.com/caikaidev/TapHound/blob/main/docs/agent-integration.md) · [Journey Generator 指南](https://github.com/caikaidev/TapHound/blob/main/docs/journey-generator-guide.md)
|
|
91
91
|
- [Acceptance Contract](https://github.com/caikaidev/TapHound/blob/main/docs/contract-schema.md) · [Baseline 与回归对比](https://github.com/caikaidev/TapHound/blob/main/docs/checkpoint-regression.md) · [失败分类](https://github.com/caikaidev/TapHound/blob/main/docs/failure-classification.md)
|
|
92
92
|
- [Semantic Anchor](https://github.com/caikaidev/TapHound/blob/main/docs/semantic-anchor.md) · [能力矩阵](https://github.com/caikaidev/TapHound/blob/main/docs/capability-matrix.md) · [`observe`](https://github.com/caikaidev/TapHound/blob/main/docs/observe.md)
|
|
93
|
-
- [运行时后端](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [本地开发与测试](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [发布](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md)
|
|
93
|
+
- [运行时后端](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [本地开发与测试](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [发布](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md) · [诊断与反馈](https://github.com/caikaidev/TapHound/blob/main/docs/diagnostics.md)
|
|
94
94
|
|
|
95
95
|
## 当前限制
|
|
96
96
|
|
|
@@ -488,6 +488,13 @@ the index with `taphound knowledge rehash --project <project> --json` and
|
|
|
488
488
|
session's selected module shards, stop and report a Context coverage gap.
|
|
489
489
|
Do not add modules after start because `contextSelection` is bound to the
|
|
490
490
|
authoritative session.
|
|
491
|
+
- When TapHound itself misbehaves (a crash, a result that contradicts the
|
|
492
|
+
device, or unexplained slowness), run
|
|
493
|
+
`taphound diagnose export --project <project>` and give the user the printed
|
|
494
|
+
bundle path to attach to their report. The bundle is redacted; do not add
|
|
495
|
+
paths, screenshots, or log excerpts to the report yourself.
|
|
496
|
+
- `UI_SNAPSHOT_FAILED` exits `3`: it is an environment failure, not
|
|
497
|
+
evidence about the app. Rerun before diagnosing the Journey.
|
|
491
498
|
- Repeated `UI_SNAPSHOT_FAILED` ("UIAutomator dump failed") on a slow or
|
|
492
499
|
busy device usually means the dump deadline is too tight, not that the
|
|
493
500
|
device is broken. Raise `ui.snapshotTimeoutMs` in `.taphound/config.json`
|
|
@@ -370,6 +370,14 @@ function validateEnvelope(envelope) {
|
|
|
370
370
|
|
|
371
371
|
// A bind source is one observe output, one step output, or a raw binding
|
|
372
372
|
// object. The binding fields are copied verbatim; nothing is invented.
|
|
373
|
+
const BIND_SOURCE_HINT = "bind --from expects the unmodified stdout of "
|
|
374
|
+
+ "`taphound generation observe --json` (status \"observed\" with "
|
|
375
|
+
+ "generationId, baseRevision, snapshotHash, snapshotRef) or of a succeeded "
|
|
376
|
+
+ "`taphound generation step --json` (status \"succeeded\" with nextBinding "
|
|
377
|
+
+ "and nextSnapshotRef); save it with `> file` instead of assembling a "
|
|
378
|
+
+ "subset. A bare binding {generationId, baseRevision, snapshotHash} is also "
|
|
379
|
+
+ "accepted";
|
|
380
|
+
|
|
373
381
|
function readBindingFromSource(source) {
|
|
374
382
|
if (!isPlainObject(source)) {
|
|
375
383
|
fail("ENVELOPE_INVALID", "bind source must be a JSON object");
|
|
@@ -460,7 +468,16 @@ async function bind(inputPath, fromPath, outPath) {
|
|
|
460
468
|
fail("ENVELOPE_INVALID", "envelope.version must be 1");
|
|
461
469
|
}
|
|
462
470
|
const source = await readJsonFile(fromPath, "bind source");
|
|
463
|
-
|
|
471
|
+
let bindingSource;
|
|
472
|
+
try {
|
|
473
|
+
bindingSource = readBindingFromSource(source);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (error?.code === "ENVELOPE_INVALID") {
|
|
476
|
+
fail("ENVELOPE_INVALID", `${error.message}. ${BIND_SOURCE_HINT}`);
|
|
477
|
+
}
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
const { binding, snapshotRef } = bindingSource;
|
|
464
481
|
const proposal = isPlainObject(envelope.proposal)
|
|
465
482
|
? { ...envelope.proposal, binding }
|
|
466
483
|
: undefined;
|
|
@@ -256,13 +256,16 @@ supported semantic element or structured event. Such a Case stays `PAUSED`,
|
|
|
256
256
|
never silently weaker.
|
|
257
257
|
|
|
258
258
|
The helper also requires a machine-generated process receipt because a report
|
|
259
|
-
file alone cannot establish an independent CLI invocation
|
|
259
|
+
file alone cannot establish an independent CLI invocation. `taphound verify
|
|
260
|
+
--journey` writes it as `receipt.json` beside the published `report.json` and
|
|
261
|
+
prints its path as `receiptPath` in the `--json` output:
|
|
260
262
|
|
|
261
263
|
```json
|
|
262
264
|
{
|
|
263
265
|
"version": 1,
|
|
264
266
|
"argv": [
|
|
265
267
|
"verify", "--project", "/absolute/project",
|
|
268
|
+
"--config", "/absolute/project/.taphound/config.json",
|
|
266
269
|
"--journey", "/absolute/project/.taphound/journeys/forward.json",
|
|
267
270
|
"--device", "emulator-5554", "--policy-from-meta", "--json"
|
|
268
271
|
],
|
|
@@ -273,9 +276,13 @@ file alone cannot establish an independent CLI invocation:
|
|
|
273
276
|
}
|
|
274
277
|
```
|
|
275
278
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
`argv` is the normalized invocation TapHound ran: absolute project, config,
|
|
280
|
+
and Journey paths plus the selected device serial. Pass the `receiptPath`
|
|
281
|
+
that `verify` printed; never write or edit a receipt by hand. A missing
|
|
282
|
+
`receiptPath` (the receipt could not be written, reported on stderr) means the
|
|
283
|
+
evidence is unavailable, so rerun `verify`. Pass absolute, symlink-free
|
|
284
|
+
`--project` and `--journey` paths so the recorded paths match the helper's
|
|
285
|
+
checks.
|
|
279
286
|
|
|
280
287
|
**A, historical worktree:**
|
|
281
288
|
|
|
@@ -288,7 +295,8 @@ exists.
|
|
|
288
295
|
--device <serial> --policy-from-meta --json
|
|
289
296
|
```
|
|
290
297
|
|
|
291
|
-
2.
|
|
298
|
+
2. Take `reportPath` and `receiptPath` from that `verify --json` output.
|
|
299
|
+
Prepare with a private input:
|
|
292
300
|
|
|
293
301
|
```json
|
|
294
302
|
{
|
|
@@ -306,7 +314,7 @@ exists.
|
|
|
306
314
|
"journeyPath": "/absolute/old-project/.taphound/journeys/forward.json",
|
|
307
315
|
"metaPath": "/absolute/old-project/.taphound/journeys/forward.meta.json",
|
|
308
316
|
"reportPath": "/absolute/old-project/.taphound/build/runs/<run>/report.json",
|
|
309
|
-
"receiptPath": "/absolute/old-project/.taphound/build/
|
|
317
|
+
"receiptPath": "/absolute/old-project/.taphound/build/runs/<run>/receipt.json"
|
|
310
318
|
}
|
|
311
319
|
}
|
|
312
320
|
```
|
|
@@ -332,7 +340,7 @@ exists.
|
|
|
332
340
|
goal/scenario against B's current Project Context and UI, but copy every
|
|
333
341
|
observable exactly into its deterministic expectations. Finalize it, then
|
|
334
342
|
launch a separate strict `verify` process on the installed refactored APK
|
|
335
|
-
and
|
|
343
|
+
and keep the `reportPath` and `receiptPath` it prints.
|
|
336
344
|
3. Compare only after that independent Replay:
|
|
337
345
|
|
|
338
346
|
```
|
|
@@ -449,7 +449,7 @@ async function compare(handoff, projectPath, journeyPath, reportPath, receiptPat
|
|
|
449
449
|
await checkReceipt(receiptFile, project, journeyFile, reportFile,
|
|
450
450
|
data.manifest.base.device, {
|
|
451
451
|
journey: journeyHash(journey), report: await fileHash(reportFile)
|
|
452
|
-
}, report.status === "failed" ?
|
|
452
|
+
}, report.status === "failed" ? 1 : 0);
|
|
453
453
|
const covered = checkReplay(project, data.manifest.base.device,
|
|
454
454
|
data.caseData, journey, meta, report, reportFile);
|
|
455
455
|
if (report.runId === data.manifest.base.runId) {
|
|
@@ -15,6 +15,12 @@ export interface AppiumHttpClient {
|
|
|
15
15
|
export interface AppiumProviderOptions {
|
|
16
16
|
endpoint?: string | undefined;
|
|
17
17
|
mapTestTagToResourceId?: boolean | undefined;
|
|
18
|
+
/** Called after each attempt to recreate a degraded session. */
|
|
19
|
+
onSessionRecovery?: ((succeeded: boolean) => void) | undefined;
|
|
20
|
+
}
|
|
21
|
+
export declare class AppiumHttpError extends Error {
|
|
22
|
+
readonly status: number;
|
|
23
|
+
constructor(status: number);
|
|
18
24
|
}
|
|
19
25
|
export declare class FetchAppiumHttpClient implements AppiumHttpClient {
|
|
20
26
|
private readonly endpoint;
|
|
@@ -28,7 +34,9 @@ export declare class AppiumUiSnapshotProviderFactory implements UiSnapshotProvid
|
|
|
28
34
|
private readonly endpoint;
|
|
29
35
|
private readonly http;
|
|
30
36
|
private readonly settings;
|
|
37
|
+
private readonly onSessionRecovery;
|
|
31
38
|
constructor(runner: ProcessRunner, http?: AppiumHttpClient, options?: AppiumProviderOptions);
|
|
32
39
|
probe(timeoutMs?: number): Promise<boolean>;
|
|
33
40
|
open(options: OpenUiSnapshotProviderOptions): Promise<UiSnapshotProvider>;
|
|
41
|
+
private createSession;
|
|
34
42
|
}
|
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|