diff --git a/docs/server/events-and-middleware.md b/docs/server/events-and-middleware.md index 4b1723e1..ed8e0e0e 100644 --- a/docs/server/events-and-middleware.md +++ b/docs/server/events-and-middleware.md @@ -150,6 +150,24 @@ server.any().on('error', (req, error) => { }); ``` +### abort + +Fires when a request is aborted. + +| Param | Type | Description | +| ----- | ------------------------- | -------------------- | +| req | [Request](server/request) | The request instance | +| event | [Event](server/event) | The event instance | + +**Example** + +```js +server.any().on('abort', req => { + console.error('Request aborted.'); + process.exit(1); +}); +``` + ## Middleware Middleware can be added via the `.any()` method. diff --git a/packages/@pollyjs/adapter-fetch/src/index.js b/packages/@pollyjs/adapter-fetch/src/index.js index 8f84a42f..c6bd0048 100644 --- a/packages/@pollyjs/adapter-fetch/src/index.js +++ b/packages/@pollyjs/adapter-fetch/src/index.js @@ -5,6 +5,7 @@ import serializeHeaders from './utils/serializer-headers'; const { defineProperty } = Object; const IS_STUBBED = Symbol(); +const ABORT_HANDLER = Symbol(); const REQUEST_ARGUMENTS = Symbol(); export default class FetchAdapter extends Adapter { @@ -134,6 +135,21 @@ export default class FetchAdapter extends Adapter { this.NativeRequest = null; } + onRequest(pollyRequest) { + const { + options: { signal } + } = pollyRequest.requestArguments; + + if (signal) { + if (signal.aborted) { + pollyRequest.abort(); + } else { + pollyRequest[ABORT_HANDLER] = () => pollyRequest.abort(); + signal.addEventListener('abort', pollyRequest[ABORT_HANDLER]); + } + } + } + async passthroughRequest(pollyRequest) { const { context } = this.options; const { options } = pollyRequest.requestArguments; @@ -159,7 +175,22 @@ export default class FetchAdapter extends Adapter { const { context: { Response } } = this.options; - const { respond } = pollyRequest.requestArguments; + const { + respond, + options: { signal } + } = pollyRequest.requestArguments; + + if (signal && pollyRequest[ABORT_HANDLER]) { + signal.removeEventListener('abort', pollyRequest[ABORT_HANDLER]); + } + + if (pollyRequest.aborted) { + respond({ + error: new DOMException('The user aborted a request.', 'AbortError') + }); + + return; + } if (error) { respond({ error }); diff --git a/packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js b/packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js index e7ad9887..c145582c 100644 --- a/packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js +++ b/packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js @@ -53,6 +53,56 @@ describe('Integration | Fetch Adapter', function() { expect(headers).to.deep.equal({ 'content-type': 'application/json' }); }); + it('should handle aborting a request', async function() { + const { server } = this.polly; + const controller = new AbortController(); + let abortEventCalled = false; + let error; + + server + .any(this.recordUrl()) + .on('request', () => controller.abort()) + .on('abort', () => (abortEventCalled = true)) + .intercept((_, res) => { + res.sendStatus(200); + }); + + try { + await this.fetchRecord({ signal: controller.signal }); + } catch (e) { + error = e; + } + + expect(abortEventCalled).to.equal(true); + expect(error.message).to.contain('The user aborted a request.'); + }); + + it('should handle immediately aborting a request', async function() { + const { server } = this.polly; + const controller = new AbortController(); + let abortEventCalled = false; + let error; + + server + .any(this.recordUrl()) + .on('abort', () => (abortEventCalled = true)) + .intercept((_, res) => { + res.sendStatus(200); + }); + + try { + const promise = this.fetchRecord({ signal: controller.signal }); + + controller.abort(); + await promise; + } catch (e) { + error = e; + } + + expect(abortEventCalled).to.equal(true); + expect(error.message).to.contain('The user aborted a request.'); + }); + describe('Request', function() { it('should support Request objects', async function() { const { server } = this.polly; diff --git a/packages/@pollyjs/adapter-node-http/src/index.js b/packages/@pollyjs/adapter-node-http/src/index.js index c9512976..901f31de 100644 --- a/packages/@pollyjs/adapter-node-http/src/index.js +++ b/packages/@pollyjs/adapter-node-http/src/index.js @@ -17,6 +17,7 @@ import mergeChunks from './utils/merge-chunks'; import urlToOptions from './utils/url-to-options'; const IS_STUBBED = Symbol(); +const ABORT_HANDLER = Symbol(); const REQUEST_ARGUMENTS = new WeakMap(); // nock begins to intercept network requests on import which is not the @@ -151,6 +152,17 @@ export default class HttpAdapter extends Adapter { }); } + onRequest(pollyRequest) { + const { req } = pollyRequest.requestArguments; + + if (req.aborted) { + pollyRequest.abort(); + } else { + pollyRequest[ABORT_HANDLER] = () => pollyRequest.abort(); + req.once('abort', pollyRequest[ABORT_HANDLER]); + } + } + async passthroughRequest(pollyRequest) { const { parsedArguments } = pollyRequest.requestArguments; const { method, headers, body } = pollyRequest; @@ -195,6 +207,19 @@ export default class HttpAdapter extends Adapter { async respondToRequest(pollyRequest, error) { const { req, respond } = pollyRequest.requestArguments; + const { statusCode, body, headers } = pollyRequest.response; + + if (pollyRequest[ABORT_HANDLER]) { + req.off('abort', pollyRequest[ABORT_HANDLER]); + } + + if (pollyRequest.aborted) { + // Even if the request has been aborted, we need to respond to the nock + // request in order to resolve its awaiting promise. + respond(null, [0, undefined, {}]); + + return; + } if (error) { // If an error was received then forward it over to nock so it can @@ -204,7 +229,6 @@ export default class HttpAdapter extends Adapter { return; } - const { statusCode, body, headers } = pollyRequest.response; const chunks = this.getChunksFromBody(body, headers); const stream = new ReadableStream(); 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 4f68df85..90a2c88e 100644 --- a/packages/@pollyjs/adapter-node-http/tests/integration/adapter-test.js +++ b/packages/@pollyjs/adapter-node-http/tests/integration/adapter-test.js @@ -146,4 +146,55 @@ function commonTests(transport) { expect(nativeHash).to.equal(recordedHash); }); + + it('should handle aborting a request', async function() { + const { server } = this.polly; + const url = `${protocol}//example.com`; + const req = transport.request(url); + let abortEventCalled = false; + + server + .any(url) + .on('request', () => req.abort()) + .on('abort', () => (abortEventCalled = true)) + .intercept((_, res) => { + res.sendStatus(200); + }); + + try { + await getResponseFromRequest(req); + } catch (e) { + // no-op + } + + await this.polly.flush(); + expect(abortEventCalled).to.equal(true); + }); + + it('should handle immediately aborting a request', async function() { + const { server } = this.polly; + const url = `${protocol}//example.com`; + const req = transport.request(url); + let abortEventCalled = false; + + server + .any(url) + .on('abort', () => (abortEventCalled = true)) + .intercept((_, res) => { + res.sendStatus(200); + }); + + const promise = getResponseFromRequest(req); + + req.abort(); + + try { + await promise; + } catch (e) { + // no-op + } + + await this.polly.flush(); + expect(abortEventCalled).to.equal(true); + }); } diff --git a/packages/@pollyjs/adapter-node-http/tests/utils/get-response-from-request.js b/packages/@pollyjs/adapter-node-http/tests/utils/get-response-from-request.js index 535ccbec..a2676a3e 100644 --- a/packages/@pollyjs/adapter-node-http/tests/utils/get-response-from-request.js +++ b/packages/@pollyjs/adapter-node-http/tests/utils/get-response-from-request.js @@ -2,6 +2,7 @@ export default function getResponseFromRequest(req, data) { return new Promise((resolve, reject) => { req.once('response', resolve); req.once('error', reject); + req.once('abort', reject); req.end(data); }); diff --git a/packages/@pollyjs/adapter-xhr/src/index.js b/packages/@pollyjs/adapter-xhr/src/index.js index f1408fec..277c8d20 100644 --- a/packages/@pollyjs/adapter-xhr/src/index.js +++ b/packages/@pollyjs/adapter-xhr/src/index.js @@ -5,6 +5,7 @@ import resolveXhr from './utils/resolve-xhr'; import serializeResponseHeaders from './utils/serialize-response-headers'; const SEND = Symbol(); +const ABORT_HANDLER = Symbol(); const stubbedXhrs = new WeakSet(); export default class XHRAdapter extends Adapter { @@ -61,10 +62,27 @@ export default class XHRAdapter extends Adapter { this.xhr.restore(); } + onRequest(pollyRequest) { + const { xhr } = pollyRequest.requestArguments; + + if (xhr.aborted) { + pollyRequest.abort(); + } else { + pollyRequest[ABORT_HANDLER] = () => pollyRequest.abort(); + xhr.addEventListener('abort', pollyRequest[ABORT_HANDLER]); + } + } + respondToRequest(pollyRequest, error) { const { xhr } = pollyRequest.requestArguments; - if (error) { + 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. @@ -78,7 +96,7 @@ export default class XHRAdapter extends Adapter { } async passthroughRequest(pollyRequest) { - const fakeXhr = pollyRequest.requestArguments.xhr; + const { xhr: fakeXhr } = pollyRequest.requestArguments; const xhr = new this.NativeXMLHttpRequest(); xhr.open( diff --git a/packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js b/packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js index ec150103..d8e360bb 100644 --- a/packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js +++ b/packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js @@ -17,16 +17,52 @@ describe('Integration | XHR Adapter', function() { persister: InMemoryPersister }); - setupFetchRecord({ - fetch() { - return xhrRequest(...arguments); - } - }); + setupFetchRecord({ fetch: xhrRequest }); setupPolly.afterEach(); adapterTests(); adapterBrowserTests(); adapterIdentifierTests(); + + it('should handle aborting a request', async function() { + const { server } = this.polly; + const xhr = new XMLHttpRequest(); + let abortEventCalled; + + server + .any(this.recordUrl()) + .on('request', () => xhr.abort()) + .on('abort', () => (abortEventCalled = true)) + .intercept((_, res) => { + res.sendStatus(200); + }); + + await this.fetchRecord({ xhr }); + await this.polly.flush(); + + expect(abortEventCalled).to.equal(true); + }); + + it('should handle immediately aborting a request', async function() { + const { server } = this.polly; + const xhr = new XMLHttpRequest(); + let abortEventCalled; + + server + .any(this.recordUrl()) + .on('abort', () => (abortEventCalled = true)) + .intercept((_, res) => { + res.sendStatus(200); + }); + + const promise = this.fetchRecord({ xhr }); + + xhr.abort(); + await promise; + await this.polly.flush(); + + expect(abortEventCalled).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 de514a0d..7d32e6e5 100644 --- a/packages/@pollyjs/adapter-xhr/tests/utils/xhr-request.js +++ b/packages/@pollyjs/adapter-xhr/tests/utils/xhr-request.js @@ -2,7 +2,7 @@ import serializeResponseHeaders from '../../src/utils/serialize-response-headers export default function request(url, obj = {}) { return new Promise(resolve => { - const xhr = new XMLHttpRequest(); + const xhr = obj.xhr || new XMLHttpRequest(); xhr.open(obj.method || 'GET', url); diff --git a/packages/@pollyjs/adapter/src/index.js b/packages/@pollyjs/adapter/src/index.js index ed5da22b..8a6167b3 100644 --- a/packages/@pollyjs/adapter/src/index.js +++ b/packages/@pollyjs/adapter/src/index.js @@ -71,21 +71,30 @@ export default class Adapter { try { pollyRequest.on('identify', (...args) => this.onIdentifyRequest(...args)); - await pollyRequest.setup(); await this.onRequest(pollyRequest); + await pollyRequest.init(); await this[REQUEST_HANDLER](pollyRequest); - await this.onRequestFinished(pollyRequest); - return pollyRequest; + if (pollyRequest.aborted) { + throw new PollyError('Request aborted.'); + } + + await this.onRequestFinished(pollyRequest); } catch (error) { await this.onRequestFailed(pollyRequest, error); } + + return pollyRequest; } async [REQUEST_HANDLER](pollyRequest) { const { mode } = this.polly; const { _interceptor: interceptor } = pollyRequest; + if (pollyRequest.aborted) { + return; + } + if (pollyRequest.shouldIntercept) { await this.intercept(pollyRequest, interceptor); @@ -228,7 +237,7 @@ export default class Adapter { /** * @param {PollyRequest} pollyRequest - * @returns {{ statusCode: number, headers: Object, body: string }} + * @returns {Object({ statusCode: number, headers: Object, body: string })} */ async passthroughRequest(/* pollyRequest */) { this.assert('Must implement the `passthroughRequest` hook.'); @@ -241,15 +250,19 @@ export default class Adapter { * Calling `pollyjs.flush()` will await this method. * * @param {PollyRequest} pollyRequest + * @param {Error} [error] */ - async respondToRequest(/* pollyRequest */) {} + async respondToRequest(/* pollyRequest, error */) {} /** * @param {PollyRequest} pollyRequest */ async onRecord(pollyRequest) { await this.onPassthrough(pollyRequest); - await this.persister.recordRequest(pollyRequest); + + if (!pollyRequest.aborted) { + await this.persister.recordRequest(pollyRequest); + } } /** @@ -300,19 +313,25 @@ export default class Adapter { */ async onRequestFinished(pollyRequest) { await this.respondToRequest(pollyRequest); - pollyRequest.promise.resolve(); } /** * @param {PollyRequest} pollyRequest - * @param {Error} error + * @param {Error} [error] */ async onRequestFailed(pollyRequest, error) { + const { aborted } = pollyRequest; + error = error || new PollyError('Request failed due to an unknown error.'); try { - await pollyRequest._emit('error', error); + if (aborted) { + await pollyRequest._emit('abort'); + } else { + await pollyRequest._emit('error', error); + } + await this.respondToRequest(pollyRequest, error); } catch (e) { // Rethrow any error not handled by `respondToRequest`. diff --git a/packages/@pollyjs/core/src/-private/request.js b/packages/@pollyjs/core/src/-private/request.js index 1fd8ff5a..7b011665 100644 --- a/packages/@pollyjs/core/src/-private/request.js +++ b/packages/@pollyjs/core/src/-private/request.js @@ -38,6 +38,7 @@ export default class PollyRequest extends HTTPBase { ); this.didRespond = false; + this.aborted = false; this.url = request.url; this.method = request.method.toUpperCase(); this.body = request.body; @@ -155,7 +156,7 @@ export default class PollyRequest extends HTTPBase { return this; } - async setup() { + async init() { // Trigger the `request` event await this._emit('request'); @@ -178,6 +179,10 @@ export default class PollyRequest extends HTTPBase { !this.didRespond ); + if (this.aborted) { + return; + } + // Timestamp the response this.response.timestamp = timestamp(); @@ -208,6 +213,10 @@ export default class PollyRequest extends HTTPBase { await this._emit('response', this.response); } + abort() { + this.aborted = true; + } + _overrideRecordingName(recordingName) { validateRecordingName(recordingName); this.recordingName = recordingName; diff --git a/packages/@pollyjs/core/src/server/handler.js b/packages/@pollyjs/core/src/server/handler.js index 04b218a8..0bdfd0c4 100644 --- a/packages/@pollyjs/core/src/server/handler.js +++ b/packages/@pollyjs/core/src/server/handler.js @@ -19,6 +19,7 @@ export default class Handler extends Map { this._eventEmitter = new EventEmitter({ eventNames: [ 'error', + 'abort', 'request', 'beforeReplay', 'beforePersist',