From f0c5db6ea266a5a96907e66ef762e0f6dd85d623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 29 Oct 2026 04:08:34 +0000 Subject: [PATCH] fix(cli): capture names the network's proxy when it refuses the connection to the site (#6706) * fix(cli): capture names the network's proxy when it refuses the connection to the site * fix(cli): capture words a refused tunnel and an unreachable proxy apart, and echoes no raw address * fix(cli): shorten the proxy failure comment --- .../src/capture/navigateForCapture.test.ts | 55 +++++++++++++++++++ .../cli/src/capture/navigateForCapture.ts | 35 ++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/capture/navigateForCapture.test.ts b/packages/cli/src/capture/navigateForCapture.test.ts index c9b6abf2c9..249d5e494f 100644 --- a/packages/cli/src/capture/navigateForCapture.test.ts +++ b/packages/cli/src/capture/navigateForCapture.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { TimeoutError, type Page } from "puppeteer-core"; import { gotoWhenSent, + explainProxyRefusal, isNavigationTimeoutError, navigateForCapture, networkIdleAttemptTimeoutMs, @@ -465,3 +466,57 @@ describe("settleAcrossNavigation", () => { expect(waitForNetworkIdle).not.toHaveBeenCalled(); }); }); + +describe("a proxy that was in the way", () => { + const failWith = async (message: string, url = "https://shop.example.com/") => { + const goto = vi.fn().mockRejectedValue(new Error(message)); + return (await navigateForCapture({ goto }, url, 120_000).then( + () => undefined, + (err: unknown) => err, + )) as Error; + }; + + it("that refused the tunnel to the site is named, with the code kept", async () => { + const failure = await failWith( + "net::ERR_TUNNEL_CONNECTION_FAILED at https://shop.example.com/x?q=1", + ); + expect(failure.message).toBe( + "Your network's proxy refused the connection to shop.example.com. Ask whoever runs it to allow shop.example.com. (net::ERR_TUNNEL_CONNECTION_FAILED)", + ); + expect((failure.cause as Error).message).toContain("/x?q=1"); + }); + + it("that could not be reached itself is not asked to allow the site", async () => { + const failure = await failWith("net::ERR_PROXY_CONNECTION_FAILED at https://shop.example.com/"); + expect(failure.message).toBe( + "Could not reach your network's proxy. Check the proxy address and that the proxy is running, or ask whoever runs it. (net::ERR_PROXY_CONNECTION_FAILED)", + ); + expect(failure.message).not.toContain("allow"); + }); + + it("names no raw address when the site's cannot be read", () => { + const failure = explainProxyRefusal( + new Error("net::ERR_TUNNEL_CONNECTION_FAILED"), + "not a url at all", + ) as Error; + expect(failure.message).toContain("connection to the site"); + expect(failure.message).not.toContain("not a url"); + }); + + it("is named after a networkidle2 timeout falls back too", async () => { + const goto = vi + .fn() + .mockRejectedValueOnce(new TimeoutError("Navigation timeout of 30000 ms exceeded")) + .mockRejectedValueOnce(new Error("net::ERR_TUNNEL_CONNECTION_FAILED at https://a.example/")); + await expect(navigateForCapture({ goto }, "https://a.example/", 120_000)).rejects.toThrow( + /proxy refused the connection to a\.example/, + ); + }); + + it("leaves every other failure as it was", async () => { + const name = new Error("net::ERR_NAME_NOT_RESOLVED at https://nope.example/"); + expect(explainProxyRefusal(name, "https://nope.example/")).toBe(name); + const goto = vi.fn().mockRejectedValue(name); + await expect(navigateForCapture({ goto }, "https://nope.example/", 120_000)).rejects.toBe(name); + }); +}); diff --git a/packages/cli/src/capture/navigateForCapture.ts b/packages/cli/src/capture/navigateForCapture.ts index 4d61d10c4b..0e9c1b60c5 100644 --- a/packages/cli/src/capture/navigateForCapture.ts +++ b/packages/cli/src/capture/navigateForCapture.ts @@ -9,6 +9,30 @@ export { isNavigationTimeoutError }; export const NETWORK_IDLE_ATTEMPT_MS = 30_000; +/** Chromium's words for a proxy in the way: the tunnel to the site was refused or not made, or the proxy itself + * could not be reached. */ +const PROXY_FAILURES = { + "net::ERR_TUNNEL_CONNECTION_FAILED": (host: string) => + `Your network's proxy refused the connection to ${host}. Ask whoever runs it to allow ${host}.`, + "net::ERR_PROXY_CONNECTION_FAILED": () => + "Could not reach your network's proxy. Check the proxy address and that the proxy is running, or ask whoever runs it.", +} as const; +const PROXY_FAILURE = new RegExp(Object.keys(PROXY_FAILURES).join("|")); + +/** The error as the person should read it when their network's proxy was the problem; any other error as is. */ +export function explainProxyRefusal(err: unknown, url: string): unknown { + const said = err instanceof Error ? err.message : String(err); + const code = PROXY_FAILURE.exec(said)?.[0] as keyof typeof PROXY_FAILURES | undefined; + if (!code) return err; + let host = "the site"; + try { + host = new URL(url).host; + } catch { + // expected: an unparseable address is not echoed + } + return new Error(`${PROXY_FAILURES[code](host)} (${code})`, { cause: err }); +} + export type CaptureGotoWaitUntil = "networkidle2" | "domcontentloaded"; export interface CaptureGotoOptions { @@ -50,15 +74,16 @@ export async function navigateForCapture( }; } catch (err) { if (!isNavigationTimeoutError(err) || networkIdleTimeoutMs >= totalTimeoutMs) { - throw err; + throw explainProxyRefusal(err, url); } } const fallbackTimeoutMs = totalTimeoutMs - networkIdleTimeoutMs; - const response = await page.goto(url, { - waitUntil: "domcontentloaded", - timeout: fallbackTimeoutMs, - }); + const response = await page + .goto(url, { waitUntil: "domcontentloaded", timeout: fallbackTimeoutMs }) + .catch((err: unknown) => { + throw explainProxyRefusal(err, url); + }); return { response, waitUntil: "domcontentloaded",