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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/assets/skills/taphound-case-suite/SKILL.md +8 -6
- package/assets/skills/taphound-case-suite/scripts/ledger.mjs +84 -19
- package/assets/skills/taphound-journey-brief-author/SKILL.md +8 -3
- package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.md +3 -1
- package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.zh-CN.md +1 -1
- package/assets/skills/taphound-journey-generator/SKILL.md +21 -5
- package/assets/skills/taphound-journey-generator/prompts/consume-journey-brief.md +3 -1
- package/assets/skills/taphound-journey-generator/prompts/generate-step.md +9 -0
- package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +80 -10
- 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/generation/proposed-step-validator.js +5 -1
- package/dist/application/journey/journey-check-service.d.ts +7 -1
- package/dist/application/journey/journey-check-service.js +16 -1
- package/dist/application/report/report-writer.d.ts +5 -0
- package/dist/application/report/report-writer.js +20 -0
- package/dist/application/runtime/verify-runtime.js +32 -2
- 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 +29 -6
- package/dist/cli/commands/diagnose.d.ts +3 -0
- package/dist/cli/commands/diagnose.js +84 -0
- package/dist/cli/commands/generation/session-commands.js +4 -1
- package/dist/cli/commands/journey.js +3 -1
- 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 +11 -3
- package/dist/domain/report.js +1 -0
- package/dist/domain/verify-receipt.d.ts +17 -0
- package/dist/domain/verify-receipt.js +17 -0
- package/dist/domain/workspace.d.ts +11 -0
- package/dist/domain/workspace.js +14 -0
- package/dist/ports/diagnostics.d.ts +15 -0
- package/dist/ports/diagnostics.js +1 -0
- package/package.json +4 -3
- package/scripts/feedback-pack.mjs +399 -0
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A snapshot provider that samples stability itself (Appium) answers every
|
|
3
|
+
* structural sample, but frame-stat samples still go to the runtime probe:
|
|
4
|
+
* the provider only knows page sources, so it would answer a cheap
|
|
5
|
+
* `dumpsys gfxinfo` request with a full hierarchy capture.
|
|
6
|
+
*/
|
|
1
7
|
export function uiStabilityProbe(provider, fallback) {
|
|
2
8
|
const withCapability = provider;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
if (withCapability.supportsStability === false
|
|
10
|
+
|| typeof withCapability.sample !== "function"
|
|
11
|
+
|| typeof withCapability.reset !== "function") {
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
const structural = withCapability;
|
|
15
|
+
return {
|
|
16
|
+
reset: () => {
|
|
17
|
+
structural.reset();
|
|
18
|
+
fallback.reset();
|
|
19
|
+
},
|
|
20
|
+
sample: (options) => (options.stabilityBackend === "frameStats"
|
|
21
|
+
? fallback.sample(options)
|
|
22
|
+
: structural.sample(options))
|
|
23
|
+
};
|
|
8
24
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { resolveIdlePolicy } from "./idle-profiles.js";
|
|
2
2
|
const EARLY_BAIL_FRAME_CHANGES = 2;
|
|
3
3
|
const POST_FALLBACK_MIN_STABLE = 2;
|
|
4
|
+
/**
|
|
5
|
+
* Once frame stats showed no rendering for `stablePolls` polls, the
|
|
6
|
+
* structural phase only has to confirm the hierarchy did not change between
|
|
7
|
+
* two captures taken during this wait: one empty diff after at least two
|
|
8
|
+
* structural samples. Each capture costs a full hierarchy dump.
|
|
9
|
+
*/
|
|
10
|
+
const POST_FRAME_STABLE_CONFIRMATIONS = 1;
|
|
11
|
+
const POST_FRAME_STABLE_MIN_SAMPLES = 2;
|
|
4
12
|
function isEditableWidgetChange(change) {
|
|
5
13
|
if (typeof change !== "object" || change === null) {
|
|
6
14
|
return false;
|
|
@@ -92,6 +100,8 @@ export class IdleWaiter {
|
|
|
92
100
|
let lastLayout;
|
|
93
101
|
let backend;
|
|
94
102
|
let fallbackUsed = false;
|
|
103
|
+
let frameStable = false;
|
|
104
|
+
let structuralSamples = 0;
|
|
95
105
|
let frameActivityDetected = false;
|
|
96
106
|
let samplingDurationMs = 0;
|
|
97
107
|
let useStructuralBackend = strategy === "layoutDiff"
|
|
@@ -105,7 +115,8 @@ export class IdleWaiter {
|
|
|
105
115
|
durationMs: this.clock.now() - startedAt
|
|
106
116
|
};
|
|
107
117
|
}
|
|
108
|
-
const
|
|
118
|
+
const pollStartedAt = this.clock.now();
|
|
119
|
+
const elapsedBeforePoll = pollStartedAt - startedAt;
|
|
109
120
|
polls += 1;
|
|
110
121
|
let observation;
|
|
111
122
|
try {
|
|
@@ -159,6 +170,7 @@ export class IdleWaiter {
|
|
|
159
170
|
consecutiveEmpty = 0;
|
|
160
171
|
}
|
|
161
172
|
else if (useStructuralBackend) {
|
|
173
|
+
structuralSamples += 1;
|
|
162
174
|
let visibleChanges = meaningfulChanges(diff, config.ignoreCursorBlink === true);
|
|
163
175
|
if (config.ignoreLayoutDrift === true && visibleChanges.length > 0) {
|
|
164
176
|
const keys = visibleChanges.map(semanticKeys);
|
|
@@ -190,13 +202,17 @@ export class IdleWaiter {
|
|
|
190
202
|
}
|
|
191
203
|
const isPostFallback = fallbackUsed && strategy === "hybrid";
|
|
192
204
|
const requiredStableObservations = useStructuralBackend
|
|
193
|
-
? (
|
|
194
|
-
?
|
|
195
|
-
:
|
|
205
|
+
? (frameStable
|
|
206
|
+
? POST_FRAME_STABLE_CONFIRMATIONS
|
|
207
|
+
: isPostFallback
|
|
208
|
+
? Math.max(POST_FALLBACK_MIN_STABLE, config.stablePolls - 1)
|
|
209
|
+
: Math.max(2, config.stablePolls))
|
|
196
210
|
: config.stablePolls;
|
|
197
|
-
if (consecutiveEmpty >= requiredStableObservations
|
|
211
|
+
if (consecutiveEmpty >= requiredStableObservations
|
|
212
|
+
&& (!frameStable || structuralSamples >= POST_FRAME_STABLE_MIN_SAMPLES)) {
|
|
198
213
|
if (strategy === "hybrid" && !useStructuralBackend) {
|
|
199
214
|
useStructuralBackend = true;
|
|
215
|
+
frameStable = true;
|
|
200
216
|
consecutiveEmpty = 0;
|
|
201
217
|
}
|
|
202
218
|
else {
|
|
@@ -231,8 +247,15 @@ export class IdleWaiter {
|
|
|
231
247
|
...telemetry(strategy, backend, fallbackUsed, frameActivityDetected, samplingDurationMs)
|
|
232
248
|
};
|
|
233
249
|
}
|
|
250
|
+
// The interval runs from the start of one poll to the start of the
|
|
251
|
+
// next: a slow sample (a full hierarchy dump) eats into the wait
|
|
252
|
+
// instead of being added on top of it.
|
|
253
|
+
const sleepMs = Math.min(config.pollIntervalMs - (this.clock.now() - pollStartedAt), remainingMs);
|
|
254
|
+
if (sleepMs <= 0) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
234
257
|
try {
|
|
235
|
-
await this.clock.sleep(
|
|
258
|
+
await this.clock.sleep(sleepMs, signal);
|
|
236
259
|
}
|
|
237
260
|
catch (error) {
|
|
238
261
|
if (isAborted(signal)) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
3
|
+
import { exitCodeForFailure, failureCodeFromUnknown } from "../../domain/failure.js";
|
|
4
|
+
import { DIAGNOSTICS_EXPORT_DIR } from "../../domain/workspace.js";
|
|
5
|
+
import { errorMessage, failureOutput, writeJson, writeLine } from "../output.js";
|
|
6
|
+
const REVIEW_NOTE = "Review the file before sharing it. It holds command outcomes, "
|
|
7
|
+
+ "timings, and failure codes; project paths, package, Activity and Journey "
|
|
8
|
+
+ "names, locator values, device serials, screenshots, UI hierarchies, and "
|
|
9
|
+
+ "log text are left out or replaced by aliases.";
|
|
10
|
+
function positiveInteger(value) {
|
|
11
|
+
const parsed = Number(value);
|
|
12
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1000) {
|
|
13
|
+
throw new InvalidArgumentError("must be an integer from 1 to 1000");
|
|
14
|
+
}
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
function exportFileName(at) {
|
|
18
|
+
return `taphound-diagnostics-${at.toISOString().replaceAll(":", "-")}.json`;
|
|
19
|
+
}
|
|
20
|
+
export function createDiagnoseCommand(dependencies) {
|
|
21
|
+
return new Command("diagnose")
|
|
22
|
+
.description("Collect redacted diagnostics to attach to TapHound feedback")
|
|
23
|
+
.addCommand(new Command("export")
|
|
24
|
+
.description("Write a redacted diagnostics bundle from the local journal and recent reports")
|
|
25
|
+
.option("--project <path>", "Android project root", dependencies.cwd())
|
|
26
|
+
.option("--out <path>", `Bundle path (default ${DIAGNOSTICS_EXPORT_DIR}/<timestamp>.json)`)
|
|
27
|
+
.option("--events <count>", "Most recent journal events to include", positiveInteger, 50)
|
|
28
|
+
.option("--runs <count>", "Most recent referenced runs to summarize", positiveInteger, 10)
|
|
29
|
+
.option("--json", "Emit one machine-readable JSON value")
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
const json = options.json === true;
|
|
32
|
+
const diagnostics = dependencies.diagnosticsExport;
|
|
33
|
+
if (diagnostics === undefined) {
|
|
34
|
+
const output = failureOutput(2, "CONFIG_INVALID", "TapHound diagnostics export is not configured");
|
|
35
|
+
if (json) {
|
|
36
|
+
writeJson(dependencies.stdout, output);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
writeLine(dependencies.stderr, output.failure.message);
|
|
40
|
+
}
|
|
41
|
+
dependencies.setExitCode(2);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const projectRoot = resolve(dependencies.cwd(), options.project);
|
|
46
|
+
const bundle = await diagnostics.export({
|
|
47
|
+
projectRoot,
|
|
48
|
+
eventLimit: options.events,
|
|
49
|
+
runLimit: options.runs
|
|
50
|
+
});
|
|
51
|
+
const path = options.out === undefined
|
|
52
|
+
? resolve(projectRoot, DIAGNOSTICS_EXPORT_DIR, exportFileName(new Date(bundle.generatedAt)))
|
|
53
|
+
: resolve(dependencies.cwd(), options.out);
|
|
54
|
+
await diagnostics.write(path, `${JSON.stringify(bundle, null, 2)}\n`);
|
|
55
|
+
if (json) {
|
|
56
|
+
writeJson(dependencies.stdout, {
|
|
57
|
+
status: "exported",
|
|
58
|
+
exitCode: 0,
|
|
59
|
+
path,
|
|
60
|
+
events: bundle.journal.events.length,
|
|
61
|
+
runs: bundle.runs.length
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
writeLine(dependencies.stdout, `TapHound diagnostics: ${path}\n${String(bundle.journal.events.length)} event(s), `
|
|
66
|
+
+ `${String(bundle.runs.length)} run summary(ies)`);
|
|
67
|
+
}
|
|
68
|
+
writeLine(dependencies.stderr, REVIEW_NOTE);
|
|
69
|
+
dependencies.setExitCode(0);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const code = failureCodeFromUnknown(error) ?? "INTERNAL_ERROR";
|
|
73
|
+
const exitCode = exitCodeForFailure(code);
|
|
74
|
+
const output = failureOutput(exitCode, code, errorMessage(error));
|
|
75
|
+
if (json) {
|
|
76
|
+
writeJson(dependencies.stdout, output);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
writeLine(dependencies.stderr, output.failure.message);
|
|
80
|
+
}
|
|
81
|
+
dependencies.setExitCode(exitCode);
|
|
82
|
+
}
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
@@ -7,7 +7,7 @@ import { GenerationOperationError, flowReplayFailureDetails } from "../../../app
|
|
|
7
7
|
import { GenerationSessionIdSchema, verificationPhaseLabel } from "../../../domain/generation.js";
|
|
8
8
|
import { DEFAULT_DEVICE_ROLE } from "../../../domain/journey.js";
|
|
9
9
|
import { ProjectRelativePathSchema } from "../../../domain/project-context.js";
|
|
10
|
-
import { CONFIG_PATH, CONTEXT_INDEX_PATH } from "../../../domain/workspace.js";
|
|
10
|
+
import { CONFIG_PATH, CONTEXT_INDEX_PATH, isJourneyBriefPath, JOURNEY_BRIEF_ROOTS } from "../../../domain/workspace.js";
|
|
11
11
|
import { GenerationSessionStoreError } from "../../../ports/generation-session-store.js";
|
|
12
12
|
import { errorMessage, writeJson, writeLine } from "../../output.js";
|
|
13
13
|
import { canonicalProjectRoot } from "../../project-root.js";
|
|
@@ -52,6 +52,9 @@ export function createStartCommand(dependencies) {
|
|
|
52
52
|
catch (error) {
|
|
53
53
|
throw new GenerationOperationError("BRIEF_INVALID", `Journey Brief path must stay within the project: ${briefRequest} (${error instanceof Error ? error.message : String(error)})`);
|
|
54
54
|
}
|
|
55
|
+
if (!isJourneyBriefPath(briefPath)) {
|
|
56
|
+
throw new GenerationOperationError("BRIEF_INVALID", `Journey Brief must live under ${JOURNEY_BRIEF_ROOTS.join("/ or ")}/: ${briefPath}`);
|
|
57
|
+
}
|
|
55
58
|
let bytes;
|
|
56
59
|
try {
|
|
57
60
|
bytes = await dependencies.readFile(resolve(projectRoot, briefPath));
|
|
@@ -213,6 +213,7 @@ function createCheckCommand(dependencies) {
|
|
|
213
213
|
.option("--project <path>", "Android project root", dependencies.cwd())
|
|
214
214
|
.option("--config <path>", "TapHound config path", CONFIG_PATH)
|
|
215
215
|
.option("--context <path>", "Project Context index path")
|
|
216
|
+
.option("--journey <path-or-name...>", "Check only these Journeys (project-relative path or Journey name)")
|
|
216
217
|
.option("--json", "Emit one machine-readable JSON value")
|
|
217
218
|
.option("--strict", "Exit non-zero when any Journey is not fresh")
|
|
218
219
|
.action(async (options) => {
|
|
@@ -245,7 +246,8 @@ function createCheckCommand(dependencies) {
|
|
|
245
246
|
projectRoot,
|
|
246
247
|
config,
|
|
247
248
|
project,
|
|
248
|
-
bundle: index.bundle
|
|
249
|
+
bundle: index.bundle,
|
|
250
|
+
journeys: options.journey
|
|
249
251
|
});
|
|
250
252
|
const notFresh = result.summary.total - result.summary.fresh;
|
|
251
253
|
const exitCode = options.strict === true && notFresh > 0 ? 1 : 0;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
3
|
import { Command } from "commander";
|
|
3
4
|
import { readCliVersion } from "../version.js";
|
|
4
5
|
import { runDiffVerification } from "../diff-verification.js";
|
|
5
6
|
import { loadPublishedReplayPolicy } from "../../application/generation/replay-policy-loader.js";
|
|
7
|
+
import { logcatEvidenceWarning } from "../../application/report/report-writer.js";
|
|
6
8
|
import { TapHoundConfigSchema } from "../../domain/config.js";
|
|
7
9
|
import { DEFAULT_DEVICE_ROLE, JourneySchema } from "../../domain/journey.js";
|
|
8
10
|
import { assertArtifactDirectory, CONFIG_PATH } from "../../domain/workspace.js";
|
|
9
11
|
import { exitCodeForFailure } from "../../domain/failure.js";
|
|
12
|
+
import { VERIFY_RECEIPT_FILE, VerifyReceiptSchema } from "../../domain/verify-receipt.js";
|
|
10
13
|
import { errorMessage, failureOutput, writeJson, writeLine } from "../output.js";
|
|
11
14
|
import { prepareWorkspace } from "../workspace-guard.js";
|
|
12
15
|
function toolVersions(checks) {
|
|
@@ -26,6 +29,50 @@ function writeFailure(dependencies, json, code, message) {
|
|
|
26
29
|
}
|
|
27
30
|
dependencies.setExitCode(exitCode);
|
|
28
31
|
}
|
|
32
|
+
/** The normalized invocation TapHound ran, recorded in the process receipt. */
|
|
33
|
+
function receiptArgv(options, projectRoot, deviceSerial) {
|
|
34
|
+
const root = resolve(projectRoot);
|
|
35
|
+
return [
|
|
36
|
+
"verify",
|
|
37
|
+
"--project", root,
|
|
38
|
+
"--config", resolve(root, options.config),
|
|
39
|
+
"--journey", resolve(root, options.journey),
|
|
40
|
+
"--device", deviceSerial,
|
|
41
|
+
...(options.package === undefined ? [] : ["--package", options.package]),
|
|
42
|
+
...(options.activity === undefined ? [] : ["--activity", options.activity]),
|
|
43
|
+
...(options.reports === undefined ? [] : ["--reports", options.reports]),
|
|
44
|
+
...(options.policyFromMeta === true ? ["--policy-from-meta"] : []),
|
|
45
|
+
...(options.json === true ? ["--json"] : [])
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Publishes the process receipt beside the report. The report is already
|
|
50
|
+
* published, so a receipt failure is reported on stderr and leaves the
|
|
51
|
+
* verify result intact; a Workflow checker then pauses on the missing receipt.
|
|
52
|
+
*/
|
|
53
|
+
async function writeReceipt(dependencies, argv, result) {
|
|
54
|
+
if (dependencies.writeVerifyReceipt === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
const receiptPath = join(dirname(result.reportPath), VERIFY_RECEIPT_FILE);
|
|
57
|
+
try {
|
|
58
|
+
const receipt = VerifyReceiptSchema.parse({
|
|
59
|
+
version: 1,
|
|
60
|
+
argv,
|
|
61
|
+
exitCode: result.exitCode,
|
|
62
|
+
journeySha256: result.report.journey.sha256,
|
|
63
|
+
reportPath: result.reportPath,
|
|
64
|
+
reportSha256: createHash("sha256")
|
|
65
|
+
.update(await dependencies.readFile(result.reportPath))
|
|
66
|
+
.digest("hex")
|
|
67
|
+
});
|
|
68
|
+
await dependencies.writeVerifyReceipt(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
69
|
+
return receiptPath;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
writeLine(dependencies.stderr, `TapHound: verify receipt was not written: ${errorMessage(error)}`);
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
29
76
|
async function runDoctorAndVerify(dependencies, options, config, journey, projectRoot) {
|
|
30
77
|
const json = options.json === true;
|
|
31
78
|
let replayPolicy;
|
|
@@ -103,11 +150,16 @@ async function runDoctorAndVerify(dependencies, options, config, journey, projec
|
|
|
103
150
|
? {}
|
|
104
151
|
: { signal: dependencies.signal })
|
|
105
152
|
});
|
|
153
|
+
const receiptPath = await writeReceipt(dependencies, receiptArgv(options, projectRoot, deviceSerial), result);
|
|
106
154
|
if (json) {
|
|
107
|
-
writeJson(dependencies.stdout, result);
|
|
155
|
+
writeJson(dependencies.stdout, receiptPath === undefined ? result : { ...result, receiptPath });
|
|
108
156
|
}
|
|
109
157
|
else {
|
|
110
|
-
writeLine(dependencies.stdout, `TapHound verify: ${result.status.toUpperCase()}\nReport: ${result.reportPath}`);
|
|
158
|
+
writeLine(dependencies.stdout, `TapHound verify: ${result.status.toUpperCase()}\nReport: ${result.reportPath}${receiptPath === undefined ? "" : `\nReceipt: ${receiptPath}`}`);
|
|
159
|
+
}
|
|
160
|
+
const logcatWarning = logcatEvidenceWarning(result.report);
|
|
161
|
+
if (logcatWarning !== undefined) {
|
|
162
|
+
writeLine(dependencies.stderr, logcatWarning);
|
|
111
163
|
}
|
|
112
164
|
dependencies.setExitCode(result.exitCode);
|
|
113
165
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { type DiagnosticsExportInput } from "../application/diagnostics/diagnostics-exporter.js";
|
|
2
|
+
import type { DiagnosticsBundle } from "../domain/diagnostics.js";
|
|
3
|
+
import { type CliDiagnostics } from "./diagnostics-recorder.js";
|
|
1
4
|
import type { MobileMcpTools } from "../adapters/runtime/mobile-mcp/mobile-mcp-tools.js";
|
|
2
5
|
import type { Clock } from "../ports/clock.js";
|
|
3
6
|
import { type AlignCameraResult } from "../application/align/align-service.js";
|
|
@@ -160,11 +163,19 @@ export interface CliDependencies {
|
|
|
160
163
|
} | undefined;
|
|
161
164
|
readJson: (path: string) => Promise<unknown>;
|
|
162
165
|
readFile: (path: string) => Promise<Buffer>;
|
|
166
|
+
/** Atomically publishes a verify process receipt beside its report. */
|
|
167
|
+
writeVerifyReceipt?: ((path: string, content: string) => Promise<void>) | undefined;
|
|
163
168
|
cwd: () => string;
|
|
164
169
|
stdout: TextOutput;
|
|
165
170
|
stderr: TextOutput;
|
|
166
171
|
setExitCode: (code: number) => void;
|
|
167
172
|
close?: (() => Promise<void>) | undefined;
|
|
173
|
+
/** Local diagnostics journal; absent in tests and when disabled. */
|
|
174
|
+
diagnostics?: CliDiagnostics | undefined;
|
|
175
|
+
diagnosticsExport?: {
|
|
176
|
+
export: (input: DiagnosticsExportInput) => Promise<DiagnosticsBundle>;
|
|
177
|
+
write: (path: string, content: string) => Promise<void>;
|
|
178
|
+
} | undefined;
|
|
168
179
|
}
|
|
169
180
|
export interface ProductionDependencyOptions {
|
|
170
181
|
generationStoreFactory?: (projectRoot: string) => GenerationSessionStore;
|
package/dist/cli/dependencies.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,6 +8,12 @@ import { AdbRuntimeBackend } from "../adapters/runtime/adb-runtime-backend.js";
|
|
|
8
8
|
import { SharedSessionRuntimeBackend } from "../adapters/runtime/shared-session-runtime-backend.js";
|
|
9
9
|
import { SessionBackedScreenshotAdapter, SessionBackedUiSnapshotProviderFactory } from "../adapters/runtime/session-backed-ports.js";
|
|
10
10
|
import { runtimeSessionPortViews } from "../adapters/runtime/session-adb-view.js";
|
|
11
|
+
import { FileSystemDiagnosticsJournal } from "../adapters/filesystem/diagnostics-journal.js";
|
|
12
|
+
import { UiCaptureTelemetry } from "../application/diagnostics/ui-capture-telemetry.js";
|
|
13
|
+
import { ObservedUiSnapshotProviderFactory } from "../application/ui/observed-ui-snapshot-provider.js";
|
|
14
|
+
import { DiagnosticsExporter } from "../application/diagnostics/diagnostics-exporter.js";
|
|
15
|
+
import { readCliVersion } from "./version.js";
|
|
16
|
+
import { diagnosticsEnabled, diagnosticsHost } from "./diagnostics-recorder.js";
|
|
11
17
|
import { MobileMcpRuntimeBackend } from "../adapters/runtime/mobile-mcp/mobile-mcp-runtime-backend.js";
|
|
12
18
|
import { McpToolClient } from "../adapters/runtime/mobile-mcp/mcp-tool-client.js";
|
|
13
19
|
import { isMobileMcpSpawnFailureDetail, mobileMcpServerUnavailableMessage } from "../adapters/runtime/mobile-mcp/mobile-mcp-errors.js";
|
|
@@ -117,6 +123,9 @@ export function createProductionDependencies(signal, options = {}) {
|
|
|
117
123
|
const clock = options.clock ?? new SystemClock();
|
|
118
124
|
const now = () => clock.now();
|
|
119
125
|
const permissionCaptureTimeoutMs = 10_000;
|
|
126
|
+
const uiTelemetry = new UiCaptureTelemetry();
|
|
127
|
+
const diagnosticsJournal = new FileSystemDiagnosticsJournal();
|
|
128
|
+
const observed = (source) => (new ObservedUiSnapshotProviderFactory(source, uiTelemetry, now));
|
|
120
129
|
let backend;
|
|
121
130
|
let screenshots;
|
|
122
131
|
let uiSnapshots;
|
|
@@ -124,7 +133,7 @@ export function createProductionDependencies(signal, options = {}) {
|
|
|
124
133
|
if (options.runtimeBackend !== undefined) {
|
|
125
134
|
backend = options.runtimeBackend;
|
|
126
135
|
screenshots = new SessionBackedScreenshotAdapter(backend);
|
|
127
|
-
uiSnapshots = new CachedUiSnapshotProviderFactory(new SessionBackedUiSnapshotProviderFactory(backend), now);
|
|
136
|
+
uiSnapshots = new CachedUiSnapshotProviderFactory(observed(new SessionBackedUiSnapshotProviderFactory(backend)), now);
|
|
128
137
|
}
|
|
129
138
|
else if (backendId === "mobile-mcp") {
|
|
130
139
|
const shared = new SharedSessionRuntimeBackend(new MobileMcpRuntimeBackend({
|
|
@@ -134,12 +143,16 @@ export function createProductionDependencies(signal, options = {}) {
|
|
|
134
143
|
sharedBackend = shared;
|
|
135
144
|
backend = shared;
|
|
136
145
|
screenshots = new SessionBackedScreenshotAdapter(backend);
|
|
137
|
-
uiSnapshots = new CachedUiSnapshotProviderFactory(new SessionBackedUiSnapshotProviderFactory(backend), now);
|
|
146
|
+
uiSnapshots = new CachedUiSnapshotProviderFactory(observed(new SessionBackedUiSnapshotProviderFactory(backend)), now);
|
|
138
147
|
}
|
|
139
148
|
else {
|
|
140
149
|
const adbAdapter = new AdbAdapter(runner);
|
|
141
150
|
const androidCli = new AndroidCliAdapter(runner);
|
|
142
|
-
const autoSnapshots = new CachedUiSnapshotProviderFactory(new AutoUiSnapshotProviderFactory(new SystemUiAutomatorSnapshotProviderFactory(runner), new AndroidCliSnapshotProviderFactory(runner), new AppiumUiSnapshotProviderFactory(runner
|
|
151
|
+
const autoSnapshots = new CachedUiSnapshotProviderFactory(observed(new AutoUiSnapshotProviderFactory(new SystemUiAutomatorSnapshotProviderFactory(runner), new AndroidCliSnapshotProviderFactory(runner), new AppiumUiSnapshotProviderFactory(runner, undefined, {
|
|
152
|
+
onSessionRecovery: (succeeded) => {
|
|
153
|
+
uiTelemetry.sessionRecovered("appium-uiautomator2", succeeded);
|
|
154
|
+
}
|
|
155
|
+
}))), now);
|
|
143
156
|
const adbBackend = new AdbRuntimeBackend({
|
|
144
157
|
adb: adbAdapter,
|
|
145
158
|
screenshots: androidCli,
|
|
@@ -585,6 +598,17 @@ export function createProductionDependencies(signal, options = {}) {
|
|
|
585
598
|
: { cliEntryPath: process.argv[1] }),
|
|
586
599
|
readJson: async (path) => JSON.parse(await readFile(path, "utf8")),
|
|
587
600
|
readFile: async (path) => readFile(path),
|
|
601
|
+
writeVerifyReceipt: async (path, content) => {
|
|
602
|
+
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
603
|
+
try {
|
|
604
|
+
await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx" });
|
|
605
|
+
await rename(temporaryPath, path);
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
await rm(temporaryPath, { force: true });
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
611
|
+
},
|
|
588
612
|
cwd: () => process.cwd(),
|
|
589
613
|
stdout: {
|
|
590
614
|
write: (content) => {
|
|
@@ -598,6 +622,35 @@ export function createProductionDependencies(signal, options = {}) {
|
|
|
598
622
|
},
|
|
599
623
|
setExitCode: (code) => {
|
|
600
624
|
process.exitCode = code;
|
|
601
|
-
}
|
|
625
|
+
},
|
|
626
|
+
diagnosticsExport: {
|
|
627
|
+
export: (input) => new DiagnosticsExporter({
|
|
628
|
+
journal: diagnosticsJournal,
|
|
629
|
+
readJson: async (path) => JSON.parse(await readFile(path, "utf8")),
|
|
630
|
+
now: () => new Date(),
|
|
631
|
+
taphoundVersion: readCliVersion(),
|
|
632
|
+
host: diagnosticsHost()
|
|
633
|
+
}).export(input),
|
|
634
|
+
write: async (path, content) => {
|
|
635
|
+
await mkdir(dirname(path), { recursive: true });
|
|
636
|
+
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
637
|
+
try {
|
|
638
|
+
await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
639
|
+
await rename(temporaryPath, path);
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
await rm(temporaryPath, { force: true });
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
...(diagnosticsEnabled(process.env)
|
|
648
|
+
? {
|
|
649
|
+
diagnostics: {
|
|
650
|
+
journal: diagnosticsJournal,
|
|
651
|
+
telemetry: uiTelemetry
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
: {})
|
|
602
655
|
};
|
|
603
656
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type CommandEvent, type DiagnosticsHostSchema } from "../domain/diagnostics.js";
|
|
3
|
+
import type { UiCaptureTelemetry } from "../application/diagnostics/ui-capture-telemetry.js";
|
|
4
|
+
import type { DiagnosticsJournal } from "../ports/diagnostics.js";
|
|
5
|
+
import type { z } from "zod";
|
|
6
|
+
export interface CliDiagnostics {
|
|
7
|
+
journal: DiagnosticsJournal;
|
|
8
|
+
telemetry: UiCaptureTelemetry;
|
|
9
|
+
}
|
|
10
|
+
export declare function diagnosticsEnabled(env: Record<string, string | undefined>): boolean;
|
|
11
|
+
export declare function diagnosticsHost(): z.infer<typeof DiagnosticsHostSchema>;
|
|
12
|
+
/** What the invoked command is, captured before its action runs. */
|
|
13
|
+
export interface InvokedCommand {
|
|
14
|
+
command: string;
|
|
15
|
+
flags: string[];
|
|
16
|
+
projectRoot: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function describeInvocation(action: Command, cwd: string): InvokedCommand;
|
|
19
|
+
/**
|
|
20
|
+
* Reads only the structured outcome of one machine-readable output: status,
|
|
21
|
+
* failure code, and run id. Messages and every other field are ignored.
|
|
22
|
+
*/
|
|
23
|
+
export declare function outcomeFromJsonOutput(stdout: string): Pick<CommandEvent, "status" | "failureCode" | "runId">;
|
|
24
|
+
export declare function commandEvent(input: {
|
|
25
|
+
invocation: InvokedCommand;
|
|
26
|
+
startedAt: Date;
|
|
27
|
+
durationMs: number;
|
|
28
|
+
exitCode: number;
|
|
29
|
+
stdout: string | undefined;
|
|
30
|
+
telemetry: UiCaptureTelemetry;
|
|
31
|
+
}): CommandEvent;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { arch, platform, version as nodeVersion } from "node:process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { CommandEventSchema, DIAGNOSTICS_DISABLE_ENV_VAR } from "../domain/diagnostics.js";
|
|
4
|
+
import { FAILURE_CODES } from "../domain/failure.js";
|
|
5
|
+
import { readCliVersion } from "./version.js";
|
|
6
|
+
export function diagnosticsEnabled(env) {
|
|
7
|
+
const value = env[DIAGNOSTICS_DISABLE_ENV_VAR]?.trim().toLowerCase();
|
|
8
|
+
return !["0", "off", "false", "no"].includes(value ?? "");
|
|
9
|
+
}
|
|
10
|
+
export function diagnosticsHost() {
|
|
11
|
+
return {
|
|
12
|
+
platform,
|
|
13
|
+
arch,
|
|
14
|
+
node: nodeVersion.replace(/^v/, "")
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function describeInvocation(action, cwd) {
|
|
18
|
+
const names = [];
|
|
19
|
+
let command = action;
|
|
20
|
+
while (command.parent !== null) {
|
|
21
|
+
names.unshift(command.name());
|
|
22
|
+
command = command.parent;
|
|
23
|
+
}
|
|
24
|
+
const flags = action.options
|
|
25
|
+
.map((option) => option.attributeName())
|
|
26
|
+
.filter((name) => action.getOptionValueSource(name) === "cli");
|
|
27
|
+
const project = action.opts().project;
|
|
28
|
+
return {
|
|
29
|
+
command: names.join(" "),
|
|
30
|
+
flags,
|
|
31
|
+
projectRoot: typeof project === "string" ? resolve(cwd, project) : cwd
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function record(value) {
|
|
35
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
36
|
+
? value
|
|
37
|
+
: undefined;
|
|
38
|
+
}
|
|
39
|
+
function failureCode(value) {
|
|
40
|
+
return FAILURE_CODES.find((code) => code === value);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reads only the structured outcome of one machine-readable output: status,
|
|
44
|
+
* failure code, and run id. Messages and every other field are ignored.
|
|
45
|
+
*/
|
|
46
|
+
export function outcomeFromJsonOutput(stdout) {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(stdout.trim());
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
const output = record(parsed);
|
|
55
|
+
if (output === undefined)
|
|
56
|
+
return {};
|
|
57
|
+
const report = record(output.report);
|
|
58
|
+
const status = [output.status, output.verdict].find((value) => (typeof value === "string" && /^[a-zA-Z][a-zA-Z\d]{0,31}$/.test(value)));
|
|
59
|
+
const code = failureCode(record(output.failure)?.code)
|
|
60
|
+
?? failureCode(record(report?.primaryFailure)?.code);
|
|
61
|
+
const runId = [report?.runId, output.runId].find((value) => (typeof value === "string" && /^[\w.-]{1,128}$/.test(value)));
|
|
62
|
+
return {
|
|
63
|
+
...(typeof status === "string" ? { status } : {}),
|
|
64
|
+
...(code === undefined ? {} : { failureCode: code }),
|
|
65
|
+
...(typeof runId === "string" ? { runId } : {})
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function commandEvent(input) {
|
|
69
|
+
return CommandEventSchema.parse({
|
|
70
|
+
version: 1,
|
|
71
|
+
kind: "command",
|
|
72
|
+
at: input.startedAt.toISOString(),
|
|
73
|
+
taphoundVersion: readCliVersion(),
|
|
74
|
+
host: diagnosticsHost(),
|
|
75
|
+
command: input.invocation.command,
|
|
76
|
+
flags: input.invocation.flags,
|
|
77
|
+
durationMs: Math.max(0, Math.round(input.durationMs)),
|
|
78
|
+
exitCode: input.exitCode,
|
|
79
|
+
...(input.stdout === undefined ? {} : outcomeFromJsonOutput(input.stdout)),
|
|
80
|
+
ui: input.telemetry.summary()
|
|
81
|
+
});
|
|
82
|
+
}
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ export interface TerminationSignalSource {
|
|
|
5
5
|
off: (event: "SIGINT" | "SIGTERM", listener: () => void) => unknown;
|
|
6
6
|
}
|
|
7
7
|
export declare function withTerminationSignal<T>(operation: (signal: AbortSignal) => Promise<T>, source?: TerminationSignalSource): Promise<T>;
|
|
8
|
-
export declare function runMain(argv: readonly string[],
|
|
8
|
+
export declare function runMain(argv: readonly string[], baseDependencies?: CliDependencies): Promise<void>;
|