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(); 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 () => { it("skips captioning entirely when no provider key is present", async () => {
const dir = makeProjectWithImages(); const dir = makeProjectWithImages();
dirs.push(dir); dirs.push(dir);
+13 -5
View File
@@ -21,6 +21,8 @@ export interface VisionCaptionOutcome {
timedOutRequests: number; timedOutRequests: number;
failedRequests: number; failedRequests: number;
budgetExhausted: boolean; budgetExhausted: boolean;
/** Failure outside provider request handling (filesystem, module, or programming error). */
internalError?: boolean;
} }
interface VisionCaptionOptions { interface VisionCaptionOptions {
@@ -36,11 +38,14 @@ export function resolveVisionPhaseCompletion(
| { status: "completed" } | { status: "completed" }
| { | {
status: "degraded"; status: "degraded";
reason: "budget-exhausted" | "request-timeout" | "provider-error"; reason: "budget-exhausted" | "request-timeout" | "provider-error" | "internal-error";
} { } {
if (outcome.budgetExhausted || remainingMs <= 0) { if (outcome.budgetExhausted || remainingMs <= 0) {
return { status: "degraded", reason: "budget-exhausted" }; return { status: "degraded", reason: "budget-exhausted" };
} }
if (outcome.internalError) {
return { status: "degraded", reason: "internal-error" };
}
if (outcome.timedOutRequests > 0) { if (outcome.timedOutRequests > 0) {
return { status: "degraded", reason: "request-timeout" }; return { status: "degraded", reason: "request-timeout" };
} }
@@ -245,12 +250,15 @@ export async function captionImagesWithGemini(
let timedOutCount = 0; let timedOutCount = 0;
let failedRequestCount = 0; let failedRequestCount = 0;
let budgetExhausted = false; let budgetExhausted = false;
let internalError = false;
const reportOutcome = (): void => { const reportOutcome = (): void => {
options.onOutcome?.({ const outcome: VisionCaptionOutcome = {
timedOutRequests: timedOutCount, timedOutRequests: timedOutCount,
failedRequests: failedRequestCount, failedRequests: failedRequestCount,
budgetExhausted, budgetExhausted,
}); };
if (internalError) outcome.internalError = true;
options.onOutcome?.(outcome);
}; };
if (options.skipVision) { if (options.skipVision) {
reportOutcome(); reportOutcome();
@@ -546,8 +554,8 @@ export async function captionImagesWithGemini(
); );
} }
} catch { } catch {
failedRequestCount = Math.max(1, failedRequestCount); internalError = true;
warnings.push(`${providerName} captioning failed; captions omitted.`); warnings.push(`${providerName} captioning failed internally; captions omitted.`);
} }
reportOutcome(); reportOutcome();
+4 -1
View File
@@ -267,7 +267,10 @@ export async function captureWebsite(
httpStatus: navigationResponse?.status() ?? null, httpStatus: navigationResponse?.status() ?? null,
...pageContentCheck, ...pageContentCheck,
}); });
if (blockedReason) throw new Error(blockedReason); if (blockedReason) {
phase("navigation", "degraded", "blocked");
throw new Error(blockedReason);
}
phase("navigation", "completed"); phase("navigation", "completed");
phase("core-extraction", "started"); phase("core-extraction", "started");
@@ -23,6 +23,16 @@ describe("detectBlockedPage", () => {
hasChallengeElement: false, 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 }) => { ])("rejects a minimal protection page: $label", ({ evidence }) => {
expect(detectBlockedPage(evidence)).toMatch(/capture blocked/i); expect(detectBlockedPage(evidence)).toMatch(/capture blocked/i);
}); });
@@ -16,7 +16,7 @@ export function detectBlockedPage(evidence: PageLoadEvidence): string | undefine
const hasBlockedStatus = const hasBlockedStatus =
evidence.httpStatus === 401 || evidence.httpStatus === 403 || evidence.httpStatus === 429; evidence.httpStatus === 401 || evidence.httpStatus === 403 || evidence.httpStatus === 429;
const hasBlockedTitle = 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(), evidence.title.trim(),
); );
+7 -1
View File
@@ -26,7 +26,13 @@ export interface CapturePhaseProgress {
status: "started" | "completed" | "degraded"; status: "started" | "completed" | "degraded";
/** Null before the post-navigation budget begins. */ /** Null before the post-navigation budget begins. */
remainingMs: number | null; 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 { export interface CaptureOptions {