diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index 605691407..123b13a1e 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -40,7 +40,7 @@ const LAST_CHUNK = Buffer.from('0\r\n\r\n'); */ export default class Fetch { private reject: (reason: Error) => void | null = null; - private resolve: (value: Response | Promise) => void | null = null; + private resolve: (value: Response | Promise) => Promise = null; private listeners = { onSignalAbort: this.onSignalAbort.bind(this) }; @@ -134,7 +134,12 @@ export default class Fetch { this.response = new this.#window.Response(result.buffer, { headers: { 'Content-Type': result.type } }); - return this.response; + const interceptedResponse = await this.interceptor?.afterAsyncResponse({ + window: this.#window, + response: this.response, + request: this.request + }); + return interceptedResponse instanceof Response ? interceptedResponse : this.response; } // Security check for "https" to "http" requests. @@ -377,9 +382,9 @@ export default class Fetch { throw new this.#window.Error('Fetch already sent.'); } - this.resolve = (response: Response | Promise): void => { + this.resolve = async (response: Response | Promise): Promise => { // We can end up here when closing down the browser frame and there is an ongoing request. - // Therefore we need to check if browserFrame.page.context is still available. + // Therefore, we need to check if browserFrame.page.context is still available. if ( !this.disableCache && response instanceof Response && @@ -395,7 +400,12 @@ export default class Fetch { }); } this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); - resolve(response); + const interceptedResponse = await this.interceptor?.afterAsyncResponse({ + window: this.#window, + response: await response, + request: this.request + }); + resolve(interceptedResponse instanceof Response ? interceptedResponse : response); }; this.reject = (error: Error): void => { this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); diff --git a/packages/happy-dom/src/fetch/SyncFetch.ts b/packages/happy-dom/src/fetch/SyncFetch.ts index fb5a557a0..8ee041276 100644 --- a/packages/happy-dom/src/fetch/SyncFetch.ts +++ b/packages/happy-dom/src/fetch/SyncFetch.ts @@ -116,7 +116,7 @@ export default class SyncFetch { if (this.request[PropertySymbol.url].protocol === 'data:') { const result = DataURIParser.parse(this.request.url); - return { + const response = { status: 200, statusText: 'OK', ok: true, @@ -125,6 +125,12 @@ export default class SyncFetch { headers: new Headers({ 'Content-Type': result.type }), body: result.buffer }; + const interceptedResponse = this.interceptor.afterSyncResponse({ + window: this.#window, + response, + request: this.request + }); + return typeof interceptedResponse === 'object' ? interceptedResponse : response; } // Security check for "https" to "http" requests. @@ -428,7 +434,12 @@ export default class SyncFetch { }); } - return redirectedResponse; + const interceptedResponse = this.interceptor.afterSyncResponse({ + window: this.#window, + response: redirectedResponse, + request: this.request + }); + return typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse; } /** diff --git a/packages/happy-dom/test/fetch/Fetch.test.ts b/packages/happy-dom/test/fetch/Fetch.test.ts index 7867cb714..29dbaaecb 100644 --- a/packages/happy-dom/test/fetch/Fetch.test.ts +++ b/packages/happy-dom/test/fetch/Fetch.test.ts @@ -1443,6 +1443,112 @@ describe('Fetch', () => { expect(await response.text()).toBe('some text'); }); + it('Should use intercepted response when given', async () => { + const originURL = 'https://localhost:8080/'; + const responseText = 'some text'; + const window = new Window({ + url: originURL, + settings: { + fetch: { + interceptor: { + async afterAsyncResponse({ window }) { + return new window.Response('intercepted text'); + } + } + } + } + }); + const url = 'https://localhost:8080/some/path'; + + 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: () => {} + }; + } + }); + + const response = await window.fetch(url); + + expect(await response.text()).toBe('intercepted text'); + }); + + it('Should use original response when no response is given', async () => { + const originURL = 'https://localhost:8080/'; + const responseText = 'some text'; + const window = new Window({ + url: originURL, + settings: { + fetch: { + interceptor: { + async afterAsyncResponse({ response }) { + response.headers.set('x-test', 'yes'); + return undefined; + } + } + } + } + }); + const url = 'https://localhost:8080/some/path'; + + 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: () => {} + }; + } + }); + + const response = await window.fetch(url); + + expect(await response.text()).toBe(responseText); + expect(response.headers.get('x-test')).toBe('yes'); + }); + it('Forwards "cookie", "authorization" or "www-authenticate" if request credentials are set to "same-origin" and the request goes to the same origin as the document.', async () => { const originURL = 'https://localhost:8080'; const window = new Window({ url: originURL }); diff --git a/packages/happy-dom/test/fetch/SyncFetch.test.ts b/packages/happy-dom/test/fetch/SyncFetch.test.ts index ad3604469..39f6e9b83 100644 --- a/packages/happy-dom/test/fetch/SyncFetch.test.ts +++ b/packages/happy-dom/test/fetch/SyncFetch.test.ts @@ -329,6 +329,199 @@ describe('SyncFetch', () => { }); }); + it('Should return the intercepted response when afterSyncRequest returns a response', () => { + const url = 'https://localhost:8080/some/path'; + const browser = new Browser({ + settings: { + fetch: { + interceptor: { + afterSyncResponse() { + return { + status: 200, + statusText: 'OK', + ok: true, + url, + redirected: false, + headers: new Headers({ 'Content-Type': 'text/plain' }), + body: Buffer.from('intercepted text') + }; + } + } + } + } + }); + 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/plain', + '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('intercepted text'); + 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/plain' + }); + }); + + it('Should return the original response when afterSyncRequest does not return a response', () => { + const url = 'https://localhost:8080/some/path'; + const browser = new Browser({ + settings: { + fetch: { + interceptor: { + afterSyncResponse({ response }) { + return { + ...response, + headers: new Headers({ 'Content-Type': 'text/plain' }) + }; + } + } + } + } + }); + 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/plain' + }); + }); + it('Performs a request with a relative URL and adds the "Referer" header set to the window location.', () => { const baseUrl = 'https://localhost:8080/base/';