diff --git a/packages/oc-docs/e2e/request-errors.spec.ts b/packages/oc-docs/e2e/request-errors.spec.ts new file mode 100644 index 0000000..38cdcfe --- /dev/null +++ b/packages/oc-docs/e2e/request-errors.spec.ts @@ -0,0 +1,70 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Locate an endpoint section by its h1 title (mirrors requests.spec.ts). + */ +function endpointSection(page: Page, name: string) { + return page.locator('.endpoint-section').filter({ + has: page.getByRole('heading', { name, level: 1, exact: true }), + }); +} + +/** Open the try-it playground for an endpoint. */ +async function openTryIt(page: Page, endpoint: string) { + await endpointSection(page, endpoint).getByRole('button', { name: 'Try' }).click(); +} + +test.describe('Try-it request failure messages (BRU-3408)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('.endpoint-section'); + }); + + test('cross-origin failure is classified as browser-blocked (CORS), inside the Response tab', async ({ page }) => { + // The sample collection targets localhost:8081 — a different origin than the + // docs page (127.0.0.1:3001). Aborting reproduces the opaque failure. + await page.route('**/api/users**', (route) => route.abort()); + + await openTryIt(page, 'get users'); + await page.getByRole('button', { name: 'SEND' }).click(); + + await expect(page.locator('.error-title')).toHaveText('Request blocked'); + await expect(page.locator('.error-message')).toContainText('usually CORS'); + await expect(page.locator('.error-message')).toContainText('Bruno desktop app'); + + // Banner renders inside the Response tab shell, not as a full-pane replacement. + await expect(page.getByRole('button', { name: 'Response', exact: true })).toBeVisible(); + }); + + test('same-origin failure is "unreachable" and never mentions CORS', async ({ page }) => { + // Point the request at the docs page's own origin, then fail it. + await page.route('**/same-origin-fail**', (route) => route.abort()); + + await openTryIt(page, 'get users'); + await page.getByPlaceholder('Enter request URL').fill('http://127.0.0.1:3001/same-origin-fail'); + await page.getByRole('button', { name: 'SEND' }).click(); + + await expect(page.locator('.error-title')).toHaveText("Couldn't reach the server"); + const message = (await page.locator('.error-message').innerText()).toLowerCase(); + expect(message).toContain('may be down'); + expect(message).not.toContain('cors'); + }); + + test('a 4xx response is NOT treated as a failure (renders normally)', async ({ page }) => { + await page.route('**/api/users**', (route) => + route.fulfill({ + status: 404, + headers: { 'access-control-allow-origin': '*' }, + contentType: 'application/json', + body: JSON.stringify({ error: 'not found' }), + }) + ); + + await openTryIt(page, 'get users'); + await page.getByRole('button', { name: 'SEND' }).click(); + + // No error banner; a 404 is a normal response (status shown). + await expect(page.locator('.error-title')).toHaveCount(0); + await expect(page.getByText('404 Not Found')).toBeVisible(); + }); +}); diff --git a/packages/oc-docs/src/components/PlaygroundDrawer/DrawerContent/Views/PlaygroundView/ResponsePane/ResponsePane.tsx b/packages/oc-docs/src/components/PlaygroundDrawer/DrawerContent/Views/PlaygroundView/ResponsePane/ResponsePane.tsx index 3297371..c01d0b5 100644 --- a/packages/oc-docs/src/components/PlaygroundDrawer/DrawerContent/Views/PlaygroundView/ResponsePane/ResponsePane.tsx +++ b/packages/oc-docs/src/components/PlaygroundDrawer/DrawerContent/Views/PlaygroundView/ResponsePane/ResponsePane.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import Tabs from '../../../../../../ui/Tabs/Tabs'; +import ErrorBanner from '../../../../../../ui/ErrorBanner/ErrorBanner'; import { ResponseBodyTab, ResponseHeadersTab, TestResultsTab } from '../../Common'; interface ResponsePaneProps { @@ -46,20 +47,19 @@ const ResponsePane: React.FC = ({ response, isLoading }) => { ); } - if (response.error) { - return ( -
-
-
-

Request Failed

-

{response.error}

-
-
-
- ); - } + // A failed request (no HTTP response) renders a danger banner inside the + // Response tab, keeping the same tab shell as a successful response. + const renderErrorBanner = () => ( +
+ +
+ ); - const renderResponseBody = () => ; + const renderResponseBody = () => + response.error ? renderErrorBanner() : ; const renderHeaders = () => ; const renderTestResults = () => ( = ({ response, isLoading }) => { tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} - rightElement={statusInfo} + rightElement={response.error ? undefined : statusInfo} /> ); diff --git a/packages/oc-docs/src/runner/RequestExecutor.ts b/packages/oc-docs/src/runner/RequestExecutor.ts index 4e52326..d938db6 100644 --- a/packages/oc-docs/src/runner/RequestExecutor.ts +++ b/packages/oc-docs/src/runner/RequestExecutor.ts @@ -1,14 +1,16 @@ import type { HttpRequest } from '@opencollection/types/requests/http'; import { RunRequestResponse } from './index'; import { getHttpMethod, getRequestUrl, getHttpHeaders, getHttpBody, getRequestAuth } from '../utils/schemaHelpers'; +import { classifyRequestError, DEFAULT_TIMEOUT_MS } from './classifyRequestError'; import stripJsonComments from 'strip-json-comments'; export class RequestExecutor { async executeRequest(request: HttpRequest, options: { timeout?: number } = {}): Promise { const startTime = Date.now(); + const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS; try { - const fetchOptions = await this.buildFetchOptions(request, options.timeout); + const fetchOptions = await this.buildFetchOptions(request, timeoutMs); const requestUrl = getRequestUrl(request); const response = await fetch(requestUrl, fetchOptions); const endTime = Date.now(); @@ -27,35 +29,22 @@ export class RequestExecutor { }; } catch (error) { const endTime = Date.now(); - let errorMessage = 'Request failed'; - let errorType = 'unknown'; - - if (error instanceof Error) { - errorMessage = error.message; - - // Categorize error types - if (error.name === 'AbortError' || error.message.includes('timeout')) { - errorType = 'timeout'; - errorMessage = `Request timed out after ${endTime - startTime}ms`; - } else if (error.name === 'TypeError' && error.message.includes('fetch')) { - errorType = 'cors'; - errorMessage = 'CORS error: Failed to fetch. The server either does not include the required CORS headers for this origin or cannot be reached.'; - } else if (error.message.includes('fetch')) { - errorType = 'network'; - } else if (error.message.includes('SSL') || error.message.includes('certificate')) { - errorType = 'ssl'; - } - } + const classified = classifyRequestError(error, { + timeoutMs, + requestUrl: getRequestUrl(request), + pageUrl: typeof window !== 'undefined' ? window.location.href : undefined + }); return { - error: errorMessage, - duration: endTime - startTime, - errorType + error: classified.message, + errorType: classified.type, + errorTitle: classified.title, + duration: endTime - startTime }; } } - private async buildFetchOptions(request: HttpRequest, timeout = 30000): Promise { + private async buildFetchOptions(request: HttpRequest, timeout = DEFAULT_TIMEOUT_MS): Promise { const method = getHttpMethod(request); const options: RequestInit = { method, diff --git a/packages/oc-docs/src/runner/classifyRequestError.spec.ts b/packages/oc-docs/src/runner/classifyRequestError.spec.ts new file mode 100644 index 0000000..e3be91f --- /dev/null +++ b/packages/oc-docs/src/runner/classifyRequestError.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { classifyRequestError } from './classifyRequestError'; + +// Helpers to build the exact error shapes the browser throws. +const timeoutError = () => { + const e = new Error('signal timed out'); + e.name = 'TimeoutError'; + return e; +}; + +const abortError = () => { + const e = new Error('The operation was aborted'); + e.name = 'AbortError'; + return e; +}; + +const failedToFetch = (message = 'Failed to fetch') => { + const e = new Error(message); + e.name = 'TypeError'; + return e; +}; + +describe('classifyRequestError', () => { + describe('timeout', () => { + it('classifies a TimeoutError from AbortSignal.timeout()', () => { + const result = classifyRequestError(timeoutError()); + expect(result.type).toBe('timeout'); + expect(result.title).toBe('Request timed out'); + expect(result.message).toBe("Request timed out. The server didn't respond in time."); + }); + + it('classifies a manual AbortError', () => { + expect(classifyRequestError(abortError()).type).toBe('timeout'); + }); + }); + + describe('mixed content (secure page, insecure URL)', () => { + it('classifies an https page calling an http URL', () => { + const result = classifyRequestError(failedToFetch(), { + pageUrl: 'https://docs.example.com/api.html', + requestUrl: 'http://api.example.com/users' + }); + expect(result.type).toBe('mixed-content'); + expect(result.message).toContain('secure (https)'); + expect(result.message).toContain('insecure (http)'); + }); + }); + + describe('browser-blocked (CORS — cross-origin or opened from a file)', () => { + it('classifies a cross-origin request (different host, same site)', () => { + const result = classifyRequestError(failedToFetch(), { + pageUrl: 'https://docs.example.com/', + requestUrl: 'https://api.example.com/users' + }); + expect(result.type).toBe('browser-blocked'); + expect(result.message).toContain('usually CORS'); + expect(result.message).toContain('Bruno desktop app'); + }); + + it('classifies a request from a docs page opened from a file (origin null)', () => { + const result = classifyRequestError(failedToFetch(), { + pageUrl: 'file:///Users/me/docs.html', + requestUrl: 'https://api.example.com/users' + }); + expect(result.type).toBe('browser-blocked'); + }); + + it('never suggests CORS for a same-origin failure', () => { + const result = classifyRequestError(failedToFetch(), { + pageUrl: 'https://app.example.com/docs', + requestUrl: 'https://app.example.com/api/users' + }); + expect(result.type).not.toBe('browser-blocked'); + expect(result.message.toLowerCase()).not.toContain('cors'); + }); + }); + + describe('server unreachable (same origin)', () => { + it('classifies a same-origin failure as unreachable', () => { + const result = classifyRequestError(failedToFetch(), { + pageUrl: 'https://app.example.com/docs', + requestUrl: 'https://app.example.com/api/users' + }); + expect(result.type).toBe('unreachable'); + expect(result.message).toBe("Couldn't reach the server. It may be down, or the URL may be wrong."); + }); + }); + + describe('anything else -> underlying message', () => { + it('surfaces the raw message for a non-network error', () => { + const result = classifyRequestError(new Error('Something weird happened')); + expect(result.type).toBe('unknown'); + expect(result.message).toBe('Something weird happened'); + }); + + it('falls through to the raw message when the request URL is unparseable', () => { + // e.g. an unresolved {{baseUrl}} leaves a relative path with no origin to compare. + const result = classifyRequestError(failedToFetch('Failed to fetch'), { + pageUrl: 'https://docs.example.com/', + requestUrl: '/users' + }); + expect(result.type).toBe('unknown'); + expect(result.message).toBe('Failed to fetch'); + }); + + it('falls back to a generic message for a non-Error throw', () => { + const result = classifyRequestError('boom'); + expect(result.type).toBe('unknown'); + expect(result.message).toBe('The request could not be completed.'); + }); + }); +}); diff --git a/packages/oc-docs/src/runner/classifyRequestError.ts b/packages/oc-docs/src/runner/classifyRequestError.ts new file mode 100644 index 0000000..8bfccfa --- /dev/null +++ b/packages/oc-docs/src/runner/classifyRequestError.ts @@ -0,0 +1,144 @@ +/** + * Classifies a failed try-it request (one that never produced an HTTP response) + * into a user-facing title + message. + * + * Browser `fetch` collapses CORS, DNS, connection-refused, offline, and TLS into + * one opaque failure with no detail — the real cause lives only in devtools and + * is unreadable by the page. So we classify from the REQUEST CONTEXT (did it time + * out, the page vs target scheme, and same-origin vs cross-origin / file) rather + * than from the error text. + * + * Origin (scheme + host + port), not site, is what decides cross-origin: a request + * from https://docs.example.com to https://api.example.com is cross-origin and + * triggers CORS even though both share the site example.com. Comparing by origin + * matches how the browser actually enforces CORS. + * + * NOTE: 4xx/5xx responses are NOT failures — they never reach this function. + */ + +export type RequestErrorType = + | 'timeout' + | 'mixed-content' + | 'browser-blocked' + | 'unreachable' + | 'unknown'; + +export interface ClassifiedRequestError { + type: RequestErrorType; + title: string; + message: string; +} + +interface ClassifyOptions { + /** The request timeout in milliseconds (reserved for future use / parity). */ + timeoutMs?: number; + /** The fully-resolved request URL passed to fetch (after variable interpolation). */ + requestUrl?: string; + /** The page URL the docs are running on, typically window.location.href. */ + pageUrl?: string; +} + +export const DEFAULT_TIMEOUT_MS = 30000; + +/** + * `AbortSignal.timeout()` rejects with a DOMException named `TimeoutError`. + * A manual `AbortController.abort()` rejects with one named `AbortError`. + * Older engines surface neither name cleanly, so we also sniff the message. + */ +const isTimeoutError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false; + if (error.name === 'TimeoutError' || error.name === 'AbortError') return true; + const msg = error.message.toLowerCase(); + return msg.includes('timed out') || msg.includes('timeout'); +}; + +/** + * The browser's opaque network failure. `fetch` throws a `TypeError` whose + * message is "Failed to fetch" (Chrome) / "NetworkError when attempting to + * fetch resource" (Firefox) / "Load failed" (Safari). + */ +const isOpaqueFetchFailure = (error: unknown): boolean => { + if (!(error instanceof Error)) return false; + if (error.name !== 'TypeError') return false; + const msg = error.message.toLowerCase(); + return msg.includes('fetch') || msg.includes('load failed') || msg.includes('networkerror'); +}; + +const safeParseUrl = (url?: string): URL | null => { + if (!url) return null; + try { + return new URL(url); + } catch { + return null; + } +}; + +const TIMEOUT: ClassifiedRequestError = { + type: 'timeout', + title: 'Request timed out', + message: "Request timed out. The server didn't respond in time." +}; + +const MIXED_CONTENT: ClassifiedRequestError = { + type: 'mixed-content', + title: 'Request blocked', + message: + 'Request blocked: this page is secure (https) but the URL is insecure (http). ' + + 'Use an https URL, or run it from the Bruno desktop app.' +}; + +const BROWSER_BLOCKED: ClassifiedRequestError = { + type: 'browser-blocked', + title: 'Request blocked', + message: + "Request blocked by your browser, usually CORS: the API didn't allow requests " + + 'from this page. Try it in the Bruno desktop app.' +}; + +const UNREACHABLE: ClassifiedRequestError = { + type: 'unreachable', + title: "Couldn't reach the server", + message: "Couldn't reach the server. It may be down, or the URL may be wrong." +}; + +export const classifyRequestError = ( + error: unknown, + options: ClassifyOptions = {} +): ClassifiedRequestError => { + if (isTimeoutError(error)) { + return TIMEOUT; + } + + if (isOpaqueFetchFailure(error)) { + const target = safeParseUrl(options.requestUrl); + const page = safeParseUrl(options.pageUrl); + + // Without a parseable target URL we can't reason about scheme/origin, so we + // fall through to the underlying message rather than guess. + if (target && page) { + // Secure page requesting an insecure URL -> the browser blocks it as mixed content. + if (page.protocol === 'https:' && target.protocol === 'http:') { + return MIXED_CONTENT; + } + + // Docs opened from a file have origin "null"; any cross-origin request is + // subject to CORS. Same-origin failures can't be CORS, so the server is + // unreachable (down, or wrong URL). + const openedFromFile = page.origin === 'null' || page.protocol === 'file:'; + if (openedFromFile || target.origin !== page.origin) { + return BROWSER_BLOCKED; + } + + return UNREACHABLE; + } + } + + // Anything else (or an unparseable URL): surface the underlying error message. + const rawMessage = + error instanceof Error && error.message ? error.message : 'The request could not be completed.'; + return { + type: 'unknown', + title: "Couldn't complete the request", + message: rawMessage + }; +}; diff --git a/packages/oc-docs/src/runner/index.ts b/packages/oc-docs/src/runner/index.ts index 347b183..2de5c8f 100644 --- a/packages/oc-docs/src/runner/index.ts +++ b/packages/oc-docs/src/runner/index.ts @@ -60,6 +60,7 @@ export interface RunRequestResponse { url?: string; error?: string; errorType?: string; + errorTitle?: string; isCancel?: boolean; requestId?: string; assertionResults?: AssertionResultsResponse; diff --git a/packages/oc-docs/src/ui/ErrorBanner/ErrorBanner.tsx b/packages/oc-docs/src/ui/ErrorBanner/ErrorBanner.tsx new file mode 100644 index 0000000..56ebbd1 --- /dev/null +++ b/packages/oc-docs/src/ui/ErrorBanner/ErrorBanner.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { StyledWrapper } from './StyledWrapper'; + +export interface ErrorBannerProps { + title: string; + message: string; + /** Optional one-line "what to do next" guidance shown beneath the message. */ + hint?: string; + className?: string; +} + +/** + * Danger banner for a failed try-it request: bold title, monospace message, + * and an optional next-step hint. Mirrors Bruno desktop's response error banner. + */ +const ErrorBanner: React.FC = ({ title, message, hint, className = '' }) => ( + +
{title}
+
{message}
+ {hint ?
{hint}
: null} +
+); + +export default ErrorBanner; diff --git a/packages/oc-docs/src/ui/ErrorBanner/StyledWrapper.tsx b/packages/oc-docs/src/ui/ErrorBanner/StyledWrapper.tsx new file mode 100644 index 0000000..9637efc --- /dev/null +++ b/packages/oc-docs/src/ui/ErrorBanner/StyledWrapper.tsx @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-left: 4px solid var(--error-color); + border-radius: 6px; + padding: 1rem; + + .error-title { + font-weight: 600; + color: var(--error-color); + margin-bottom: 0.5rem; + } + + .error-message { + font-family: monospace; + font-size: 0.8125rem; + line-height: 1.3rem; + white-space: pre-wrap; + word-break: break-all; + color: var(--text-primary); + } + + .error-hint { + margin-top: 0.75rem; + font-size: 0.8125rem; + line-height: 1.3rem; + white-space: pre-wrap; + color: var(--text-secondary); + } +`;