From 48ea1d706be07e0d4036c308c405bfd0604a5411 Mon Sep 17 00:00:00 2001 From: Offir Golan Date: Mon, 18 May 2020 10:23:07 -0700 Subject: [PATCH] feat(adapter-xhr): Add support for handling binary data (#333) --- package.json | 2 +- packages/@pollyjs/adapter-fetch/src/index.js | 2 +- .../tests/integration/adapter-test.js | 2 - .../adapter-node-http/rollup.config.test.js | 2 +- .../tests/integration/adapter-test.js | 72 ++++++++--------- .../tests/utils/calculate-hash-from-stream.js | 12 --- packages/@pollyjs/adapter-xhr/package.json | 3 +- packages/@pollyjs/adapter-xhr/src/index.js | 79 +++++++++++++------ .../tests/integration/adapter-test.js | 38 +++++++++ .../adapter-xhr/tests/utils/xhr-request.js | 6 +- packages/@pollyjs/utils/src/index.js | 4 + .../src/utils/is-buffer-utf8-representable.js | 2 +- yarn.lock | 62 +++++---------- 13 files changed, 164 insertions(+), 122 deletions(-) delete mode 100644 packages/@pollyjs/adapter-node-http/tests/utils/calculate-hash-from-stream.js rename packages/@pollyjs/{adapter-fetch => utils}/src/utils/is-buffer-utf8-representable.js (90%) diff --git a/package.json b/package.json index fb22dcce..4f8135f7 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "lint-staged": "^8.2.0", "mocha": "^6.1.4", "npm-run-all": "^4.1.5", - "prettier": "^1.18.2", + "prettier": "~1.18.2", "rimraf": "^2.6.3", "rollup": "^1.14.6", "rollup-plugin-alias": "^1.5.2", diff --git a/packages/@pollyjs/adapter-fetch/src/index.js b/packages/@pollyjs/adapter-fetch/src/index.js index 007b0136..ea62a62a 100644 --- a/packages/@pollyjs/adapter-fetch/src/index.js +++ b/packages/@pollyjs/adapter-fetch/src/index.js @@ -1,10 +1,10 @@ import Adapter from '@pollyjs/adapter'; +import { isBufferUtf8Representable } from '@pollyjs/utils'; import isNode from 'detect-node'; import { Buffer } from 'buffer/'; import bufferToArrayBuffer from 'to-arraybuffer'; import serializeHeaders from './utils/serializer-headers'; -import isBufferUtf8Representable from './utils/is-buffer-utf8-representable'; const { defineProperty } = Object; const IS_STUBBED = Symbol(); diff --git a/packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js b/packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js index 708b5171..3757bc03 100644 --- a/packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js +++ b/packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js @@ -105,8 +105,6 @@ describe('Integration | Fetch Adapter', function() { }); it('should be able to download binary content', async function() { - this.timeout(10000); - const fetch = async () => Buffer.from( await this.fetch('/assets/32x32.png').then(res => res.arrayBuffer()) diff --git a/packages/@pollyjs/adapter-node-http/rollup.config.test.js b/packages/@pollyjs/adapter-node-http/rollup.config.test.js index 546724be..d5005d03 100644 --- a/packages/@pollyjs/adapter-node-http/rollup.config.test.js +++ b/packages/@pollyjs/adapter-node-http/rollup.config.test.js @@ -3,7 +3,7 @@ import createJestTestConfig from '../../../scripts/rollup/jest.test.config'; import { external } from './rollup.config.shared'; -const testExternal = [...external, 'crypto', 'fs', 'path']; +const testExternal = [...external, 'fs', 'path']; export default [ createNodeTestConfig({ external: testExternal }), diff --git a/packages/@pollyjs/adapter-node-http/tests/integration/adapter-test.js b/packages/@pollyjs/adapter-node-http/tests/integration/adapter-test.js index 0326a0d0..52af63ff 100644 --- a/packages/@pollyjs/adapter-node-http/tests/integration/adapter-test.js +++ b/packages/@pollyjs/adapter-node-http/tests/integration/adapter-test.js @@ -14,7 +14,6 @@ import NodeHTTPAdapter from '../../src'; import nativeRequest from '../utils/native-request'; import pollyConfig from '../utils/polly-config'; import getResponseFromRequest from '../utils/get-response-from-request'; -import calculateHashFromStream from '../utils/calculate-hash-from-stream'; describe('Integration | Node Http Adapter', function() { describe('Concurrency', function() { @@ -62,6 +61,41 @@ describe('Integration | Node Http Adapter', function() { adapterTests(); adapterIdentifierTests(); commonTests(http); + + it('should be able to download binary content', async function() { + const fetch = async () => + Buffer.from( + await this.relativeFetch('/assets/32x32.png').then(res => + res.arrayBuffer() + ) + ); + + this.polly.disconnectFrom(NodeHTTPAdapter); + + const nativeResponseBuffer = await fetch(); + + this.polly.connectTo(NodeHTTPAdapter); + + const recordedResponseBuffer = await fetch(); + + const { recordingName, config } = this.polly; + + await this.polly.stop(); + this.polly = new Polly(recordingName, config); + this.polly.replay(); + + const replayedResponseBuffer = await fetch(); + + expect(nativeResponseBuffer.equals(recordedResponseBuffer)).to.equal( + true + ); + expect(recordedResponseBuffer.equals(replayedResponseBuffer)).to.equal( + true + ); + expect(nativeResponseBuffer.equals(replayedResponseBuffer)).to.equal( + true + ); + }); }); describe('https', function() { @@ -123,42 +157,6 @@ function commonTests(transport) { expect(request.body).to.include('@pollyjs/adapter-node-http'); }); - it('should be able to download binary content', async function() { - const url = `${protocol}//via.placeholder.com/150/92c952`; - - this.polly.disconnectFrom(NodeHTTPAdapter); - - const nativeResponseStream = await getResponseFromRequest( - transport.request(url) - ); - - this.polly.connectTo(NodeHTTPAdapter); - - const recordedResponseStream = await getResponseFromRequest( - transport.request(url) - ); - - const { recordingName, config } = this.polly; - - await this.polly.stop(); - this.polly = new Polly(recordingName, config); - this.polly.replay(); - - const replayedResponseStream = await getResponseFromRequest( - transport.request(url) - ); - - const [nativeHash, recordedHash, replayedHash] = await Promise.all([ - calculateHashFromStream(nativeResponseStream), - calculateHashFromStream(recordedResponseStream), - calculateHashFromStream(replayedResponseStream) - ]); - - expect(nativeHash).to.equal(recordedHash); - expect(recordedHash).to.equal(replayedHash); - expect(nativeHash).to.equal(replayedHash); - }); - it('should handle aborting a request', async function() { const { server } = this.polly; const url = `${protocol}//example.com`; diff --git a/packages/@pollyjs/adapter-node-http/tests/utils/calculate-hash-from-stream.js b/packages/@pollyjs/adapter-node-http/tests/utils/calculate-hash-from-stream.js deleted file mode 100644 index 07f76c44..00000000 --- a/packages/@pollyjs/adapter-node-http/tests/utils/calculate-hash-from-stream.js +++ /dev/null @@ -1,12 +0,0 @@ -import crypto from 'crypto'; - -import getBufferFromStream from './get-buffer-from-stream'; - -export default async function calculateHashFromStream(stream) { - const hmac = crypto.createHmac('sha256', 'a secret'); - const hashStream = stream.pipe(hmac); - - const hashBuffer = await getBufferFromStream(hashStream); - - return hashBuffer.toString('hex'); -} diff --git a/packages/@pollyjs/adapter-xhr/package.json b/packages/@pollyjs/adapter-xhr/package.json index 65151198..69eb35f9 100644 --- a/packages/@pollyjs/adapter-xhr/package.json +++ b/packages/@pollyjs/adapter-xhr/package.json @@ -40,9 +40,10 @@ "watch-all": "npm-run-all --parallel build:watch test:build:watch" }, "dependencies": { + "@offirgolan/nise": "^4.1.0", "@pollyjs/adapter": "^4.2.1", "@pollyjs/utils": "^4.1.0", - "nise": "^1.5.0" + "to-arraybuffer": "^1.0.1" }, "devDependencies": { "@pollyjs/core": "^4.2.1", diff --git a/packages/@pollyjs/adapter-xhr/src/index.js b/packages/@pollyjs/adapter-xhr/src/index.js index 277c8d20..52bab9e1 100644 --- a/packages/@pollyjs/adapter-xhr/src/index.js +++ b/packages/@pollyjs/adapter-xhr/src/index.js @@ -1,5 +1,8 @@ -import fakeXhr from 'nise/lib/fake-xhr'; +import fakeXhr from '@offirgolan/nise/lib/fake-xhr'; import Adapter from '@pollyjs/adapter'; +import { isBufferUtf8Representable } from '@pollyjs/utils'; +import { Buffer } from 'buffer/'; +import bufferToArrayBuffer from 'to-arraybuffer'; import resolveXhr from './utils/resolve-xhr'; import serializeResponseHeaders from './utils/serialize-response-headers'; @@ -8,6 +11,8 @@ const SEND = Symbol(); const ABORT_HANDLER = Symbol(); const stubbedXhrs = new WeakSet(); +const BINARY_RESPONSE_TYPES = ['arraybuffer', 'blob']; + export default class XHRAdapter extends Adapter { static get id() { return 'xhr'; @@ -73,28 +78,6 @@ export default class XHRAdapter extends Adapter { } } - respondToRequest(pollyRequest, error) { - const { xhr } = pollyRequest.requestArguments; - - if (pollyRequest[ABORT_HANDLER]) { - xhr.removeEventListener('abort', pollyRequest[ABORT_HANDLER]); - } - - if (pollyRequest.aborted) { - return; - } else if (error) { - // If an error was received then call the `error` method on the fake XHR - // request provided by nise which will simulate a network error on the request. - // The onerror handler will be called and the status will be 0. - // https://github.com/sinonjs/nise/blob/v1.4.10/lib/fake-xhr/index.js#L614-L621 - xhr.error(); - } else { - const { response } = pollyRequest; - - xhr.respond(response.statusCode, response.headers, response.body); - } - } - async passthroughRequest(pollyRequest) { const { xhr: fakeXhr } = pollyRequest.requestArguments; const xhr = new this.NativeXMLHttpRequest(); @@ -108,6 +91,9 @@ export default class XHRAdapter extends Adapter { ); xhr.async = fakeXhr.async; + xhr.responseType = BINARY_RESPONSE_TYPES.includes(fakeXhr.responseType) + ? 'arraybuffer' + : 'text'; if (fakeXhr.async) { xhr.timeout = fakeXhr.timeout; @@ -120,10 +106,55 @@ export default class XHRAdapter extends Adapter { await resolveXhr(xhr, pollyRequest.body); + let body = xhr.response; + let isBinary = false; + + // responseType will either be `arraybuffer` or `text` + if (xhr.responseType === 'arraybuffer') { + const buffer = Buffer.from(xhr.response); + + isBinary = !isBufferUtf8Representable(buffer); + body = buffer.toString(isBinary ? 'hex' : 'utf8'); + } + return { statusCode: xhr.status, headers: serializeResponseHeaders(xhr.getAllResponseHeaders()), - body: xhr.responseText + body, + isBinary }; } + + respondToRequest(pollyRequest, error) { + const { xhr } = pollyRequest.requestArguments; + + if (pollyRequest[ABORT_HANDLER]) { + xhr.removeEventListener('abort', pollyRequest[ABORT_HANDLER]); + } + + if (pollyRequest.aborted) { + return; + } else if (error) { + // If an error was received then call the `error` method on the fake XHR + // request provided by nise which will simulate a network error on the request. + // The onerror handler will be called and the status will be 0. + // https://github.com/sinonjs/nise/blob/v1.4.10/lib/fake-xhr/index.js#L614-L621 + xhr.error(); + } else { + const { statusCode, headers, body, isBinary } = pollyRequest.response; + let responseBody = body; + + if (isBinary) { + const buffer = Buffer.from(body, 'hex'); + + if (BINARY_RESPONSE_TYPES.includes(xhr.responseType)) { + responseBody = bufferToArrayBuffer(buffer); + } else { + responseBody = buffer.toString('utf8'); + } + } + + xhr.respond(statusCode, headers, responseBody); + } + } } diff --git a/packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js b/packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js index d8e360bb..0a26a581 100644 --- a/packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js +++ b/packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js @@ -4,6 +4,7 @@ import adapterTests from '@pollyjs-tests/integration/adapter-tests'; import adapterBrowserTests from '@pollyjs-tests/integration/adapter-browser-tests'; import adapterIdentifierTests from '@pollyjs-tests/integration/adapter-identifier-tests'; import InMemoryPersister from '@pollyjs/persister-in-memory'; +import { Buffer } from 'buffer/'; import xhrRequest from '../utils/xhr-request'; import XHRAdapter from '../../src'; @@ -63,6 +64,43 @@ describe('Integration | XHR Adapter', function() { expect(abortEventCalled).to.equal(true); }); + + ['arraybuffer', 'blob', 'text'].forEach(responseType => + it(`should be able to download binary content (${responseType})`, async function() { + const fetch = async () => + Buffer.from( + await this.fetch('/assets/32x32.png', { + responseType + }).then(res => res.arrayBuffer()) + ); + + this.polly.disconnectFrom(XHRAdapter); + + const nativeResponseBuffer = await fetch(); + + this.polly.connectTo(XHRAdapter); + + const recordedResponseBuffer = await fetch(); + + const { recordingName, config } = this.polly; + + await this.polly.stop(); + this.polly = new Polly(recordingName, config); + this.polly.replay(); + + const replayedResponseBuffer = await fetch(); + + expect(nativeResponseBuffer.equals(recordedResponseBuffer)).to.equal( + true + ); + expect(recordedResponseBuffer.equals(replayedResponseBuffer)).to.equal( + true + ); + expect(nativeResponseBuffer.equals(replayedResponseBuffer)).to.equal( + true + ); + }) + ); }); describe('Integration | XHR Adapter | Init', function() { diff --git a/packages/@pollyjs/adapter-xhr/tests/utils/xhr-request.js b/packages/@pollyjs/adapter-xhr/tests/utils/xhr-request.js index 7d32e6e5..c88016ee 100644 --- a/packages/@pollyjs/adapter-xhr/tests/utils/xhr-request.js +++ b/packages/@pollyjs/adapter-xhr/tests/utils/xhr-request.js @@ -12,6 +12,10 @@ export default function request(url, obj = {}) { } } + if (obj.responseType) { + xhr.responseType = obj.responseType; + } + xhr.onreadystatechange = () => xhr.readyState === XMLHttpRequest.DONE && resolve(xhr); xhr.onerror = () => resolve(xhr); @@ -19,7 +23,7 @@ export default function request(url, obj = {}) { xhr.send(obj.body); }).then(xhr => { const responseBody = - xhr.status === 204 && xhr.responseText === '' ? null : xhr.responseText; + xhr.status === 204 && xhr.response === '' ? null : xhr.response; return new Response(responseBody, { status: xhr.status || 500, diff --git a/packages/@pollyjs/utils/src/index.js b/packages/@pollyjs/utils/src/index.js index 1062bd93..871592d9 100644 --- a/packages/@pollyjs/utils/src/index.js +++ b/packages/@pollyjs/utils/src/index.js @@ -15,3 +15,7 @@ export { default as Serializers } from './utils/serializers'; export { default as URL } from './utils/url'; export { default as getFactoryId } from './utils/get-factory-id'; + +export { + default as isBufferUtf8Representable +} from './utils/is-buffer-utf8-representable'; diff --git a/packages/@pollyjs/adapter-fetch/src/utils/is-buffer-utf8-representable.js b/packages/@pollyjs/utils/src/utils/is-buffer-utf8-representable.js similarity index 90% rename from packages/@pollyjs/adapter-fetch/src/utils/is-buffer-utf8-representable.js rename to packages/@pollyjs/utils/src/utils/is-buffer-utf8-representable.js index c80b61e0..b6023c47 100644 --- a/packages/@pollyjs/adapter-fetch/src/utils/is-buffer-utf8-representable.js +++ b/packages/@pollyjs/utils/src/utils/is-buffer-utf8-representable.js @@ -1,4 +1,4 @@ -import { Buffer } from 'buffer/'; +import { Buffer } from 'buffer'; /** * Determine if the given buffer is utf8. diff --git a/yarn.lock b/yarn.lock index c043b93a..32811c81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1857,6 +1857,17 @@ universal-user-agent "^2.0.0" url-template "^2.0.8" +"@offirgolan/nise@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@offirgolan/nise/-/nise-4.1.0.tgz#11ef964cc8a7d8134555134b55c16b9d2b707387" + integrity sha512-7GEUt4YBlYdQM3D5cyQTUiupvBtSNapNiplkz1AI9hJETjFOemUPUHJq0+NmlaBt3WjdHGq8zg+FfKwjYTSQcw== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/fake-timers" "^6.0.0" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -1879,29 +1890,19 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== -"@sinonjs/commons@^1", "@sinonjs/commons@^1.0.2": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.4.0.tgz#7b3ec2d96af481d7a0321252e7b1c94724ec5a78" - integrity sha512-9jHK3YF/8HtJ9wCAbG+j8cD0i0+ATS9A7gXFqS36TblLPNy6rEEc+SB0imo91eCboGaBYGV/MT1/br/J+EE7Tw== +"@sinonjs/commons@^1.7.0": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2" + integrity sha512-+DUO6pnp3udV/v2VfUWgaY5BIE1IfT7lLfeDzPVeMT1XKkaAp9LgSI9x5RtrFQoZ9Oi0PgXQQHPaoKu7dCjVxw== dependencies: type-detect "4.0.8" -"@sinonjs/formatio@^3.1.0": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-3.2.1.tgz#52310f2f9bcbc67bdac18c94ad4901b95fde267e" - integrity sha512-tsHvOB24rvyvV2+zKMmPkZ7dXX6LSLKZ7aOtXY6Edklp0uRcgGpOsQTTGTcWViFyx4uhWc6GV8QdnALbIbIdeQ== - dependencies: - "@sinonjs/commons" "^1" - "@sinonjs/samsam" "^3.1.0" - -"@sinonjs/samsam@^3.1.0": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-3.3.1.tgz#e88c53fbd9d91ad9f0f2b0140c16c7c107fe0d07" - integrity sha512-wRSfmyd81swH0hA1bxJZJ57xr22kC07a1N4zuIL47yTS04bDk6AoCkczcqHEjcRPmJ+FruGJ9WBQiJwMtIElFw== +"@sinonjs/fake-timers@^6.0.0": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40" + integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA== dependencies: - "@sinonjs/commons" "^1.0.2" - array-from "^2.1.1" - lodash "^4.17.11" + "@sinonjs/commons" "^1.7.0" "@sinonjs/text-encoding@^0.7.1": version "0.7.1" @@ -2431,11 +2432,6 @@ array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" -array-from@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/array-from/-/array-from-2.1.1.tgz#cfe9d8c26628b9dc5aecc62a9f5d8f1f352c1195" - integrity sha1-z+nYwmYoudxa7MYqn12PHzUsEZU= - array-ify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" @@ -9731,11 +9727,6 @@ log-update@^2.3.0: cli-cursor "^2.0.0" wrap-ansi "^3.0.1" -lolex@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/lolex/-/lolex-4.1.0.tgz#ecdd7b86539391d8237947a3419aa8ac975f0fe1" - integrity sha512-BYxIEXiVq5lGIXeVHnsFzqa1TxN5acnKnPCdlZSpzm8viNEOhiigupA4vTQ9HEFQ6nLTQ9wQOgBknJgzUYQ9Aw== - long@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" @@ -10397,17 +10388,6 @@ nice-try@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.4.tgz#d93962f6c52f2c1558c0fbda6d512819f1efe1c4" -nise@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/nise/-/nise-1.5.0.tgz#d03ea0e6c1b75c638015aa3585eddc132949a50d" - integrity sha512-Z3sfYEkLFzFmL8KY6xnSJLRxwQwYBjOXi/24lb62ZnZiGA0JUzGGTI6TBIgfCSMIDl9Jlu8SRmHNACLTemDHww== - dependencies: - "@sinonjs/formatio" "^3.1.0" - "@sinonjs/text-encoding" "^0.7.1" - just-extend "^4.0.2" - lolex "^4.1.0" - path-to-regexp "^1.7.0" - no-case@^2.2.0: version "2.3.2" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" @@ -11416,7 +11396,7 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^1.14.2, prettier@^1.18.2: +prettier@^1.14.2, prettier@~1.18.2: version "1.18.2" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.18.2.tgz#6823e7c5900017b4bd3acf46fe9ac4b4d7bda9ea" integrity sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==