patchwarden 0.6.0 → 0.6.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/README.md +3 -3
- package/dist/doctor.js +1 -1
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/changeCapture.d.ts +41 -0
- package/dist/runner/changeCapture.js +171 -1
- package/dist/runner/runTask.js +204 -24
- package/dist/smoke-test.js +8 -8
- package/dist/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- package/dist/tools/auditTask.js +11 -5
- package/dist/tools/getTaskSummary.d.ts +3 -0
- package/dist/tools/getTaskSummary.js +15 -1
- package/dist/tools/healthCheck.d.ts +5 -0
- package/dist/tools/healthCheck.js +21 -0
- package/dist/tools/registry.js +53 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +2 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/performance-notes.md +55 -0
- package/docs/release-v0.6.1.md +75 -0
- package/package.json +3 -2
- package/scripts/http-mcp-smoke.js +10 -2
- package/scripts/lifecycle-smoke.js +336 -2
- package/scripts/mcp-manifest-check.js +30 -7
- package/scripts/mcp-smoke.js +11 -8
- package/scripts/pack-clean.js +157 -1
- package/scripts/unit-tests.js +36 -0
- package/src/doctor.ts +1 -1
- package/src/logging.ts +152 -0
- package/src/runner/changeCapture.ts +212 -1
- package/src/runner/runTask.ts +220 -22
- package/src/smoke-test.ts +5 -5
- package/src/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/auditTask.ts +11 -5
- package/src/tools/getTaskSummary.ts +22 -1
- package/src/tools/healthCheck.ts +22 -0
- package/src/tools/registry.ts +63 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/toolCatalog.ts +2 -0
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
package/src/runner/runTask.ts
CHANGED
|
@@ -23,13 +23,22 @@ import {
|
|
|
23
23
|
compareSnapshots,
|
|
24
24
|
emptyArtifactHygiene,
|
|
25
25
|
writeSnapshot,
|
|
26
|
+
extractExternalDirtyFiles,
|
|
27
|
+
findNewExternalDirtyFiles,
|
|
28
|
+
buildArtifactManifest,
|
|
29
|
+
groupChangedFiles,
|
|
26
30
|
type ChangedFile,
|
|
27
31
|
type ChangeArtifacts,
|
|
32
|
+
type ExternalDirtyFile,
|
|
33
|
+
type ArtifactManifest,
|
|
28
34
|
} from "./changeCapture.js";
|
|
35
|
+
import { PatchWardenError, errorPayload } from "../errors.js";
|
|
36
|
+
import { diagnoseAndroidBuild } from "../tools/androidDoctor.js";
|
|
29
37
|
|
|
30
38
|
const HEARTBEAT_INTERVAL_MS = 2000;
|
|
31
39
|
const GRACEFUL_KILL_MS = 2000;
|
|
32
40
|
const MAX_CAPTURE_CHARS = 100_000;
|
|
41
|
+
const ARTIFACT_COLLECTION_TIMEOUT_MS = 60_000;
|
|
33
42
|
|
|
34
43
|
interface TaskRunResult {
|
|
35
44
|
task_id: string;
|
|
@@ -97,7 +106,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
97
106
|
try {
|
|
98
107
|
timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
|
|
99
108
|
} catch (error) {
|
|
100
|
-
return failBeforeExecution(taskId, taskDir, errorMessage(error));
|
|
109
|
+
return failBeforeExecution(taskId, taskDir, errorMessage(error), error);
|
|
101
110
|
}
|
|
102
111
|
const startedAtMs = Date.now();
|
|
103
112
|
const deadlineMs = startedAtMs + timeoutSeconds * 1000;
|
|
@@ -107,13 +116,13 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
107
116
|
repoPath = guardWorkspacePath(rawRepoPath, wsRoot);
|
|
108
117
|
} catch (error) {
|
|
109
118
|
const message = `repo_path validation failed: ${errorMessage(error)}`;
|
|
110
|
-
return failBeforeExecution(taskId, taskDir, message);
|
|
119
|
+
return failBeforeExecution(taskId, taskDir, message, error);
|
|
111
120
|
}
|
|
112
121
|
try {
|
|
113
122
|
verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
|
|
114
123
|
} catch (error) {
|
|
115
124
|
const message = `verification metadata validation failed: ${errorMessage(error)}`;
|
|
116
|
-
return failBeforeExecution(taskId, taskDir, message);
|
|
125
|
+
return failBeforeExecution(taskId, taskDir, message, error);
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
// ── Assessment freshness revalidation before execution ──
|
|
@@ -128,7 +137,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
128
137
|
}
|
|
129
138
|
} catch (error) {
|
|
130
139
|
const message = `assessment validation error: ${errorMessage(error)}`;
|
|
131
|
-
return failBeforeExecution(taskId, taskDir, message);
|
|
140
|
+
return failBeforeExecution(taskId, taskDir, message, error);
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
143
|
|
|
@@ -145,14 +154,17 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
145
154
|
|
|
146
155
|
let beforeSnapshot;
|
|
147
156
|
let beforeWorkspaceSnapshot;
|
|
157
|
+
let externalDirtyBaseline: ExternalDirtyFile[] = [];
|
|
148
158
|
try {
|
|
149
159
|
beforeSnapshot = captureRepoSnapshot(repoPath);
|
|
150
160
|
beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : captureRepoSnapshot(wsRoot);
|
|
151
161
|
writeSnapshot(taskDir, "git-before.json", beforeSnapshot);
|
|
152
162
|
writeSnapshot(taskDir, "workspace-before.json", beforeWorkspaceSnapshot);
|
|
163
|
+
// Phase 4: Record external dirty files as baseline before task execution
|
|
164
|
+
externalDirtyBaseline = extractExternalDirtyFiles(beforeWorkspaceSnapshot, repoPath, wsRoot);
|
|
153
165
|
} catch (error) {
|
|
154
166
|
const message = `Pre-task snapshot failed: ${errorMessage(error)}`;
|
|
155
|
-
return failBeforeExecution(taskId, taskDir, message);
|
|
167
|
+
return failBeforeExecution(taskId, taskDir, message, error);
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
let agentResult: ManagedProcessResult | null = null;
|
|
@@ -160,6 +172,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
160
172
|
const verifyResults: TestExecutionResult[] = [];
|
|
161
173
|
let finalStatus: TaskStatus = "failed";
|
|
162
174
|
let finalError: string | null = null;
|
|
175
|
+
let lastCaughtError: unknown = null;
|
|
163
176
|
|
|
164
177
|
try {
|
|
165
178
|
const planFile = resolve(plansDir, planId, "plan.md");
|
|
@@ -226,19 +239,42 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
226
239
|
finalStatus = "done";
|
|
227
240
|
}
|
|
228
241
|
} catch (error) {
|
|
242
|
+
lastCaughtError = error;
|
|
229
243
|
finalError = errorMessage(error);
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
setTaskPhase(taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
|
|
247
|
+
const artifactCollectionStartedAt = new Date().toISOString();
|
|
233
248
|
let changes: ChangeArtifacts;
|
|
249
|
+
let artifactStatus: "collected" | "partial" | "failed" | "timeout" = "collected";
|
|
250
|
+
let artifactCollectionError: string | null = null;
|
|
251
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
234
252
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
253
|
+
// Phase 5: Wrap artifact collection with a timeout
|
|
254
|
+
const collectionResult = await Promise.race([
|
|
255
|
+
Promise.resolve().then(() => {
|
|
256
|
+
const afterSnapshot = captureRepoSnapshot(repoPath);
|
|
257
|
+
writeSnapshot(taskDir, "git-after.json", afterSnapshot);
|
|
258
|
+
return buildChangeArtifacts(repoPath, beforeSnapshot, afterSnapshot);
|
|
259
|
+
}),
|
|
260
|
+
new Promise<never>((_, reject) => {
|
|
261
|
+
timeoutHandle = setTimeout(() => reject(new Error("Artifact collection timed out")), ARTIFACT_COLLECTION_TIMEOUT_MS);
|
|
262
|
+
}),
|
|
263
|
+
]);
|
|
264
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
265
|
+
changes = collectionResult;
|
|
238
266
|
} catch (error) {
|
|
267
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
268
|
+
lastCaughtError = error;
|
|
269
|
+
artifactCollectionError = errorMessage(error);
|
|
270
|
+
if (artifactCollectionError.includes("timed out")) {
|
|
271
|
+
artifactStatus = "timeout";
|
|
272
|
+
} else {
|
|
273
|
+
artifactStatus = "failed";
|
|
274
|
+
}
|
|
239
275
|
changes = {
|
|
240
276
|
changed_files: [],
|
|
241
|
-
diff: `(change capture failed: ${
|
|
277
|
+
diff: `(change capture failed: ${artifactCollectionError})\n`,
|
|
242
278
|
diff_available: false,
|
|
243
279
|
diff_truncated: false,
|
|
244
280
|
diff_size_bytes: 0,
|
|
@@ -248,17 +284,50 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
248
284
|
workspace_dirty_before: beforeSnapshot.workspace_dirty,
|
|
249
285
|
workspace_dirty_after: beforeSnapshot.workspace_dirty,
|
|
250
286
|
patch_mode: "hash_only",
|
|
251
|
-
unavailable_reason: `Change capture failed: ${
|
|
287
|
+
unavailable_reason: `Change capture failed: ${artifactCollectionError}`,
|
|
252
288
|
artifact_hygiene: emptyArtifactHygiene(),
|
|
253
289
|
};
|
|
254
|
-
|
|
255
|
-
|
|
290
|
+
// Phase 5: Write partial_result.md when artifact collection fails
|
|
291
|
+
writeFileSync(join(taskDir, "partial_result.md"), [
|
|
292
|
+
"# PatchWarden Partial Result",
|
|
293
|
+
"",
|
|
294
|
+
`Task: ${taskId}`,
|
|
295
|
+
"",
|
|
296
|
+
"## Artifact Collection Status",
|
|
297
|
+
artifactStatus,
|
|
298
|
+
"",
|
|
299
|
+
"## Error",
|
|
300
|
+
artifactCollectionError,
|
|
301
|
+
"",
|
|
302
|
+
"## Agent Status",
|
|
303
|
+
agentResult ? `Exit code: ${agentResult.exitCode}` : "Agent did not run",
|
|
304
|
+
"",
|
|
305
|
+
"## Verification Status",
|
|
306
|
+
verifyResults.length > 0 ? verifyResults.map((r) => `- ${r.command}: exit ${r.exitCode}`).join("\n") : "No verification ran",
|
|
307
|
+
"",
|
|
308
|
+
"## Note",
|
|
309
|
+
"Artifact collection did not complete. The task result may be incomplete. Review stdout.log, stderr.log, and verify.json for details.",
|
|
310
|
+
"",
|
|
311
|
+
].join("\n"), "utf-8");
|
|
312
|
+
// Don't override test failure with artifact failure
|
|
313
|
+
if (finalStatus === "done") {
|
|
314
|
+
finalError = `Change capture failed: ${artifactCollectionError}`;
|
|
315
|
+
finalStatus = "failed";
|
|
316
|
+
} else {
|
|
317
|
+
finalError ||= `Change capture failed: ${artifactCollectionError}`;
|
|
318
|
+
}
|
|
256
319
|
}
|
|
320
|
+
const artifactCollectionFinishedAt = new Date().toISOString();
|
|
257
321
|
|
|
258
322
|
let outOfScopeChanges: ChangedFile[] = [];
|
|
323
|
+
let preexistingExternalDirty: ExternalDirtyFile[] = externalDirtyBaseline;
|
|
324
|
+
let newOutOfScopeChanges: ExternalDirtyFile[] = [];
|
|
259
325
|
try {
|
|
260
326
|
const afterWorkspaceSnapshot = repoPath === wsRoot ? captureRepoSnapshot(repoPath) : captureRepoSnapshot(wsRoot);
|
|
261
327
|
writeSnapshot(taskDir, "workspace-after.json", afterWorkspaceSnapshot);
|
|
328
|
+
const allExternalDirty = extractExternalDirtyFiles(afterWorkspaceSnapshot, repoPath, wsRoot);
|
|
329
|
+
// Phase 4: Only NEW external dirty files (not in baseline) are scope violations
|
|
330
|
+
newOutOfScopeChanges = findNewExternalDirtyFiles(externalDirtyBaseline, allExternalDirty);
|
|
262
331
|
outOfScopeChanges = compareSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
|
|
263
332
|
.filter((file) =>
|
|
264
333
|
!isPathInside(resolve(wsRoot, file.path), repoPath) ||
|
|
@@ -269,15 +338,21 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
269
338
|
if (finalStatus === "done") finalStatus = "failed";
|
|
270
339
|
}
|
|
271
340
|
|
|
272
|
-
|
|
341
|
+
// Phase 4: Pre-existing external dirty files are warnings, not failures
|
|
342
|
+
const preexistingWarnings: string[] = preexistingExternalDirty.length > 0
|
|
343
|
+
? [`Pre-existing external dirty files (not caused by this task): ${preexistingExternalDirty.length} file(s)`]
|
|
344
|
+
: [];
|
|
345
|
+
|
|
346
|
+
if (newOutOfScopeChanges.length > 0) {
|
|
273
347
|
finalStatus = "failed_scope_violation";
|
|
274
|
-
finalError = `Detected ${
|
|
348
|
+
finalError = `Detected ${newOutOfScopeChanges.length} new change(s) outside resolved_repo_path during task execution.`;
|
|
275
349
|
writeFileSync(join(taskDir, "rollback-plan.json"), JSON.stringify({
|
|
276
350
|
task_id: taskId,
|
|
277
351
|
status: "review_required",
|
|
278
352
|
automatic_rollback_performed: false,
|
|
279
353
|
warning: "Review ownership and concurrent edits before rollback. PatchWarden did not modify or restore these files.",
|
|
280
|
-
out_of_scope_changes:
|
|
354
|
+
out_of_scope_changes: newOutOfScopeChanges,
|
|
355
|
+
preexisting_external_dirty_files: preexistingExternalDirty,
|
|
281
356
|
}, null, 2), "utf-8");
|
|
282
357
|
writeFileSync(join(taskDir, "rollback_scope_violation_plan.md"), [
|
|
283
358
|
"# Scope Violation Rollback Plan",
|
|
@@ -286,8 +361,8 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
286
361
|
"",
|
|
287
362
|
"PatchWarden did not automatically roll back any file. Review concurrent or user-owned edits before acting.",
|
|
288
363
|
"",
|
|
289
|
-
"##
|
|
290
|
-
...
|
|
364
|
+
"## New out-of-scope files (caused by this task)",
|
|
365
|
+
...newOutOfScopeChanges.map((file) => `- ${file.change}: ${file.path}`),
|
|
291
366
|
"",
|
|
292
367
|
].join("\n"), "utf-8");
|
|
293
368
|
} else if (changePolicy === "no_changes" && changes.changed_files.length > 0) {
|
|
@@ -298,6 +373,12 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
298
373
|
writeFileSync(join(taskDir, "git.diff"), changes.diff, "utf-8");
|
|
299
374
|
writeFileSync(join(taskDir, "diff.patch"), changes.diff, "utf-8");
|
|
300
375
|
writeFileSync(join(taskDir, "changed-files.json"), JSON.stringify(changes, null, 2), "utf-8");
|
|
376
|
+
|
|
377
|
+
// Phase 6: Generate artifact_manifest.json and group changed files
|
|
378
|
+
const artifactManifest: ArtifactManifest = buildArtifactManifest(changes.changed_files, repoPath, taskId);
|
|
379
|
+
writeFileSync(join(taskDir, "artifact_manifest.json"), JSON.stringify(artifactManifest, null, 2), "utf-8");
|
|
380
|
+
const changedFileGroups = groupChangedFiles(changes.changed_files);
|
|
381
|
+
|
|
301
382
|
writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
|
|
302
383
|
task_id: taskId,
|
|
303
384
|
additions: changes.additions,
|
|
@@ -306,7 +387,10 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
306
387
|
}, null, 2), "utf-8");
|
|
307
388
|
writeFileSync(join(taskDir, "test.log"), buildTestLog(testResult), "utf-8");
|
|
308
389
|
const verifyJson = buildVerifyJson(verifyCommands, verifyResults, repoPath);
|
|
309
|
-
|
|
390
|
+
// Phase 4: Use newOutOfScopeChanges (not outOfScopeChanges) for verify_status
|
|
391
|
+
// to stay consistent with finalStatus. Pre-existing external dirty files
|
|
392
|
+
// that didn't change during the task should NOT cause verify_status to fail.
|
|
393
|
+
if (newOutOfScopeChanges.length > 0) {
|
|
310
394
|
verifyJson.status = "failed";
|
|
311
395
|
verifyJson.failure_reason = "scope_violation";
|
|
312
396
|
} else if (finalStatus === "failed_policy_violation") {
|
|
@@ -319,6 +403,21 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
319
403
|
if (!["canceled", "done", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(finalStatus)) finalStatus = "failed";
|
|
320
404
|
const finalPhase: TaskPhase = finalStatus === "done" ? "completed" : finalStatus;
|
|
321
405
|
const followup = buildFailureFollowup(finalStatus, finalError, verifyJson.commands);
|
|
406
|
+
|
|
407
|
+
// Phase 7: Run Android build environment diagnostics if android_app exists
|
|
408
|
+
const androidDiagnostic = diagnoseAndroidBuild(repoPath);
|
|
409
|
+
let androidWarning: string | null = null;
|
|
410
|
+
if (androidDiagnostic.status !== "skip") {
|
|
411
|
+
if (androidDiagnostic.status === "fail") {
|
|
412
|
+
androidWarning = "Android project exists, APK not built because Android SDK is missing.";
|
|
413
|
+
} else if (androidDiagnostic.status === "warn") {
|
|
414
|
+
const apkCheck = androidDiagnostic.checks.find((c) => c.check === "APK output path");
|
|
415
|
+
if (apkCheck && apkCheck.status !== "ok") {
|
|
416
|
+
androidWarning = `Android build environment has warnings: ${apkCheck.reason}`;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
322
421
|
const resultMd = buildResultMarkdown({
|
|
323
422
|
taskId,
|
|
324
423
|
planId,
|
|
@@ -330,6 +429,12 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
330
429
|
verify: verifyJson,
|
|
331
430
|
changes,
|
|
332
431
|
outOfScopeChanges,
|
|
432
|
+
artifactStatus,
|
|
433
|
+
artifactManifest,
|
|
434
|
+
changedFileGroups,
|
|
435
|
+
androidDiagnostic,
|
|
436
|
+
androidWarning,
|
|
437
|
+
preexistingWarnings,
|
|
333
438
|
});
|
|
334
439
|
writeFileSync(join(taskDir, "result.md"), resultMd, "utf-8");
|
|
335
440
|
writeFileSync(join(taskDir, "result.json"), JSON.stringify({
|
|
@@ -344,8 +449,26 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
344
449
|
change_policy: changePolicy,
|
|
345
450
|
summary: finalError || "Agent execution and configured verification completed successfully.",
|
|
346
451
|
changed_files: changes.changed_files,
|
|
452
|
+
changed_file_groups: {
|
|
453
|
+
source_changes: changedFileGroups.source_changes.length,
|
|
454
|
+
docs_changes: changedFileGroups.docs_changes.length,
|
|
455
|
+
config_changes: changedFileGroups.config_changes.length,
|
|
456
|
+
test_changes: changedFileGroups.test_changes.length,
|
|
457
|
+
release_artifacts: changedFileGroups.release_artifacts.length,
|
|
458
|
+
runtime_generated_files: changedFileGroups.runtime_generated_files.length,
|
|
459
|
+
},
|
|
347
460
|
artifact_hygiene: changes.artifact_hygiene,
|
|
461
|
+
artifact_status: artifactStatus,
|
|
462
|
+
artifact_collection_error: artifactCollectionError,
|
|
463
|
+
artifact_collection_started_at: artifactCollectionStartedAt,
|
|
464
|
+
artifact_collection_finished_at: artifactCollectionFinishedAt,
|
|
465
|
+
artifact_manifest: artifactManifest,
|
|
348
466
|
out_of_scope_changes: outOfScopeChanges,
|
|
467
|
+
new_out_of_scope_changes: newOutOfScopeChanges,
|
|
468
|
+
preexisting_external_dirty_files: preexistingExternalDirty,
|
|
469
|
+
target_repo_status: changes.workspace_dirty_after ? "dirty" : "clean",
|
|
470
|
+
workspace_status: changes.workspace_dirty_after ? "dirty" : "clean",
|
|
471
|
+
android_diagnostic: androidDiagnostic,
|
|
349
472
|
verify_status: verifyJson.status,
|
|
350
473
|
verify_commands: verifyJson.commands,
|
|
351
474
|
commands_run: verifyJson.commands.map((command) => ({
|
|
@@ -356,12 +479,15 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
356
479
|
commands_observed: [],
|
|
357
480
|
verify: verifyJson,
|
|
358
481
|
artifacts: [
|
|
359
|
-
"result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json",
|
|
482
|
+
"result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json", "artifact_manifest.json",
|
|
360
483
|
...(outOfScopeChanges.length > 0 ? ["rollback_scope_violation_plan.md", "rollback-plan.json"] : []),
|
|
484
|
+
...(artifactStatus !== "collected" ? ["partial_result.md"] : []),
|
|
361
485
|
],
|
|
362
486
|
warnings: [
|
|
363
487
|
...beforeSnapshot.warnings,
|
|
364
488
|
...(changes.diff_truncated ? ["diff.patch was truncated; changed-files.json retains file evidence."] : []),
|
|
489
|
+
...preexistingWarnings,
|
|
490
|
+
...(androidWarning ? [androidWarning] : []),
|
|
365
491
|
],
|
|
366
492
|
errors: finalError ? [finalError] : [],
|
|
367
493
|
known_issues: finalError ? [finalError] : [],
|
|
@@ -373,7 +499,14 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
373
499
|
? ["Review get_task_summary and audit_task before accepting the work."]
|
|
374
500
|
: ["Resolve the reported failure before accepting the work."],
|
|
375
501
|
}, null, 2), "utf-8");
|
|
376
|
-
if (finalError)
|
|
502
|
+
if (finalError) {
|
|
503
|
+
// Phase 9: Preserve PatchWardenError structured info in error.log
|
|
504
|
+
const structuredError = errorPayload(lastCaughtError);
|
|
505
|
+
writeFileSync(join(taskDir, "error.log"), JSON.stringify({
|
|
506
|
+
summary: finalError,
|
|
507
|
+
...structuredError,
|
|
508
|
+
}, null, 2), "utf-8");
|
|
509
|
+
}
|
|
377
510
|
|
|
378
511
|
const finishedAt = new Date().toISOString();
|
|
379
512
|
updateStatus(taskDir, {
|
|
@@ -385,7 +518,13 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
385
518
|
error: finalError,
|
|
386
519
|
changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
|
|
387
520
|
artifact_hygiene_counts: changes.artifact_hygiene.counts,
|
|
521
|
+
artifact_status: artifactStatus,
|
|
522
|
+
artifact_collection_error: artifactCollectionError,
|
|
523
|
+
artifact_collection_started_at: artifactCollectionStartedAt,
|
|
524
|
+
artifact_collection_finished_at: artifactCollectionFinishedAt,
|
|
388
525
|
out_of_scope_changes: outOfScopeChanges,
|
|
526
|
+
new_out_of_scope_changes: newOutOfScopeChanges,
|
|
527
|
+
preexisting_external_dirty_files: preexistingExternalDirty,
|
|
389
528
|
verify_status: verifyJson.status,
|
|
390
529
|
verify_commands: verifyCommands,
|
|
391
530
|
diff_available: changes.diff_available,
|
|
@@ -670,6 +809,12 @@ function buildResultMarkdown(input: {
|
|
|
670
809
|
verify: VerifyReport;
|
|
671
810
|
changes: ChangeArtifacts;
|
|
672
811
|
outOfScopeChanges: ChangedFile[];
|
|
812
|
+
artifactStatus?: string;
|
|
813
|
+
artifactManifest?: ArtifactManifest;
|
|
814
|
+
changedFileGroups?: { source_changes: ChangedFile[]; docs_changes: ChangedFile[]; config_changes: ChangedFile[]; test_changes: ChangedFile[]; release_artifacts: ChangedFile[]; runtime_generated_files: ChangedFile[] };
|
|
815
|
+
androidDiagnostic?: { status: string; checks?: Array<{ check: string; status: string; reason: string }> };
|
|
816
|
+
androidWarning?: string | null;
|
|
817
|
+
preexistingWarnings?: string[];
|
|
673
818
|
}): string {
|
|
674
819
|
const changed = input.changes.changed_files.length
|
|
675
820
|
? input.changes.changed_files.map((file) => `- ${file.change}: ${file.path}`).join("\n")
|
|
@@ -677,6 +822,45 @@ function buildResultMarkdown(input: {
|
|
|
677
822
|
const outOfScope = input.outOfScopeChanges.length
|
|
678
823
|
? input.outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`).join("\n")
|
|
679
824
|
: "(none)";
|
|
825
|
+
|
|
826
|
+
// Phase 6: Artifact manifest summary
|
|
827
|
+
const artifactCount = input.artifactManifest?.artifacts.length || 0;
|
|
828
|
+
const artifactLines = artifactCount > 0
|
|
829
|
+
? input.artifactManifest!.artifacts.map((a) => `- ${a.type}: ${a.path} (${a.size} bytes, sha256: ${a.sha256.slice(0, 16)}...)`).join("\n")
|
|
830
|
+
: "(no release artifacts)";
|
|
831
|
+
|
|
832
|
+
// Phase 6: Changed file group summary
|
|
833
|
+
const groupLines = input.changedFileGroups
|
|
834
|
+
? [
|
|
835
|
+
`- source_changes: ${input.changedFileGroups.source_changes.length}`,
|
|
836
|
+
`- docs_changes: ${input.changedFileGroups.docs_changes.length}`,
|
|
837
|
+
`- config_changes: ${input.changedFileGroups.config_changes.length}`,
|
|
838
|
+
`- test_changes: ${input.changedFileGroups.test_changes.length}`,
|
|
839
|
+
`- release_artifacts: ${input.changedFileGroups.release_artifacts.length}`,
|
|
840
|
+
`- runtime_generated_files: ${input.changedFileGroups.runtime_generated_files.length}`,
|
|
841
|
+
].join("\n")
|
|
842
|
+
: "";
|
|
843
|
+
|
|
844
|
+
// Phase 7: Android diagnostic summary
|
|
845
|
+
let androidSection = "";
|
|
846
|
+
if (input.androidDiagnostic && input.androidDiagnostic.status !== "skip") {
|
|
847
|
+
const checkLines = (input.androidDiagnostic.checks || [])
|
|
848
|
+
.map((c) => `- [${c.status}] ${c.check}: ${c.reason}`)
|
|
849
|
+
.join("\n");
|
|
850
|
+
androidSection = [
|
|
851
|
+
"## Android Build Environment",
|
|
852
|
+
`Status: ${input.androidDiagnostic.status}`,
|
|
853
|
+
...(input.androidWarning ? [`Warning: ${input.androidWarning}`] : []),
|
|
854
|
+
checkLines,
|
|
855
|
+
"",
|
|
856
|
+
].join("\n");
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Phase 4: Pre-existing warnings
|
|
860
|
+
const preexistingSection = input.preexistingWarnings && input.preexistingWarnings.length > 0
|
|
861
|
+
? ["## Pre-existing Warnings", ...input.preexistingWarnings, ""].join("\n")
|
|
862
|
+
: "";
|
|
863
|
+
|
|
680
864
|
return [
|
|
681
865
|
"# PatchWarden Task Result",
|
|
682
866
|
"",
|
|
@@ -695,6 +879,12 @@ function buildResultMarkdown(input: {
|
|
|
695
879
|
"## Files changed",
|
|
696
880
|
changed,
|
|
697
881
|
"",
|
|
882
|
+
"## Changed file groups",
|
|
883
|
+
groupLines,
|
|
884
|
+
"",
|
|
885
|
+
"## Release artifacts",
|
|
886
|
+
artifactLines,
|
|
887
|
+
"",
|
|
698
888
|
"## Verification",
|
|
699
889
|
`- diff_available: ${input.changes.diff_available}`,
|
|
700
890
|
`- diff_truncated: ${input.changes.diff_truncated}`,
|
|
@@ -703,10 +893,13 @@ function buildResultMarkdown(input: {
|
|
|
703
893
|
`- verify_status: ${input.verify.status}`,
|
|
704
894
|
`- verify_commands: ${input.verify.commands.length}/${input.verify.requested_commands.length} executed`,
|
|
705
895
|
`- out_of_scope_changes: ${input.outOfScopeChanges.length}`,
|
|
896
|
+
`- artifact_status: ${input.artifactStatus || "collected"}`,
|
|
706
897
|
"",
|
|
707
898
|
"## Out-of-scope changes",
|
|
708
899
|
outOfScope,
|
|
709
900
|
"",
|
|
901
|
+
preexistingSection,
|
|
902
|
+
androidSection,
|
|
710
903
|
"## Summary",
|
|
711
904
|
input.error || "Agent execution and configured verification completed successfully.",
|
|
712
905
|
"",
|
|
@@ -824,8 +1017,13 @@ function normalizeTimeout(value: unknown, config: ReturnType<typeof getConfig>):
|
|
|
824
1017
|
return timeout;
|
|
825
1018
|
}
|
|
826
1019
|
|
|
827
|
-
function failBeforeExecution(taskId: string, taskDir: string, message: string): TaskRunResult {
|
|
828
|
-
|
|
1020
|
+
function failBeforeExecution(taskId: string, taskDir: string, message: string, caughtError?: unknown): TaskRunResult {
|
|
1021
|
+
// Phase 9: Preserve PatchWardenError structured info in error.log
|
|
1022
|
+
const structuredError = caughtError ? errorPayload(caughtError) : { error: message };
|
|
1023
|
+
writeFileSync(join(taskDir, "error.log"), JSON.stringify({
|
|
1024
|
+
summary: message,
|
|
1025
|
+
...structuredError,
|
|
1026
|
+
}, null, 2), "utf-8");
|
|
829
1027
|
const current = readStatus(join(taskDir, "status.json"));
|
|
830
1028
|
const now = new Date().toISOString();
|
|
831
1029
|
const verify: VerifyReport = {
|
package/src/smoke-test.ts
CHANGED
|
@@ -592,7 +592,7 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
|
|
|
592
592
|
try {
|
|
593
593
|
process.env.PATCHWARDEN_TOOL_PROFILE = "full";
|
|
594
594
|
const fullTools = getToolDefs();
|
|
595
|
-
if (fullTools.length !==
|
|
595
|
+
if (fullTools.length !== 30) throw new Error(`Expected 30 full tools, got ${fullTools.length}`);
|
|
596
596
|
|
|
597
597
|
const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
|
|
598
598
|
const names = coreTools.map((tool) => tool.name);
|
|
@@ -1926,10 +1926,10 @@ reloadConfig();
|
|
|
1926
1926
|
|
|
1927
1927
|
let directSessionId = "";
|
|
1928
1928
|
|
|
1929
|
-
test("M1. chatgpt_core still has
|
|
1929
|
+
test("M1. chatgpt_core still has 17 tools", () => {
|
|
1930
1930
|
const tools = getToolDefs();
|
|
1931
1931
|
const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
|
|
1932
|
-
if (coreTools.length !==
|
|
1932
|
+
if (coreTools.length !== 17) throw new Error(`Expected 17, got ${coreTools.length}`);
|
|
1933
1933
|
if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
|
|
1934
1934
|
throw new Error("Tool names mismatch");
|
|
1935
1935
|
}
|
|
@@ -1942,10 +1942,10 @@ test("M2. chatgpt_direct disabled exposes only health_check", () => {
|
|
|
1942
1942
|
if (disabledTools[0].name !== "health_check") throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
|
|
1943
1943
|
});
|
|
1944
1944
|
|
|
1945
|
-
test("M3. chatgpt_direct enabled has
|
|
1945
|
+
test("M3. chatgpt_direct enabled has 10 tools", () => {
|
|
1946
1946
|
const tools = getToolDefs();
|
|
1947
1947
|
const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
|
|
1948
|
-
if (directTools.length !==
|
|
1948
|
+
if (directTools.length !== 10) throw new Error(`Expected 10, got ${directTools.length}`);
|
|
1949
1949
|
if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
|
|
1950
1950
|
throw new Error("Tool names mismatch");
|
|
1951
1951
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
diagnoseAndroidBuild,
|
|
8
|
+
type AndroidBuildDiagnosticReport,
|
|
9
|
+
} from "../../tools/androidDoctor.js";
|
|
10
|
+
|
|
11
|
+
const VALID_STATUSES = ["ok", "warn", "fail", "skip"] as const;
|
|
12
|
+
|
|
13
|
+
/** Restore an env var to its original value (deleted when undefined). */
|
|
14
|
+
function restoreEnv(key: string, value: string | undefined) {
|
|
15
|
+
if (value === undefined) delete process.env[key];
|
|
16
|
+
else process.env[key] = value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("diagnoseAndroidBuild", () => {
|
|
20
|
+
let tempDir: string;
|
|
21
|
+
let savedJavaHome: string | undefined;
|
|
22
|
+
let savedAndroidHome: string | undefined;
|
|
23
|
+
let savedAndroidSdkRoot: string | undefined;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-android-doctor-"));
|
|
27
|
+
savedJavaHome = process.env.JAVA_HOME;
|
|
28
|
+
savedAndroidHome = process.env.ANDROID_HOME;
|
|
29
|
+
savedAndroidSdkRoot = process.env.ANDROID_SDK_ROOT;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
34
|
+
restoreEnv("JAVA_HOME", savedJavaHome);
|
|
35
|
+
restoreEnv("ANDROID_HOME", savedAndroidHome);
|
|
36
|
+
restoreEnv("ANDROID_SDK_ROOT", savedAndroidSdkRoot);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns a skip result when no android_app directory exists", () => {
|
|
40
|
+
const result = diagnoseAndroidBuild(tempDir);
|
|
41
|
+
assert.equal(result.status, "skip");
|
|
42
|
+
assert.equal((result as { reason: string }).reason, "No android_app directory found");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns a structured diagnostic report when android_app exists", () => {
|
|
46
|
+
const androidApp = join(tempDir, "android_app");
|
|
47
|
+
mkdirSync(join(androidApp, "app"), { recursive: true });
|
|
48
|
+
mkdirSync(join(androidApp, "gradle", "wrapper"), { recursive: true });
|
|
49
|
+
writeFileSync(join(androidApp, "gradlew"), "#!/bin/sh\necho gradle\n");
|
|
50
|
+
writeFileSync(join(androidApp, "settings.gradle.kts"), 'pluginManagement { repositories { google() } }\n');
|
|
51
|
+
writeFileSync(join(androidApp, "app", "build.gradle.kts"), 'plugins { id("com.android.application") }\n');
|
|
52
|
+
writeFileSync(
|
|
53
|
+
join(androidApp, "gradle", "wrapper", "gradle-wrapper.properties"),
|
|
54
|
+
"distributionUrl=https://services.gradle.org/distributions/gradle-8.5-bin.zip\n",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Make SDK-related checks deterministic regardless of the host environment.
|
|
58
|
+
delete process.env.ANDROID_HOME;
|
|
59
|
+
delete process.env.ANDROID_SDK_ROOT;
|
|
60
|
+
|
|
61
|
+
const result = diagnoseAndroidBuild(tempDir);
|
|
62
|
+
assert.notEqual(result.status, "skip");
|
|
63
|
+
const report = result as AndroidBuildDiagnosticReport;
|
|
64
|
+
|
|
65
|
+
assert.equal(report.repo_path, tempDir);
|
|
66
|
+
assert.equal(report.android_app_path, join(tempDir, "android_app"));
|
|
67
|
+
assert.ok(Array.isArray(report.checks));
|
|
68
|
+
assert.equal(report.checks.length, 10);
|
|
69
|
+
|
|
70
|
+
for (const item of report.checks) {
|
|
71
|
+
assert.equal(typeof item.check, "string");
|
|
72
|
+
assert.ok(item.check.length > 0);
|
|
73
|
+
assert.ok((VALID_STATUSES as readonly string[]).includes(item.status));
|
|
74
|
+
assert.equal(typeof item.reason, "string");
|
|
75
|
+
assert.equal(typeof item.suggested_fix, "string");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Overall status follows the fail > warn > ok precedence.
|
|
79
|
+
const hasFail = report.checks.some((c) => c.status === "fail");
|
|
80
|
+
const hasWarn = report.checks.some((c) => c.status === "warn");
|
|
81
|
+
const expected = hasFail ? "fail" : hasWarn ? "warn" : "ok";
|
|
82
|
+
assert.equal(report.status, expected);
|
|
83
|
+
|
|
84
|
+
// The expected check names are all present.
|
|
85
|
+
const checkNames = report.checks.map((c) => c.check);
|
|
86
|
+
assert.ok(checkNames.includes("java -version"));
|
|
87
|
+
assert.ok(checkNames.includes("JAVA_HOME"));
|
|
88
|
+
assert.ok(checkNames.includes("ANDROID_HOME"));
|
|
89
|
+
assert.ok(checkNames.includes("ANDROID_SDK_ROOT"));
|
|
90
|
+
assert.ok(checkNames.includes("Android SDK platform"));
|
|
91
|
+
assert.ok(checkNames.includes("Android build-tools"));
|
|
92
|
+
assert.ok(checkNames.includes("Gradle wrapper"));
|
|
93
|
+
assert.ok(checkNames.includes("android_app/settings.gradle.kts"));
|
|
94
|
+
assert.ok(checkNames.includes("android_app/app/build.gradle.kts"));
|
|
95
|
+
assert.ok(checkNames.includes("APK output path"));
|
|
96
|
+
|
|
97
|
+
// Files we created should be detected as ok.
|
|
98
|
+
const gradleCheck = report.checks.find((c) => c.check === "Gradle wrapper");
|
|
99
|
+
assert.equal(gradleCheck?.status, "ok");
|
|
100
|
+
|
|
101
|
+
const settingsCheck = report.checks.find((c) => c.check === "android_app/settings.gradle.kts");
|
|
102
|
+
assert.equal(settingsCheck?.status, "ok");
|
|
103
|
+
|
|
104
|
+
const appBuildCheck = report.checks.find((c) => c.check === "android_app/app/build.gradle.kts");
|
|
105
|
+
assert.equal(appBuildCheck?.status, "ok");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reports a missing JAVA_HOME as warn or fail", () => {
|
|
109
|
+
const androidApp = join(tempDir, "android_app");
|
|
110
|
+
mkdirSync(androidApp, { recursive: true });
|
|
111
|
+
|
|
112
|
+
delete process.env.JAVA_HOME;
|
|
113
|
+
delete process.env.ANDROID_HOME;
|
|
114
|
+
delete process.env.ANDROID_SDK_ROOT;
|
|
115
|
+
|
|
116
|
+
const result = diagnoseAndroidBuild(tempDir);
|
|
117
|
+
const report = result as AndroidBuildDiagnosticReport;
|
|
118
|
+
const javaHomeCheck = report.checks.find((c) => c.check === "JAVA_HOME");
|
|
119
|
+
assert.ok(javaHomeCheck, "JAVA_HOME check should exist");
|
|
120
|
+
assert.ok(
|
|
121
|
+
javaHomeCheck!.status === "warn" || javaHomeCheck!.status === "fail",
|
|
122
|
+
`expected warn or fail, got ${javaHomeCheck!.status}`,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reports a missing ANDROID_HOME as fail with the SDK-missing message", () => {
|
|
127
|
+
const androidApp = join(tempDir, "android_app");
|
|
128
|
+
mkdirSync(androidApp, { recursive: true });
|
|
129
|
+
|
|
130
|
+
delete process.env.ANDROID_HOME;
|
|
131
|
+
delete process.env.ANDROID_SDK_ROOT;
|
|
132
|
+
|
|
133
|
+
const result = diagnoseAndroidBuild(tempDir);
|
|
134
|
+
const report = result as AndroidBuildDiagnosticReport;
|
|
135
|
+
const androidHomeCheck = report.checks.find((c) => c.check === "ANDROID_HOME");
|
|
136
|
+
assert.ok(androidHomeCheck, "ANDROID_HOME check should exist");
|
|
137
|
+
assert.equal(androidHomeCheck!.status, "fail");
|
|
138
|
+
assert.ok(
|
|
139
|
+
androidHomeCheck!.reason.includes(
|
|
140
|
+
"Android project exists, APK not built because Android SDK is missing.",
|
|
141
|
+
),
|
|
142
|
+
`reason should mention the SDK-missing sentence, got: ${androidHomeCheck!.reason}`,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// With the SDK missing, the overall status must be fail.
|
|
146
|
+
assert.equal(report.status, "fail");
|
|
147
|
+
|
|
148
|
+
// The APK output check should also carry the SDK-missing reason and be skipped.
|
|
149
|
+
const apkCheck = report.checks.find((c) => c.check === "APK output path");
|
|
150
|
+
assert.ok(apkCheck, "APK output path check should exist");
|
|
151
|
+
assert.equal(apkCheck!.status, "skip");
|
|
152
|
+
assert.ok(
|
|
153
|
+
apkCheck!.reason.includes(
|
|
154
|
+
"Android project exists, APK not built because Android SDK is missing.",
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|