fix(cli): preserve capture failure diagnostics

This commit is contained in:
Miguel Ángel
2026-07-31 20:30:07 +00:00
parent ac9458888c
commit 9ae0007261
6 changed files with 64 additions and 8 deletions
@@ -309,6 +309,35 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("classifies non-provider pipeline failures without leaking the local path", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-caption-no-assets-"));
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-unused-key");
const warnings: string[] = [];
let outcome: VisionCaptionOutcome | undefined;
const captions = await captionImagesWithGemini(dir, () => {}, warnings, {
onOutcome: (value) => {
outcome = value;
},
});
expect(captions).toEqual({});
expect(warnings).toEqual(["OpenRouter captioning failed internally; captions omitted."]);
expect(warnings.join(" ")).not.toContain(dir);
expect(outcome).toEqual({
timedOutRequests: 0,
failedRequests: 0,
budgetExhausted: false,
internalError: true,
});
if (!outcome) throw new Error("Expected vision caption outcome");
expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({
status: "degraded",
reason: "internal-error",
});
});
it("skips captioning entirely when no provider key is present", async () => {
const dir = makeProjectWithImages();
dirs.push(dir);
+13 -5
View File
@@ -21,6 +21,8 @@ export interface VisionCaptionOutcome {
timedOutRequests: number;
failedRequests: number;
budgetExhausted: boolean;
/** Failure outside provider request handling (filesystem, module, or programming error). */
internalError?: boolean;
}
interface VisionCaptionOptions {
@@ -36,11 +38,14 @@ export function resolveVisionPhaseCompletion(
| { status: "completed" }
| {
status: "degraded";
reason: "budget-exhausted" | "request-timeout" | "provider-error";
reason: "budget-exhausted" | "request-timeout" | "provider-error" | "internal-error";
} {
if (outcome.budgetExhausted || remainingMs <= 0) {
return { status: "degraded", reason: "budget-exhausted" };
}
if (outcome.internalError) {
return { status: "degraded", reason: "internal-error" };
}
if (outcome.timedOutRequests > 0) {
return { status: "degraded", reason: "request-timeout" };
}
@@ -245,12 +250,15 @@ export async function captionImagesWithGemini(
let timedOutCount = 0;
let failedRequestCount = 0;
let budgetExhausted = false;
let internalError = false;
const reportOutcome = (): void => {
options.onOutcome?.({
const outcome: VisionCaptionOutcome = {
timedOutRequests: timedOutCount,
failedRequests: failedRequestCount,
budgetExhausted,
});
};
if (internalError) outcome.internalError = true;
options.onOutcome?.(outcome);
};
if (options.skipVision) {
reportOutcome();
@@ -546,8 +554,8 @@ export async function captionImagesWithGemini(
);
}
} catch {
failedRequestCount = Math.max(1, failedRequestCount);
warnings.push(`${providerName} captioning failed; captions omitted.`);
internalError = true;
warnings.push(`${providerName} captioning failed internally; captions omitted.`);
}
reportOutcome();
+4 -1
View File
@@ -267,7 +267,10 @@ export async function captureWebsite(
httpStatus: navigationResponse?.status() ?? null,
...pageContentCheck,
});
if (blockedReason) throw new Error(blockedReason);
if (blockedReason) {
phase("navigation", "degraded", "blocked");
throw new Error(blockedReason);
}
phase("navigation", "completed");
phase("core-extraction", "started");
@@ -23,6 +23,16 @@ describe("detectBlockedPage", () => {
hasChallengeElement: false,
},
},
{
label: "classic Cloudflare interstitial",
evidence: {
httpStatus: 503,
title: "Attention Required! | Cloudflare",
textLength: 120,
bodyChildCount: 3,
hasChallengeElement: false,
},
},
])("rejects a minimal protection page: $label", ({ evidence }) => {
expect(detectBlockedPage(evidence)).toMatch(/capture blocked/i);
});
@@ -16,7 +16,7 @@ export function detectBlockedPage(evidence: PageLoadEvidence): string | undefine
const hasBlockedStatus =
evidence.httpStatus === 401 || evidence.httpStatus === 403 || evidence.httpStatus === 429;
const hasBlockedTitle =
/^(?:(?:error\s*)?(?:401|403|429)(?:\s*(?:[-:—]\s*)?(?:forbidden|unauthorized|access denied|too many requests))?|forbidden|access denied|attention required|just a moment(?:\.{3})?)(?:\s*[|—-]\s*(?:cloudflare|sucuri website firewall))?$/i.test(
/^(?:(?:error\s*)?(?:401|403|429)(?:\s*(?:[-:—]\s*)?(?:forbidden|unauthorized|access denied|too many requests))?|forbidden|access denied|attention required!?|just a moment(?:\.{3})?)(?:\s*[|—-]\s*(?:cloudflare|sucuri website firewall))?$/i.test(
evidence.title.trim(),
);
+7 -1
View File
@@ -26,7 +26,13 @@ export interface CapturePhaseProgress {
status: "started" | "completed" | "degraded";
/** Null before the post-navigation budget begins. */
remainingMs: number | null;
reason?: "budget-exhausted" | "disabled" | "request-timeout" | "provider-error";
reason?:
| "budget-exhausted"
| "disabled"
| "request-timeout"
| "provider-error"
| "internal-error"
| "blocked";
}
export interface CaptureOptions {