diff --git a/packages/happy-dom/src/browser/types/IBrowserSettings.ts b/packages/happy-dom/src/browser/types/IBrowserSettings.ts index 8bc79506..4d4b8d66 100644 --- a/packages/happy-dom/src/browser/types/IBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IBrowserSettings.ts @@ -1,6 +1,6 @@ import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js'; import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js'; -import IAsyncRequestInterceptor from '../../fetch/types/IAsyncRequestInterceptor.js'; +import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js'; /** * Browser settings. @@ -42,9 +42,7 @@ export default interface IBrowserSettings { */ disableSameOriginPolicy: boolean; - intercept?: { - asyncFetch?: IAsyncRequestInterceptor; - }; + interceptor?: IFetchInterceptor; }; /** diff --git a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts index 122ae510..5592028d 100644 --- a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts @@ -1,6 +1,6 @@ import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js'; import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js'; -import IAsyncRequestInterceptor from '../../fetch/types/IAsyncRequestInterceptor.js'; +import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js'; export default interface IOptionalBrowserSettings { /** Disables JavaScript evaluation. */ @@ -36,9 +36,7 @@ export default interface IOptionalBrowserSettings { */ disableSameOriginPolicy?: boolean; - intercept?: { - asyncFetch?: IAsyncRequestInterceptor; - }; + interceptor?: IFetchInterceptor; }; /** diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index 77e99526..90b7bbfb 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -26,7 +26,7 @@ import FetchResponseHeaderUtility from './utilities/FetchResponseHeaderUtility.j import FetchHTTPSCertificate from './certificate/FetchHTTPSCertificate.js'; import { Buffer } from 'buffer'; import FetchBodyUtility from './utilities/FetchBodyUtility.js'; -import IAsyncRequestInterceptor from './types/IAsyncRequestInterceptor.js'; +import IFetchInterceptor from './types/IFetchInterceptor.js'; const LAST_CHUNK = Buffer.from('0\r\n\r\n'); @@ -51,7 +51,7 @@ export default class Fetch { private nodeResponse: IncomingMessage | null = null; private response: Response | null = null; private responseHeaders: Headers | null = null; - private requestInterceptor?: IAsyncRequestInterceptor; + private interceptor?: IFetchInterceptor; private request: Request; private redirectCount = 0; private disableCache: boolean; @@ -101,8 +101,7 @@ export default class Fetch { options.disableSameOriginPolicy ?? this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ?? false; - this.requestInterceptor = - this.#browserFrame.page.context.browser.settings.fetch.intercept?.asyncFetch; + this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor; } /** @@ -112,11 +111,14 @@ export default class Fetch { */ public async send(): Promise { FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request); - const beforeSendResponse = this.requestInterceptor?.beforeSend - ? await this.requestInterceptor?.beforeSend(this.request, this.#window) + const beforeRequestResponse = this.interceptor?.beforeAsyncRequest + ? await this.interceptor?.beforeAsyncRequest({ + request: this.request, + window: this.#window + }) : undefined; - if (beforeSendResponse instanceof Response) { - return beforeSendResponse; + if (beforeRequestResponse instanceof Response) { + return beforeRequestResponse; } FetchRequestValidationUtility.validateSchema(this.request); diff --git a/packages/happy-dom/src/fetch/SyncFetch.ts b/packages/happy-dom/src/fetch/SyncFetch.ts index e04c0c15..123f9835 100644 --- a/packages/happy-dom/src/fetch/SyncFetch.ts +++ b/packages/happy-dom/src/fetch/SyncFetch.ts @@ -20,6 +20,7 @@ import Zlib from 'zlib'; import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtility.js'; import FetchCORSUtility from './utilities/FetchCORSUtility.js'; import Fetch from './Fetch.js'; +import IFetchInterceptor from "./types/IFetchInterceptor.js"; interface ISyncHTTPResponse { error: string; @@ -39,6 +40,7 @@ export default class SyncFetch { private redirectCount = 0; private disableCache: boolean; private disableSameOriginPolicy: boolean; + private interceptor?: IFetchInterceptor; #browserFrame: IBrowserFrame; #window: BrowserWindow; #unfilteredHeaders: Headers | null = null; @@ -84,6 +86,7 @@ export default class SyncFetch { options.disableSameOriginPolicy ?? this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ?? false; + this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor; } /** @@ -93,6 +96,15 @@ export default class SyncFetch { */ public send(): ISyncResponse { FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request); + const beforeRequestResponse = this.interceptor?.beforeSyncRequest + ? this.interceptor?.beforeSyncRequest({ + request: this.request, + window: this.#window + }) + : undefined; + if (typeof beforeRequestResponse === 'object') { + return beforeRequestResponse; + } FetchRequestValidationUtility.validateSchema(this.request); if (this.request.signal.aborted) { diff --git a/packages/happy-dom/src/fetch/types/IAsyncRequestInterceptor.ts b/packages/happy-dom/src/fetch/types/IAsyncRequestInterceptor.ts deleted file mode 100644 index 5bb3f9b8..00000000 --- a/packages/happy-dom/src/fetch/types/IAsyncRequestInterceptor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import Request from '../Request.js'; -import BrowserWindow from '../../window/BrowserWindow.js'; -import Response from '../Response.js'; - -export default interface IAsyncRequestInterceptor { - /** - * Hook dispatched before sending out async fetches. - * It can be used for modifying the request, providing a response without making a request or for logging. - * - * @param request The request about to be sent out. - * @param window The window from where the request originates. - * - * @returns Promise that can resolve to a response to be used instead of sending out the response. - */ - beforeSend?: (request: Request, window: BrowserWindow) => Promise; -} diff --git a/packages/happy-dom/src/fetch/types/IFetchInterceptor.ts b/packages/happy-dom/src/fetch/types/IFetchInterceptor.ts new file mode 100644 index 00000000..a70e8fe9 --- /dev/null +++ b/packages/happy-dom/src/fetch/types/IFetchInterceptor.ts @@ -0,0 +1,60 @@ +import Request from '../Request.js'; +import BrowserWindow from '../../window/BrowserWindow.js'; +import Response from '../Response.js'; +import ISyncResponse from './ISyncResponse.js'; + +export default interface IFetchInterceptor { + /** + * Hook dispatched before making an async request. + * It can be used for modifying the request, providing a response without making a request or for logging. + * + * @param context Contains the request and the window from where the request was made. + * + * @returns Promise that can resolve to a response to be used instead of sending out the response. + */ + beforeAsyncRequest?: (context: { + request: Request; + window: BrowserWindow; + }) => Promise; + + /** + * Hook dispatched before making an sync request. + * It can be used for modifying the request, providing a response without making a request or for logging. + * + * @param context Contains the request and the window from where the request was made. + * + * @returns Promise that can resolve to a response to be used instead of sending out the response. + */ + beforeSyncRequest?: (context: { + request: Request; + window: BrowserWindow; + }) => ISyncResponse | void; + + /** + * Hook dispatched after receiving an async response. + * It can be used for modifying or replacing the response and logging. + * + * @param context Contains the request, response and the window from where the request was made. + * + * @returns Promise that can resolve to a response to be used instead of sending out the response. + */ + afterAsyncResponse?: (context: { + request: Request; + response: Response; + window: BrowserWindow; + }) => Promise; + + /** + * Hook dispatched after receiving a sync response. + * It can be used for modifying or replacing the response and logging + * + * @param context Contains the request, response and the window from where the request was made. + * + * @returns Promise that can resolve to a response to be used instead of sending out the response. + */ + afterSyncResponse?: (context: { + request: Request; + response: Response; + window: BrowserWindow; + }) => Promise; +} diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts index e852c5a8..53395f4b 100644 --- a/packages/happy-dom/src/index.ts +++ b/packages/happy-dom/src/index.ts @@ -57,7 +57,7 @@ import AbortSignal from './fetch/AbortSignal.js'; import Headers from './fetch/Headers.js'; import Request from './fetch/Request.js'; import Response from './fetch/Response.js'; -import IAsyncRequestInterceptor from './fetch/types/IAsyncRequestInterceptor.js'; +import IFetchInterceptor from './fetch/types/IFetchInterceptor.js'; import Blob from './file/Blob.js'; import File from './file/File.js'; import FileReader from './file/FileReader.js'; @@ -207,7 +207,7 @@ import type ITouchEventInit from './event/events/ITouchEventInit.js'; import type IWheelEventInit from './event/events/IWheelEventInit.js'; export type { - IAsyncRequestInterceptor, + IFetchInterceptor, IAnimationEventInit, IBrowser, IBrowserContext, diff --git a/packages/happy-dom/test/fetch/Fetch.test.ts b/packages/happy-dom/test/fetch/Fetch.test.ts index fdaaca7b..7867cb71 100644 --- a/packages/happy-dom/test/fetch/Fetch.test.ts +++ b/packages/happy-dom/test/fetch/Fetch.test.ts @@ -1364,18 +1364,15 @@ describe('Fetch', () => { ); }); - it('Uses intercepted response when beforeSend returns a Response', async () => { + it('Should use intercepted response and not send the request when beforeAsyncRequest returns a Response', async () => { const originURL = 'https://localhost:8080/'; - const responseText = 'some text'; const window = new Window({ url: originURL, settings: { fetch: { - intercept: { - asyncFetch: { - async beforeSend(_request, window) { - return new window.Response('intercepted text'); - } + interceptor: { + async beforeAsyncRequest({ window }) { + return new window.Response('intercepted text'); } } } @@ -1385,31 +1382,7 @@ describe('Fetch', () => { mockModule('https', { request: () => { - return { - end: () => {}, - on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => { - if (event === 'response') { - async function* generate(): AsyncGenerator { - yield responseText; - } - - const response = Stream.Readable.from(generate()); - - response.statusCode = 200; - response.statusMessage = 'OK'; - response.headers = {}; - response.rawHeaders = [ - 'content-type', - 'text/html', - 'content-length', - String(responseText.length) - ]; - - callback(response); - } - }, - setTimeout: () => {} - }; + fail('No request should be made when beforeAsyncRequest returns a Response'); } }); @@ -1418,18 +1391,16 @@ describe('Fetch', () => { expect(await response.text()).toBe('intercepted text'); }); - it('Makes a normal request when before does not return a Response', async () => { + it('Should make a normal request when before does not return a Response', async () => { const originURL = 'https://localhost:8080/'; const responseText = 'some text'; const window = new Window({ url: originURL, settings: { fetch: { - intercept: { - asyncFetch: { - async beforeSend() { - return undefined; - } + interceptor: { + async beforeAsyncRequest() { + return undefined; } } } diff --git a/packages/happy-dom/test/fetch/SyncFetch.test.ts b/packages/happy-dom/test/fetch/SyncFetch.test.ts index 6b781a56..81612039 100644 --- a/packages/happy-dom/test/fetch/SyncFetch.test.ts +++ b/packages/happy-dom/test/fetch/SyncFetch.test.ts @@ -13,6 +13,7 @@ import SyncFetch from '../../src/fetch/SyncFetch.js'; import IBrowserFrame from '../../src/browser/types/IBrowserFrame.js'; import Browser from '../../src/browser/Browser.js'; import '../types.d.js'; +import { fail } from "node:assert"; const PLATFORM = 'X11; ' + @@ -191,6 +192,143 @@ describe('SyncFetch', () => { }); }); + it('Should use intercepted response and not send the request when beforeSyncRequest returns a response', () => { + const url = 'https://localhost:8080/some/path'; + const browser = new Browser({ + settings: { + fetch: { + interceptor: { + beforeSyncRequest() { + return { + status: 200, + statusText: 'OK', + ok: true, + url, + redirected: false, + headers: new Headers(), + body: Buffer.from("intercepted text") + }; + } + } + } + } + }); + const page = browser.newPage(); + + const browserFrame = page.mainFrame; + const window = page.mainFrame.window; + browserFrame.url = 'https://localhost:8080/'; + + mockModule('child_process', { + execFileSync: () => { + fail("No request should be made when beforeSyncRequest returns a response"); + } + }); + + const response = new SyncFetch({ + browserFrame, + window, + url, + init: { + method: 'GET' + } + }).send(); + + expect(response.body.toString()).toBe("intercepted text"); + }); + + it('Should perform the http request normally when the beforeSyncRequest does not return a response', () => { + const url = 'https://localhost:8080/some/path'; + const browser = new Browser({ + settings: { + fetch: { + interceptor: { + beforeSyncRequest() { + return undefined; + } + } + } + } + }); + const page = browser.newPage(); + + const browserFrame = page.mainFrame; + const window = page.mainFrame.window; + browserFrame.url = 'https://localhost:8080/'; + + const responseText = 'some text'; + + mockModule('child_process', { + execFileSync: ( + command: string, + args: string[], + options: { encoding: string; maxBuffer: number } + ) => { + expect(command).toEqual(process.argv[0]); + expect(args[0]).toBe('-e'); + expect(args[1]).toBe( + SyncFetchScriptBuilder.getScript({ + url: new URL(url), + method: 'GET', + headers: { + Accept: '*/*', + Connection: 'close', + Referer: 'https://localhost:8080/', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br' + }, + body: null + }) + ); + expect(options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 + }); + return JSON.stringify({ + error: null, + incomingMessage: { + statusCode: 200, + statusMessage: 'OK', + rawHeaders: [ + 'content-type', + 'text/html', + 'content-length', + String(responseText.length) + ], + data: Buffer.from(responseText).toString('base64') + } + }); + } + }); + + const response = new SyncFetch({ + browserFrame, + window, + url, + init: { + method: 'GET' + } + }).send(); + + expect(response.url).toBe(url); + expect(response.ok).toBe(true); + expect(response.redirected).toBe(false); + expect(response.status).toBe(200); + expect(response.statusText).toBe('OK'); + expect(response.body.toString()).toBe(responseText); + expect(response.headers instanceof Headers).toBe(true); + + const headers = {}; + for (const [key, value] of response.headers) { + headers[key] = value; + } + + expect(headers).toEqual({ + 'content-type': 'text/html', + 'content-length': String(responseText.length) + }); + }); + it('Performs a request with a relative URL and adds the "Referer" header set to the window location.', () => { const baseUrl = 'https://localhost:8080/base/';