diff --git a/.travis.yml b/.travis.yml index 6ace5aa61e3c..c3a5fce90f6a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ branches: only: - master -sudo: false +sudo: required language: node_js node_js: - '8' @@ -13,5 +13,8 @@ addons: chrome: stable firefox: latest sauce_connect: true +before_script: + - "sudo chown root /opt/google/chrome/chrome-sandbox" + - "sudo chmod 4755 /opt/google/chrome/chrome-sandbox" script: - npm run test && if [ "$TRAVIS_SECURE_ENV_VARS" == "true" ]; then npm run test:ci; else exit 0; fi diff --git a/karma.config.js b/karma.config.js index d5d1f7051917..9de77be69cca 100644 --- a/karma.config.js +++ b/karma.config.js @@ -60,7 +60,7 @@ module.exports = { // Concurrency level // how many browser should be started simultaneous - concurrency: Infinity, + concurrency: 2, client: { mocha: { diff --git a/src/raven.js b/src/raven.js index 1d2e9008fc14..f44953a69d6e 100644 --- a/src/raven.js +++ b/src/raven.js @@ -25,6 +25,7 @@ var isSameException = utils.isSameException; var isSameStacktrace = utils.isSameStacktrace; var parseUrl = utils.parseUrl; var fill = utils.fill; +var supportsFetch = utils.supportsFetch; var wrapConsoleMethod = require('./console').wrapMethod; @@ -1179,7 +1180,7 @@ Raven.prototype = { xhrproto, 'send', function(origSend) { - return function(data) { + return function() { // preserve arity var xhr = this; @@ -1227,12 +1228,12 @@ Raven.prototype = { ); } - if (autoBreadcrumbs.xhr && 'fetch' in _window) { + if (autoBreadcrumbs.xhr && supportsFetch()) { fill( _window, 'fetch', function(origFetch) { - return function(fn, t) { + return function() { // preserve arity // Make a copy of the arguments to prevent deoptimization // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments @@ -1256,6 +1257,11 @@ Raven.prototype = { url = '' + fetchInput; } + // if Sentry key appears in URL, don't capture, as it's our own request + if (url.indexOf(self._globalKey) !== -1) { + return origFetch.apply(this, args); + } + if (args[1] && args[1].method) { method = args[1].method; } @@ -1692,8 +1698,14 @@ Raven.prototype = { try { // If Retry-After is not in Access-Control-Expose-Headers, most // browsers will throw an exception trying to access it - retry = request.getResponseHeader('Retry-After'); - retry = parseInt(retry, 10) * 1000; // Retry-After is returned in seconds + if (supportsFetch()) { + retry = request.headers.get('Retry-After'); + } else { + retry = request.getResponseHeader('Retry-After'); + } + + // Retry-After is returned in seconds + retry = parseInt(retry, 10) * 1000; } catch (e) { /* eslint no-empty:0 */ } @@ -1882,6 +1894,32 @@ Raven.prototype = { }, _makeRequest: function(opts) { + // Auth is intentionally sent as part of query string (NOT as custom HTTP header) to avoid preflight CORS requests + var url = opts.url + '?' + urlencode(opts.auth); + + if (supportsFetch()) { + return _window + .fetch(url, { + method: 'POST', + body: stringify(opts.data) + }) + .then(function(response) { + if (response.ok) { + opts.onSuccess && opts.onSuccess(); + } else { + var error = new Error('Sentry error code: ' + response.status); + // It's called request only to keep compatibility with XHR interface + // and not add more redundant checks in setBackoffState method + error.request = response; + opts.onError && opts.onError(error); + } + }) + ['catch'](function() { + opts.onError && + opts.onError(new Error('Sentry error code: network unavailable')); + }); + } + var request = _window.XMLHttpRequest && new _window.XMLHttpRequest(); if (!request) return; @@ -1890,8 +1928,6 @@ Raven.prototype = { if (!hasCORS) return; - var url = opts.url; - if ('withCredentials' in request) { request.onreadystatechange = function() { if (request.readyState !== 4) { @@ -1923,9 +1959,7 @@ Raven.prototype = { } } - // NOTE: auth is intentionally sent as part of query string (NOT as custom - // HTTP header) so as to avoid preflight CORS requests - request.open('POST', url + '?' + urlencode(opts.auth)); + request.open('POST', url); request.send(stringify(opts.data)); }, diff --git a/src/utils.js b/src/utils.js index 90efbfcf449d..d3fbbfec2efe 100644 --- a/src/utils.js +++ b/src/utils.js @@ -56,6 +56,19 @@ function supportsErrorEvent() { } } +function supportsFetch() { + if (!('fetch' in _window)) return false; + + try { + new Headers(); // eslint-disable-line no-new + new Request(''); // eslint-disable-line no-new + new Response(); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + function wrappedCallback(callback) { function dataCallback(data, original) { var normalizedData = callback(data) || data; @@ -373,6 +386,7 @@ module.exports = { isString: isString, isEmptyObject: isEmptyObject, supportsErrorEvent: supportsErrorEvent, + supportsFetch: supportsFetch, wrappedCallback: wrappedCallback, each: each, objectMerge: objectMerge, diff --git a/test/raven.test.js b/test/raven.test.js index 39d3b53baa6b..53333f9f9f0e 100644 --- a/test/raven.test.js +++ b/test/raven.test.js @@ -23,6 +23,7 @@ _Raven.prototype._getUuid = function() { var utils = require('../src/utils'); var joinRegExp = utils.joinRegExp; var supportsErrorEvent = utils.supportsErrorEvent; +var supportsFetch = utils.supportsFetch; // window.console must be stubbed in for browsers that don't have it if (typeof window.console === 'undefined') { @@ -1495,7 +1496,10 @@ describe('globals', function() { assert.equal(Raven._backoffDuration, 2000); }); - it('should set backoffDuration to value of Retry-If header if present', function() { + it('should set backoffDuration to value of Retry-If header if present - XHR API', function() { + var origFetch = window.fetch; + delete window.fetch; + this.sinon.stub(Raven, 'isSetup').returns(true); this.sinon.stub(Raven, '_makeRequest'); @@ -1509,12 +1513,40 @@ describe('globals', function() { .withArgs('Retry-After') .returns('2') }; + opts.onError(mockError); assert.equal(Raven._backoffStart, 100); // clock is at 100ms assert.equal(Raven._backoffDuration, 2000); // converted to ms, int + + window.fetch = origFetch; }); + if (supportsFetch()) { + it('should set backoffDuration to value of Retry-If header if present - FETCH API', function() { + this.sinon.stub(Raven, 'isSetup').returns(true); + this.sinon.stub(Raven, '_makeRequest'); + + Raven._send({message: 'bar'}); + var opts = Raven._makeRequest.lastCall.args[0]; + var mockError = new Error('401: Unauthorized'); + mockError.request = { + status: 401, + headers: { + get: sinon + .stub() + .withArgs('Retry-After') + .returns('2') + } + }; + + opts.onError(mockError); + + assert.equal(Raven._backoffStart, 100); // clock is at 100ms + assert.equal(Raven._backoffDuration, 2000); // converted to ms, int + }); + } + it('should reset backoffDuration and backoffStart if onSuccess is fired (200)', function() { this.sinon.stub(Raven, 'isSetup').returns(true); this.sinon.stub(Raven, '_makeRequest'); @@ -1653,74 +1685,138 @@ describe('globals', function() { }); describe('makeRequest', function() { - beforeEach(function() { - // NOTE: can't seem to call useFakeXMLHttpRequest via sandbox; must - // restore manually - this.xhr = sinon.useFakeXMLHttpRequest(); - var requests = (this.requests = []); + if (supportsFetch()) { + describe('using Fetch API', function() { + afterEach(function() { + window.fetch.restore(); + }); - this.xhr.onCreate = function(xhr) { - requests.push(xhr); - }; - }); + it('should create an XMLHttpRequest object with body as JSON payload', function() { + this.sinon.spy(window, 'fetch'); - afterEach(function() { - this.xhr.restore(); - }); + Raven._makeRequest({ + url: 'http://localhost/', + auth: {a: '1', b: '2'}, + data: {foo: 'bar'}, + options: Raven._globalOptions + }); + + assert.deepEqual(window.fetch.lastCall.args, [ + 'http://localhost/?a=1&b=2', + { + method: 'POST', + body: '{"foo":"bar"}' + } + ]); + }); - it('should create an XMLHttpRequest object with body as JSON payload', function() { - XMLHttpRequest.prototype.withCredentials = true; + it('should pass a request object to onError', function(done) { + sinon.stub(window, 'fetch'); + window.fetch.returns( + Promise.resolve( + new window.Response('{"foo":"bar"}', { + ok: false, + status: 429, + headers: { + 'Content-type': 'text/html' + } + }) + ) + ); + + Raven._makeRequest({ + url: 'http://localhost/', + auth: {a: '1', b: '2'}, + data: {foo: 'bar'}, + options: Raven._globalOptions, + onError: function(error) { + assert.equal(error.request.status, 429); + done(); + } + }); + }); + }); + } + + describe('using XHR API', function() { + var origFetch = window.fetch; + var xhr; + var requests; - Raven._makeRequest({ - url: 'http://localhost/', - auth: {a: '1', b: '2'}, - data: {foo: 'bar'}, - options: Raven._globalOptions + before(function() { + delete window.fetch; }); - var lastXhr = this.requests[this.requests.length - 1]; - assert.equal(lastXhr.requestBody, '{"foo":"bar"}'); - assert.equal(lastXhr.url, 'http://localhost/?a=1&b=2'); - }); + after(function() { + window.fetch = origFetch; + }); - it('should no-op if CORS is not supported', function() { - delete XMLHttpRequest.prototype.withCredentials; - var oldSupportsCORS = sinon.xhr.supportsCORS; - sinon.xhr.supportsCORS = false; + beforeEach(function() { + // NOTE: can't seem to call useFakeXMLHttpRequest via sandbox; must restore manually + xhr = sinon.useFakeXMLHttpRequest(); + requests = []; - var oldXDR = window.XDomainRequest; - window.XDomainRequest = undefined; + XMLHttpRequest.prototype.withCredentials = true; - Raven._makeRequest({ - url: 'http://localhost/', - auth: {a: '1', b: '2'}, - data: {foo: 'bar'}, - options: Raven._globalOptions + xhr.onCreate = function(xhr) { + requests.push(xhr); + }; }); - assert.equal(this.requests.length, 1); // the "test" xhr - assert.equal(this.requests[0].readyState, 0); + afterEach(function() { + xhr.restore(); + }); - sinon.xhr.supportsCORS = oldSupportsCORS; - window.XDomainRequest = oldXDR; - }); + it('should create an XMLHttpRequest object with body as JSON payload', function() { + Raven._makeRequest({ + url: 'http://localhost/', + auth: {a: '1', b: '2'}, + data: {foo: 'bar'}, + options: Raven._globalOptions + }); - it('should pass a request object to onError', function(done) { - XMLHttpRequest.prototype.withCredentials = true; + var lastXhr = requests[requests.length - 1]; + assert.equal(lastXhr.requestBody, '{"foo":"bar"}'); + assert.equal(lastXhr.url, 'http://localhost/?a=1&b=2'); + }); - Raven._makeRequest({ - url: 'http://localhost/', - auth: {a: '1', b: '2'}, - data: {foo: 'bar'}, - options: Raven._globalOptions, - onError: function(error) { - assert.equal(error.request.status, 429); - done(); - } + it('should pass a request object to onError', function(done) { + Raven._makeRequest({ + url: 'http://localhost/', + auth: {a: '1', b: '2'}, + data: {foo: 'bar'}, + options: Raven._globalOptions, + onError: function(error) { + assert.equal(error.request.status, 429); + done(); + } + }); + + var lastXhr = requests[requests.length - 1]; + lastXhr.respond(429, {'Content-Type': 'text/html'}, 'Too many requests'); }); - var lastXhr = this.requests[this.requests.length - 1]; - lastXhr.respond(429, {'Content-Type': 'text/html'}, 'Too many requests'); + it('should no-op if CORS is not supported', function() { + delete XMLHttpRequest.prototype.withCredentials; + var oldSupportsCORS = sinon.xhr.supportsCORS; + sinon.xhr.supportsCORS = false; + + var oldXDR = window.XDomainRequest; + window.XDomainRequest = undefined; + + Raven._makeRequest({ + url: 'http://localhost/', + auth: {a: '1', b: '2'}, + data: {foo: 'bar'}, + options: Raven._globalOptions + }); + + assert.equal(requests.length, 1); // the "test" xhr + assert.equal(requests[0].readyState, 0); + + sinon.xhr.supportsCORS = oldSupportsCORS; + window.XDomainRequest = oldXDR; + }); }); });