diff --git a/docs/api.md b/docs/api.md index f733f7f2..f182ee58 100644 --- a/docs/api.md +++ b/docs/api.md @@ -169,6 +169,7 @@ * [request.method](#requestmethod) * [request.postData](#requestpostdata) * [request.resourceType](#requestresourcetype) + * [request.respond(response)](#requestrespondresponse) * [request.response()](#requestresponse) * [request.url](#requesturl) - [class: Response](#class-response) @@ -974,9 +975,10 @@ The extra HTTP headers will be sent with every request the page initiates. - `value` <[boolean]> Whether to enable request interception. - returns: <[Promise]> -Activating request interception enables `request.abort` and `request.continue`. +Activating request interception enables `request.abort`, `request.continue` and +`request.respond` methods. -An example of a naïve request interceptor which aborts all image requests: +An example of a naïve request interceptor that aborts all image requests: ```js const puppeteer = require('puppeteer'); @@ -994,6 +996,9 @@ puppeteer.launch().then(async browser => { }); ``` +> **NOTE** Request interception doesn't work with data URLs. Calling `abort`, +> `continue` or `respond` on requests for data URLs is a noop. + #### page.setUserAgent(userAgent) - `userAgent` <[string]> Specific user agent to use in this page - returns: <[Promise]> Promise which resolves when the user agent is set. @@ -1899,6 +1904,32 @@ Contains the request's post body, if any. Contains the request's resource type as it was perceived by the rendering engine. ResourceType will be one of the following: `document`, `stylesheet`, `image`, `media`, `font`, `script`, `texttrack`, `xhr`, `fetch`, `eventsource`, `websocket`, `manifest`, `other`. +#### request.respond(response) +- `response` <[Object]> Response that will fulfill this request + - `status` <[number]> Response status code, defaults to `200`. + - `headers` <[Object]> Optional response headers + - `contentType` <[string]> If set, equals to setting `Content-Type` response header + - `body` <[Buffer]|[string]> Optional response body +- returns: <[Promise]> + +Fulfills request with given response. To use this, request interception should +be enabled with `page.setRequestInterceptionEnabled`. Exception is thrown if +request interception is not enabled. + +An example of fulfilling all requests with 404 responses: + +```js +await page.setRequestInterceptionEnabled(true); +page.on('request', request => { + request.respond({ + status: 404, + contentType: 'text/plain', + body: 'Not Found!' + }); +}); +``` + + #### request.response() - returns: <[Response]> A matching [Response] object, or `null` if the response has not been received yet. diff --git a/lib/NetworkManager.js b/lib/NetworkManager.js index f63bb82c..2454e240 100644 --- a/lib/NetworkManager.js +++ b/lib/NetworkManager.js @@ -340,6 +340,54 @@ class Request { }); } + /** + * @param {!{status: number, headers: Object, contentType: string, body: (string|Buffer)}} response + */ + async respond(response) { + // DataURL's are not interceptable. In this case, do nothing. + if (this.url.startsWith('data:')) + return; + console.assert(this._allowInterception, 'Request Interception is not enabled!'); + console.assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + + const responseBody = response.body && helper.isString(response.body) ? Buffer.from(/** @type {string} */(response.body)) : /** @type {?Buffer} */(response.body || null); + + const responseHeaders = {}; + if (response.headers) { + for (const header of Object.keys(response.headers)) + responseHeaders[header.toLowerCase()] = response.headers[header]; + } + if (response.contentType) + responseHeaders['content-type'] = response.contentType; + if (responseBody && !('content-length' in responseHeaders)) { + // @ts-ignore + responseHeaders['content-length'] = Buffer.byteLength(responseBody); + } + + const statusCode = response.status || 200; + const statusText = statusTexts[statusCode] || ''; + const statusLine = `HTTP/1.1 ${statusCode} ${statusText}`; + + const CRLF = '\r\n'; + let text = statusLine + CRLF; + for (const header of Object.keys(responseHeaders)) + text += header + ': ' + responseHeaders[header] + CRLF; + text += CRLF; + let responseBuffer = Buffer.from(text, 'utf8'); + if (responseBody) + responseBuffer = Buffer.concat([responseBuffer, responseBody]); + + await this._client.send('Network.continueInterceptedRequest', { + interceptionId: this._interceptionId, + rawResponse: responseBuffer.toString('base64') + }).catch(error => { + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors. + debugError(error); + }); + } + /** * @param {string=} errorCode */ @@ -486,4 +534,67 @@ NetworkManager.Events = { RequestFinished: 'requestfinished', }; +const statusTexts = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '209': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Switch Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': 'I\'m a teapot', + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '510': 'Not Extended', + '511': 'Network Authentication Required', +}; + module.exports = NetworkManager; diff --git a/test/assets/pptr.png b/test/assets/pptr.png new file mode 100644 index 00000000..65d87c68 Binary files /dev/null and b/test/assets/pptr.png differ diff --git a/test/golden/mock-binary-response.png b/test/golden/mock-binary-response.png new file mode 100644 index 00000000..8595e059 Binary files /dev/null and b/test/golden/mock-binary-response.png differ diff --git a/test/test.js b/test/test.js index 96fd2eae..fae8865d 100644 --- a/test/test.js +++ b/test/test.js @@ -1337,6 +1337,55 @@ describe('Page', function() { await request.continue().catch(e => error = e); expect(error).toBe(null); })); + it('should throw if interception is not enabled', SX(async function() { + let error = null; + page.on('request', async request => { + try { + await request.continue(); + } catch (e) { + error = e; + } + }); + await page.goto(EMPTY_PAGE); + expect(error.message).toContain('Request Interception is not enabled'); + })); + }); + + describe('Request.respond', function() { + it('should work', SX(async function() { + await page.setRequestInterceptionEnabled(true); + page.on('request', request => { + request.respond({ + status: 201, + headers: { + foo: 'bar' + }, + body: 'Yo, page!' + }); + }); + const response = await page.goto(EMPTY_PAGE); + expect(response.status).toBe(201); + expect(response.headers.foo).toBe('bar'); + expect(await page.evaluate(() => document.body.textContent)).toBe('Yo, page!'); + })); + it('should allow mocking binary responses', SX(async function() { + await page.setRequestInterceptionEnabled(true); + page.on('request', request => { + const imageBuffer = fs.readFileSync(path.join(__dirname, 'assets', 'pptr.png')); + request.respond({ + contentType: 'image/png', + body: imageBuffer + }); + }); + await page.evaluate(PREFIX => { + const img = document.createElement('img'); + img.src = PREFIX + '/does-not-exist.png'; + document.body.appendChild(img); + return new Promise(fulfill => img.onload = fulfill); + }, PREFIX); + const img = await page.$('img'); + expect(await img.screenshot()).toBeGolden('mock-binary-response.png'); + })); }); describe('Page.Events.Dialog', function() {