diff --git a/.travis.yml b/.travis.yml index d1bb9ead9828..c0643d6f577d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,15 +5,6 @@ branches: install: true sudo: required -node_js: - - "4" - - "5" - - "6" - - "7" - - "8" - - "9" - - "10" - language: node_js dist: trusty @@ -22,17 +13,62 @@ cache: directories: - node_modules -script: .travis/script.sh - matrix: include: - - node_js: "8" + - name: "@sentry/packages - lint" + node_js: "8" script: .travis/lint.sh - - node_js: "8" + - name: "@sentry/packages - build and test [node v6]" + node_js: "6" + script: .travis/test.sh + - name: "@sentry/packages - build and test [node v7]" + node_js: "7" + script: .travis/test.sh + - name: "@sentry/packages - build and test [node v8]" + node_js: "8" + script: .travis/test.sh + - name: "@sentry/packages - build and test [node v9]" + node_js: "9" + script: .travis/test.sh + - name: "@sentry/packages - build and test [node v10]" + node_js: "10" + script: .travis/test.sh + - name: "@sentry/browser - integration tests" + node_js: "8" addons: chrome: stable firefox: latest sauce_connect: true - script: .travis/script.sh - exclude: - - node_js: "8" + script: .travis/integration.sh + - name: "raven-js - unit and integration tests" + node_js: "8" + addons: + chrome: stable + firefox: latest + script: .travis/raven-js.sh + - name: "raven-js - saucelabs tests" + node_js: "8" + addons: + sauce_connect: true + script: .travis/raven-js-saucelabs.sh + - name: "raven-node [node v4]" + node_js: "4" + script: .travis/raven-node.sh + - name: "raven-node [node v5]" + node_js: "5" + script: .travis/raven-node.sh + - name: "raven-node [node v6]" + node_js: "6" + script: .travis/raven-node.sh + - name: "raven-node [node v7]" + node_js: "7" + script: .travis/raven-node.sh + - name: "raven-node [node v8]" + node_js: "8" + script: .travis/raven-node.sh + - name: "raven-node [node v9]" + node_js: "9" + script: .travis/raven-node.sh + - name: "raven-node [node v10]" + node_js: "10" + script: .travis/raven-node.sh diff --git a/.travis/before_script.sh b/.travis/before_script.sh deleted file mode 100755 index 9cf7167ef0fb..000000000000 --- a/.travis/before_script.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -set -e - -if [[ $TRAVIS_BRANCH == 'master' ]]; then -CHANGES="[force ci]" -else -CHANGES=$(git --no-pager diff --name-only FETCH_HEAD $(git merge-base FETCH_HEAD master)) -fi - -if [ -n "$(grep 'raven-js' <<< "$CHANGES")" ]; then - RAVEN_JS_CHANGES=true -fi - -if [ -n "$(grep 'raven-node' <<< "$CHANGES")" ]; then - RAVEN_NODE_CHANGES=true -fi - -FORCE=$(git log --format=%B --no-merges -n 1) - -if [ -n "$(grep '\[force ci\]' <<< "$FORCE")" ]; then - RAVEN_JS_CHANGES=true - RAVEN_NODE_CHANGES=true -fi - -NODE_VERSION=$(node -v); diff --git a/.travis/detect-raven.sh b/.travis/detect-raven.sh new file mode 100755 index 000000000000..26d2d10b8870 --- /dev/null +++ b/.travis/detect-raven.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +echo "" +echo "RAVEN: $RAVEN" + +# Does any of the commits in a PR contain "[force ci]" string? +COMMITS=$(git --no-pager log master.. --no-merges --format=%s) +if [[ -n "$(grep '\[force ci\]' <<< "$COMMITS")" ]]; then + HAS_FORCE_COMMIT=true +else + HAS_FORCE_COMMIT=false +fi + +# echo "COMMITS: $COMMITS" +echo "HAS_FORCE_COMMIT: $HAS_FORCE_COMMIT" + +# Does any changed file lives in raven-js/raven-node directory? +CHANGES=$(git --no-pager diff --name-only master) +if [[ -n "$(grep "$RAVEN" <<< "$CHANGES")" ]]; then + HAS_CHANGES=true +else + HAS_CHANGES=false +fi + +echo "HAS_CHANGES: $HAS_CHANGES" + +# If any of the above is true, run tests +if [[ ( $HAS_FORCE_COMMIT == "true" || $HAS_CHANGES == "true" ) ]]; then + SHOULD_RUN=true +else + SHOULD_RUN=false +fi + +echo "SHOULD_RUN: $SHOULD_RUN" + diff --git a/.travis/integration.sh b/.travis/integration.sh new file mode 100755 index 000000000000..8cb3bd9d1318 --- /dev/null +++ b/.travis/integration.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +yarn +# We have to build other packages first, as we use absolute packages import in TypeScript +yarn build +cd packages/browser +yarn test:integration + diff --git a/.travis/lint.sh b/.travis/lint.sh index 6b4121cdf6f4..7cbf27bd0c69 100755 --- a/.travis/lint.sh +++ b/.travis/lint.sh @@ -1,23 +1,8 @@ #!/bin/bash set -e -source .travis/before_script.sh +yarn +# We have to build it first, so that TypeScript Types are recognized correctly +yarn build +yarn lint -# Run @sentry/* -yarn && yarn build && yarn lint - -# Run raven-node -if [[ ("$RAVEN_NODE_CHANGES" = "true" || "$TRAVIS_PULL_REQUEST" = "false" ) ]]; then - cd packages/raven-node - npm install - npm run lint - cd ../.. -fi - -# Run raven-js -if [[ ("$RAVEN_JS_CHANGES" = "true" || "$TRAVIS_PULL_REQUEST" = "false" ) ]]; then - cd packages/raven-js - npm install - npm run lint - cd ../.. -fi diff --git a/.travis/raven-js-saucelabs.sh b/.travis/raven-js-saucelabs.sh new file mode 100755 index 000000000000..26a1710467b4 --- /dev/null +++ b/.travis/raven-js-saucelabs.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +RAVEN="raven-js" +source .travis/detect-raven.sh + +if [[ $SHOULD_RUN == "true" ]]; then + cd packages/raven-js + npm install + npm run test:ci +fi + diff --git a/.travis/raven-js.sh b/.travis/raven-js.sh new file mode 100755 index 000000000000..d4861e890ffc --- /dev/null +++ b/.travis/raven-js.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +RAVEN="raven-js" +source .travis/detect-raven.sh + +if [[ $SHOULD_RUN == "true" ]]; then + cd packages/raven-js + npm install + npm run lint + npm run test +fi + diff --git a/.travis/raven-node.sh b/.travis/raven-node.sh new file mode 100755 index 000000000000..8439385c5a36 --- /dev/null +++ b/.travis/raven-node.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +RAVEN="raven-node" +source .travis/detect-raven.sh + +if [[ $SHOULD_RUN == "true" ]]; then + cd packages/raven-node + npm install + npm run lint + npm run test +fi + diff --git a/.travis/script.sh b/.travis/script.sh deleted file mode 100755 index e3504bfbaef1..000000000000 --- a/.travis/script.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -set -e - -source .travis/before_script.sh - -# Run raven-node -if [[ ("$RAVEN_NODE_CHANGES" = "true" || "$TRAVIS_PULL_REQUEST" = "false" ) ]]; then - cd packages/raven-node - npm install - if [[ "$TRAVIS_SECURE_ENV_VARS" = "true" ]]; then - npm run test-full - else - npm run test - fi - cd ../.. -fi - -# Run raven-js -if [[ ("$RAVEN_JS_CHANGES" = "true" || "$TRAVIS_PULL_REQUEST" = "false" ) && ${NODE_VERSION:1:1} -eq 8 ]]; then - cd packages/raven-js - npm install - npm run test - if [[ "$TRAVIS_SECURE_ENV_VARS" = "true" ]]; then - npm run test:ci - fi - cd ../.. -fi - -# Run @sentry/* -if [ ${NODE_VERSION:1:1} -gt 5 ]; then - yarn && yarn build && yarn test && yarn codecov -fi diff --git a/.travis/test.sh b/.travis/test.sh new file mode 100755 index 000000000000..0f0c2de558b9 --- /dev/null +++ b/.travis/test.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +yarn +yarn build +yarn test +yarn codecov + diff --git a/packages/browser/karma/karma.integration.config.js b/packages/browser/karma/karma.integration.config.js new file mode 100644 index 000000000000..9e84ec46481f --- /dev/null +++ b/packages/browser/karma/karma.integration.config.js @@ -0,0 +1,52 @@ +module.exports = config => { + config.set({ + colors: true, + singleRun: true, + autoWatch: false, + basePath: process.cwd(), + files: [ + { pattern: 'test/integration/polyfills/es6-promise-4.2.4.js', included: false }, + { pattern: 'test/integration/polyfills/whatwg-fetch-2.0.4.js', included: false }, + { pattern: 'test/integration/123', included: false }, + { pattern: 'test/integration/throw-string.js', included: false }, + { pattern: 'test/integration/throw-error.js', included: false }, + { pattern: 'test/integration/throw-object.js', included: false }, + { pattern: 'test/integration/example.json', included: false }, + { pattern: 'test/integration/frame.html', included: false }, + { pattern: 'build/bundle.js', included: false }, + { pattern: 'build/bundle.js.map', included: false }, + 'test/integration/test.js', + ], + frameworks: ['mocha', 'chai', 'sinon'], + plugins: [ + 'karma-mocha', + 'karma-mocha-reporter', + 'karma-chai', + 'karma-sinon', + 'karma-chrome-launcher', + 'karma-firefox-launcher', + 'karma-failed-reporter', + ], + reporters: ['mocha'], + browsers: ['ChromeHeadlessNoSandbox', 'FirefoxHeadless'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox', '--disable-setuid-sandbox'], + }, + FirefoxHeadless: { + base: 'Firefox', + flags: ['-headless'], + }, + }, + // https://docs.travis-ci.com/user/gui-and-headless-browsers/#Karma-and-Firefox-inactivity-timeouts + browserNoActivityTimeout: 30000, + concurrency: 2, + client: { + mocha: { + reporter: 'html', + ui: 'bdd', + }, + }, + }); +}; diff --git a/packages/browser/karma.config.js b/packages/browser/karma/karma.unit.config.js similarity index 95% rename from packages/browser/karma.config.js rename to packages/browser/karma/karma.unit.config.js index edfedeb300f8..19544a20546e 100644 --- a/packages/browser/karma.config.js +++ b/packages/browser/karma/karma.unit.config.js @@ -1,19 +1,16 @@ -module.exports = function(config) { +module.exports = config => { config.set({ colors: true, singleRun: true, autoWatch: false, - + basePath: process.cwd(), + files: ['test/**/*.ts', 'src/**/*.+(js|ts)'], frameworks: ['mocha', 'chai', 'sinon', 'karma-typescript'], browsers: ['ChromeHeadless'], reporters: ['mocha', 'karma-typescript'], - - basePath: process.cwd(), - files: ['test/**/*.ts', 'src/**/*.+(js|ts)'], preprocessors: { '**/*.+(js|ts)': ['karma-typescript'], }, - karmaTypescriptConfig: { tsconfig: 'tsconfig.json', compilerOptions: { @@ -30,7 +27,6 @@ module.exports = function(config) { 'text-summary': '', }, }, - // Uncomment if you want to silence console logs in the output // client: { // captureConsole: false, diff --git a/packages/browser/package.json b/packages/browser/package.json index d556b36c4104..7619e515153b 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -28,6 +28,8 @@ "karma": "^2.0.2", "karma-chai": "^0.1.0", "karma-chrome-launcher": "^2.2.0", + "karma-failed-reporter": "0.0.3", + "karma-firefox-launcher": "^1.1.0", "karma-mocha": "^1.3.0", "karma-mocha-reporter": "^2.2.5", "karma-rollup-preprocessor": "^6.0.0", @@ -58,8 +60,10 @@ "fix": "run-s fix:tslint fix:prettier", "fix:prettier": "prettier --write '{src,test}/**/*.ts'", "fix:tslint": "tslint --fix -t stylish -p .", - "test": "karma start karma.config.js", - "test:watch": "karma start karma.config.js --auto-watch --no-single-run", + "test": "karma start karma/karma.unit.config.js", + "test:watch": "karma start karma/karma.unit.config.js --auto-watch --no-single-run", + "test:integration": "karma start karma/karma.integration.config.js", + "test:integration:watch": "karma start karma/karma.integration.config.js --auto-watch --no-single-run", "size:check": "cat build/bundle.min.js | gzip -9 | wc -c | awk '{$1=$1/1024; print $1,\"kB\";}'", "version": "node ../../scripts/versionbump.js src/version.ts" }, diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index f24f5ee30bca..2559c9087219 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -8,6 +8,32 @@ const commitHash = require('child_process') .execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }) .trim(); +const bundleConfig = { + input: 'src/index.ts', + output: { + format: 'iife', + name: 'Sentry', + sourcemap: true, + }, + context: 'window', + plugins: [ + typescript({ + tsconfig: 'tsconfig.build.json', + tsconfigOverride: { compilerOptions: { declaration: false } }, + }), + resolve({ + jsnext: true, + main: true, + browser: true, + }), + commonjs(), + license({ + sourcemap: true, + banner: `/*! @sentry/browser <%= pkg.version %> (${commitHash}) | https://github.com/getsentry/raven-js */`, + }), + ], +}; + export default [ { input: 'src/index.ts', @@ -30,31 +56,19 @@ export default [ commonjs(), ], }, - { - input: 'src/index.ts', - output: { + Object.assign({}, bundleConfig, { + output: Object.assign({}, bundleConfig.output, { file: 'build/bundle.min.js', - format: 'iife', - name: 'Sentry', - sourcemap: true, - }, - context: 'window', - plugins: [ - typescript({ - tsconfig: 'tsconfig.build.json', - tsconfigOverride: { compilerOptions: { declaration: false } }, - }), - resolve({ - jsnext: true, - main: true, - browser: true, - }), - commonjs(), - uglify(), - license({ - sourcemap: true, - banner: `/*! @sentry/browser <%= pkg.version %> (${commitHash}) | https://github.com/getsentry/raven-js */`, - }), - ], - }, + }), + }), + Object.assign({}, bundleConfig, { + output: Object.assign({}, bundleConfig.output, { + file: 'build/bundle.js', + }), + // Uglify has to be at the end of compilation, BUT before the license banner + plugins: bundleConfig.plugins + .slice(0, -1) + .concat(uglify()) + .concat(bundleConfig.plugins.slice(-1)), + }), ]; diff --git a/packages/browser/src/backend.ts b/packages/browser/src/backend.ts index 59bf1d2a43eb..d08df5bfff46 100644 --- a/packages/browser/src/backend.ts +++ b/packages/browser/src/backend.ts @@ -2,7 +2,7 @@ import { Backend, logger, Options, SentryError } from '@sentry/core'; import { SentryEvent, SentryResponse, Status } from '@sentry/types'; import { isDOMError, isDOMException, isError, isErrorEvent, isPlainObject } from '@sentry/utils/is'; import { supportsFetch } from '@sentry/utils/supports'; -import { eventFromStacktrace, getEventOptionsFromPlainObject, prepareFramesForEvent } from './parsers'; +import { eventFromPlainObject, eventFromStacktrace, prepareFramesForEvent } from './parsers'; import { computeStackTrace } from './tracekit'; import { FetchTransport, XHRTransport } from './transports'; @@ -58,10 +58,13 @@ export class BrowserBackend implements Backend { * @inheritDoc */ public async eventFromException(exception: any, syntheticException: Error | null): Promise { + let event; + if (isErrorEvent(exception as ErrorEvent) && (exception as ErrorEvent).error) { // If it is an ErrorEvent with `error` property, extract it to get actual Error const ex = exception as ErrorEvent; exception = ex.error; // tslint:disable-line:no-parameter-reassignment + event = eventFromStacktrace(computeStackTrace(exception as Error)); } else if (isDOMError(exception as DOMError) || isDOMException(exception as DOMException)) { // If it is a DOMError or DOMException (which are legacy APIs, but still supported in some browsers) // then we just extract the name and message, as they don't provide anything else @@ -71,16 +74,16 @@ export class BrowserBackend implements Backend { const name = ex.name || (isDOMError(ex) ? 'DOMError' : 'DOMException'); const message = ex.message ? `${name}: ${ex.message}` : name; - return this.eventFromMessage(message, syntheticException); + event = await this.eventFromMessage(message, syntheticException); } else if (isError(exception as Error)) { // we have a real Error object, do nothing + event = eventFromStacktrace(computeStackTrace(exception as Error)); } else if (isPlainObject(exception as {})) { // If it is plain Object, serialize it manually and extract options // This will allow us to group events based on top-level keys // which is much better than creating new group when any key/value change const ex = exception as {}; - const options = getEventOptionsFromPlainObject(ex); - exception = new Error(options.message); // tslint:disable-line:no-parameter-reassignment + event = eventFromPlainObject(ex, syntheticException); } else { // If none of previous checks were valid, then it means that // it's not a DOMError/DOMException @@ -89,11 +92,9 @@ export class BrowserBackend implements Backend { // it's not an Error // So bail out and capture it as a simple message: const ex = exception as string; - return this.eventFromMessage(ex, syntheticException); + event = await this.eventFromMessage(ex, syntheticException); } - let event: SentryEvent = eventFromStacktrace(computeStackTrace(exception as Error)); - event = { ...event, exception: { diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 6dd976ac175c..a8eda53f686f 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -1,3 +1,4 @@ +import { DSN } from '@sentry/core'; import { getCurrentHub } from '@sentry/hub'; import { Integration, Severity } from '@sentry/types'; import { isFunction, isString } from '@sentry/utils/is'; @@ -5,6 +6,7 @@ import { getGlobalObject, parseUrl } from '@sentry/utils/misc'; import { fill } from '@sentry/utils/object'; import { safeJoin } from '@sentry/utils/string'; import { supportsFetch, supportsHistory } from '@sentry/utils/supports'; +import { BrowserOptions } from '../backend'; import { breadcrumbEventHandler, keypressEventHandler, wrap } from './helpers'; const global = getGlobalObject() as Window; @@ -36,7 +38,7 @@ export class Breadcrumbs implements Integration { * @inheritDoc */ public constructor( - private readonly options: { + private readonly config: { console?: boolean; dom?: boolean; fetch?: boolean; @@ -109,7 +111,7 @@ export class Breadcrumbs implements Integration { } /** JSDoc */ - private instrumentFetch(): void { + private instrumentFetch(options: { filterUrl?: string }): void { if (!supportsFetch()) { return; } @@ -131,6 +133,11 @@ export class Breadcrumbs implements Integration { url = String(fetchInput); } + // if Sentry key appears in URL, don't capture, as it's our own request + if (options.filterUrl && url.includes(options.filterUrl)) { + return originalFetch.apply(global, args); + } + if (args[1] && args[1].method) { method = args[1].method; } @@ -178,7 +185,12 @@ export class Breadcrumbs implements Integration { const captureUrlChange = (from: string | undefined, to: string | undefined): void => { const parsedLoc = parseUrl(global.location.href); const parsedTo = parseUrl(to as string); - const parsedFrom = parseUrl(from as string); + let parsedFrom = parseUrl(from as string); + + // Initial pushState doesn't provide `from` information + if (!parsedFrom.path) { + parsedFrom = parsedLoc; + } // because onpopstate only tells you the "new" (to) value of location.href, and // not the previous (from) value, we need to track the value of the current URL @@ -211,7 +223,7 @@ export class Breadcrumbs implements Integration { const currentHref = global.location.href; captureUrlChange(lastHref, currentHref); if (oldOnPopState) { - return oldOnPopState.apply(global, args); + return oldOnPopState.apply(this, args); } }; @@ -219,14 +231,14 @@ export class Breadcrumbs implements Integration { function historyReplacementFunction(originalHistoryFunction: () => void): () => void { // note history.pushState.length is 0; intentionally not declaring // params to preserve 0 arity - return function(...args: any[]): void { + return function(this: History, ...args: any[]): void { const url = args.length > 2 ? args[2] : undefined; // url argument is optional if (url) { // coerce to string (this is what pushState does) captureUrlChange(lastHref, String(url)); } - return originalHistoryFunction.apply(global, ...args); + return originalHistoryFunction.apply(this, args); }; } @@ -234,7 +246,7 @@ export class Breadcrumbs implements Integration { fill(global.history, 'replaceState', historyReplacementFunction); } /** JSDoc */ - private instrumentXHR(): void { + private instrumentXHR(options: { filterUrl?: string }): void { if (!('XMLHttpRequest' in global)) { return; } @@ -250,6 +262,7 @@ export class Breadcrumbs implements Integration { function: prop, handler: (original && original.name) || '', }, + handled: true, type: 'instrument', }, }), @@ -263,7 +276,9 @@ export class Breadcrumbs implements Integration { 'open', originalOpen => function(this: SentryWrappedXMLHttpRequest, ...args: any[]): void { - if (isString(args[1])) { + const url = args[1]; + // if Sentry key appears in URL, don't capture, as it's our own request + if (isString(url) && (options.filterUrl && !url.includes(options.filterUrl))) { this.__sentry_xhr__ = { method: args[0], url: args[1], @@ -312,6 +327,7 @@ export class Breadcrumbs implements Integration { function: 'onreadystatechange', handler: (original && original.name) || '', }, + handled: true, type: 'instrument', }, }, @@ -323,7 +339,7 @@ export class Breadcrumbs implements Integration { // are free to set our own and capture the breadcrumb xhr.onreadystatechange = onreadystatechangeHandler; } - return originalSend.apply(XMLHttpRequest, args); + return originalSend.apply(this, args); }, ); } @@ -337,20 +353,23 @@ export class Breadcrumbs implements Integration { * * Can be disabled or individually configured via the `autoBreadcrumbs` config option */ - public install(): void { - if (this.options.console) { + public install(options: BrowserOptions = {}): void { + // TODO: Use API provider instead of raw `new DSN` + const filterUrl = options.dsn && new DSN(options.dsn).user; + + if (this.config.console) { this.instrumentConsole(); } - if (this.options.dom) { + if (this.config.dom) { this.instrumentDOM(); } - if (this.options.xhr) { - this.instrumentXHR(); + if (this.config.xhr) { + this.instrumentXHR({ filterUrl }); } - if (this.options.fetch) { - this.instrumentFetch(); + if (this.config.fetch) { + this.instrumentFetch({ filterUrl }); } - if (this.options.history) { + if (this.config.history) { this.instrumentHistory(); } } diff --git a/packages/browser/src/integrations/dedupe.ts b/packages/browser/src/integrations/dedupe.ts new file mode 100644 index 000000000000..25ffd1d9190f --- /dev/null +++ b/packages/browser/src/integrations/dedupe.ts @@ -0,0 +1,145 @@ +import { logger } from '@sentry/core'; +import { getCurrentHub } from '@sentry/hub'; +import { Integration, SentryEvent, SentryException, StackFrame } from '@sentry/types'; + +/** Deduplication filter */ +export class Dedupe implements Integration { + /** + * @inheritDoc + */ + private previousEvent?: SentryEvent; + + /** + * @inheritDoc + */ + public name: string = 'Dedupe'; + + /** + * @inheritDoc + */ + public install(): void { + getCurrentHub().addEventProcessor(async (event: SentryEvent) => { + // Juuust in case something goes wrong + try { + if (this.shouldDropEvent(event)) { + return null; + } + } catch (_oO) { + return (this.previousEvent = event); + } + + return (this.previousEvent = event); + }); + } + + /** JSDoc */ + public shouldDropEvent(event: SentryEvent): boolean { + if (!this.previousEvent) { + return false; + } + + if (this.isSameMessage(event)) { + logger.warn( + `Event dropped due to being a duplicate of previous event (same message).\n Event: ${event.event_id}`, + ); + return true; + } + + if (this.isSameException(event)) { + logger.warn( + `Event dropped due to being a duplicate of previous event (same exception).\n Event: ${event.event_id}`, + ); + return true; + } + + if (this.isSameStacktrace(event)) { + logger.warn( + `Event dropped due to being a duplicate of previous event (same stacktrace).\n Event: ${event.event_id}`, + ); + return true; + } + + return false; + } + + /** JSDoc */ + private isSameMessage(event: SentryEvent): boolean { + if (!this.previousEvent) { + return false; + } + return !!(event.message && this.previousEvent.message && event.message === this.previousEvent.message); + } + + /** JSDoc */ + private getFramesFromEvent(event: SentryEvent): StackFrame[] | undefined { + const exception = event.exception; + + if (exception) { + try { + // @ts-ignore + return exception.values[0].stacktrace.frames; + } catch (_oO) { + return undefined; + } + } else if (event.stacktrace) { + return event.stacktrace.frames; + } else { + return undefined; + } + } + + /** JSDoc */ + private isSameStacktrace(event: SentryEvent): boolean { + if (!this.previousEvent) { + return false; + } + + const previousFrames = this.getFramesFromEvent(this.previousEvent); + const currentFrames = this.getFramesFromEvent(event); + + if (!previousFrames || !currentFrames || previousFrames.length !== currentFrames.length) { + return false; + } + + for (let i = 0; i < previousFrames.length; i++) { + const frameA = previousFrames[i]; + const frameB = currentFrames[i]; + + if ( + frameA.filename !== frameB.filename || + frameA.lineno !== frameB.lineno || + frameA.colno !== frameB.colno || + frameA.function !== frameB.function + ) { + return false; + } + } + + return true; + } + + /** JSDoc */ + private getExceptionFromEvent(event: SentryEvent): SentryException | undefined { + return event.exception && event.exception.values && event.exception.values[0]; + } + + /** JSDoc */ + private isSameException(event: SentryEvent): boolean { + if (!this.previousEvent) { + return false; + } + + const previousException = this.getExceptionFromEvent(this.previousEvent); + const currentException = this.getExceptionFromEvent(event); + + if (!previousException || !currentException) { + return false; + } + + if (previousException.type !== currentException.type || previousException.value !== currentException.value) { + return false; + } + + return this.isSameStacktrace(event); + } +} diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index dd159849628e..5048fa424347 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -8,6 +8,7 @@ import { StackTrace as TraceKitStackTrace, subscribe, } from '../tracekit'; +import { shouldIgnoreOnError } from './helpers'; /** Global handlers */ export class GlobalHandlers implements Integration { @@ -43,6 +44,9 @@ export class GlobalHandlers implements Integration { // 9: " foo();" // 10: " }" // ] + if (shouldIgnoreOnError()) { + return; + } captureEvent(this.eventFromGlobalHandler(stack)); }); diff --git a/packages/browser/src/integrations/helpers.ts b/packages/browser/src/integrations/helpers.ts index 68343fbecdd3..810c2bf9c013 100644 --- a/packages/browser/src/integrations/helpers.ts +++ b/packages/browser/src/integrations/helpers.ts @@ -1,16 +1,17 @@ import { getCurrentHub } from '@sentry/hub'; -import { SentryEvent, SentryWrappedFunction } from '@sentry/types'; +import { Mechanism, SentryEvent, SentryWrappedFunction } from '@sentry/types'; import { isFunction } from '@sentry/utils/is'; import { htmlTreeAsString } from '@sentry/utils/misc'; const debounceDuration: number = 1000; let keypressTimeout: number | undefined; let lastCapturedEvent: Event | undefined; -let ignoreOnError: number = -1; - -// TODO: Fix `ignoreNextOnError`. Just temporary build fix for unused variable -ignoreOnError = ignoreOnError + 1; +let ignoreOnError: number = 0; +/** JSDoc */ +export function shouldIgnoreOnError(): boolean { + return ignoreOnError > 0; +} /** JSDoc */ export function ignoreNextOnError(): void { // onerror should trigger before setTimeout @@ -29,9 +30,9 @@ export function ignoreNextOnError(): void { */ export function wrap( fn: SentryWrappedFunction, - options?: { - mechanism?: object; - }, + options: { + mechanism?: Mechanism; + } = {}, before?: SentryWrappedFunction, ): any { try { @@ -65,10 +66,16 @@ export function wrap( ignoreNextOnError(); getCurrentHub().withScope(async () => { - getCurrentHub().addEventProcessor(async (event: SentryEvent) => ({ - ...event, - ...(options && options.mechanism), - })); + getCurrentHub().addEventProcessor(async (event: SentryEvent) => { + const processedEvent = { ...event }; + + if (options.mechanism) { + processedEvent.exception = processedEvent.exception || {}; + processedEvent.exception.mechanism = options.mechanism; + } + + return processedEvent; + }); getCurrentHub().captureException(ex); }); diff --git a/packages/browser/src/integrations/index.ts b/packages/browser/src/integrations/index.ts index 805082372f84..417d37ccdad1 100644 --- a/packages/browser/src/integrations/index.ts +++ b/packages/browser/src/integrations/index.ts @@ -1,4 +1,5 @@ export { GlobalHandlers } from './globalhandlers'; +export { Dedupe } from './dedupe'; export { FunctionToString } from './functiontostring'; export { TryCatch } from './trycatch'; export { Breadcrumbs } from './breadcrumbs'; diff --git a/packages/browser/src/integrations/trycatch.ts b/packages/browser/src/integrations/trycatch.ts index 016221a319b9..c47ff67b7b77 100644 --- a/packages/browser/src/integrations/trycatch.ts +++ b/packages/browser/src/integrations/trycatch.ts @@ -20,6 +20,7 @@ export class TryCatch implements Integration { args[0] = wrap(originalCallback, { mechanism: { data: { function: original.name || '' }, + handled: true, type: 'instrument', }, }); @@ -37,6 +38,7 @@ export class TryCatch implements Integration { function: 'requestAnimationFrame', handler: (original && original.name) || '', }, + handled: true, type: 'instrument', }, }), @@ -70,6 +72,7 @@ export class TryCatch implements Integration { handler: ((fn as any) as SentryWrappedFunction).name || '', target, }, + handled: true, type: 'instrument', }, }); @@ -124,6 +127,7 @@ export class TryCatch implements Integration { handler: ((fn as any) as SentryWrappedFunction).name || '', target, }, + handled: true, type: 'instrument', }, }, diff --git a/packages/browser/src/parsers.ts b/packages/browser/src/parsers.ts index 355048ba1edf..338055c2dd81 100644 --- a/packages/browser/src/parsers.ts +++ b/packages/browser/src/parsers.ts @@ -1,7 +1,7 @@ import { SentryEvent, StackFrame } from '@sentry/types'; import { limitObjectDepthToSize, serializeKeysToEventMessage } from '@sentry/utils/object'; import * as md5proxy from 'md5'; -import { StackFrame as TraceKitStackFrame, StackTrace as TraceKitStackTrace } from './tracekit'; +import { computeStackTrace, StackFrame as TraceKitStackFrame, StackTrace as TraceKitStackTrace } from './tracekit'; // Workaround for Rollup issue with overloading namespaces // https://github.com/rollup/rollup/issues/1267#issuecomment-296395734 @@ -10,21 +10,25 @@ const md5 = ((md5proxy as any).default || md5proxy) as (input: string) => string const STACKTRACE_LIMIT = 50; /** JSDoc */ -export function getEventOptionsFromPlainObject(exception: {}): { - extra: { - __serialized__: object; - }; - fingerprint: [string]; - message: string; -} { +export function eventFromPlainObject(exception: {}, syntheticException: Error | null): SentryEvent { const exceptionKeys = Object.keys(exception).sort(); - return { + const event: SentryEvent = { extra: { __serialized__: limitObjectDepthToSize(exception), }, fingerprint: [md5(exceptionKeys.join(''))], message: `Non-Error exception captured with keys: ${serializeKeysToEventMessage(exceptionKeys)}`, }; + + if (syntheticException) { + const stacktrace = computeStackTrace(syntheticException); + const frames = prepareFramesForEvent(stacktrace.stack); + event.stacktrace = { + frames, + }; + } + + return event; } /** JSDoc */ diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 0a1fc5e34252..8d3fe733b75a 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -3,6 +3,7 @@ import { BrowserOptions } from './backend'; import { BrowserClient } from './client'; import { Breadcrumbs, + Dedupe, FunctionToString, GlobalHandlers, InboundFilters, @@ -11,6 +12,7 @@ import { } from './integrations'; export const defaultIntegrations = [ + new Dedupe(), new FunctionToString(), new TryCatch(), new Breadcrumbs(), diff --git a/packages/browser/src/tracekit/index.js b/packages/browser/src/tracekit/index.js index 7c382f9483be..8f884cc7daa7 100644 --- a/packages/browser/src/tracekit/index.js +++ b/packages/browser/src/tracekit/index.js @@ -230,6 +230,7 @@ TraceKit.report = (function reportModuleWrapper() { * @memberof TraceKit.report */ function traceKitWindowOnError(message, url, lineNo, columnNo, errorObj) { + debugger; var stack = null; // If 'errorObj' is ErrorEvent, get real Error from inside errorObj = isErrorEvent(errorObj) ? errorObj.error : errorObj; @@ -265,7 +266,15 @@ TraceKit.report = (function reportModuleWrapper() { name: name, message: msg, mode: 'onerror', - stack: [location], + stack: [ + { + ...location, + // Firefox sometimes doesn't return url correctly and this is an old behavior + // that I prefer to port here as well. + // It can be altered only here, as previously it's using `location.url` for other things — Kamil + url: location.url || getLocationHref(), + }, + ], }; notifyHandlers(stack, true, null); @@ -841,8 +850,9 @@ TraceKit.computeStackTrace = (function computeStackTraceWrapper() { if (isEval && (submatch = chromeEval.exec(parts[2]))) { // throw out eval line/column and use top-most line/column number parts[2] = submatch[1]; // url - parts[3] = submatch[2]; // line - parts[4] = submatch[3]; // column + // NOTE: It's messing out our integration tests in Karma, let's see if we can live with it – Kamil + // parts[3] = submatch[2]; // line + // parts[4] = submatch[3]; // column } element = { url: !isNative ? parts[2] : null, @@ -864,8 +874,9 @@ TraceKit.computeStackTrace = (function computeStackTraceWrapper() { if (isEval && (submatch = geckoEval.exec(parts[3]))) { // throw out eval line/column and use top-most line number parts[3] = submatch[1]; - parts[4] = submatch[2]; - parts[5] = null; // no column when eval + // NOTE: It's messing out our integration tests in Karma, let's see if we can live with it – Kamil + // parts[4] = submatch[2]; + // parts[5] = null; // no column when eval } else if (i === 0 && !parts[5] && !isUndefined(ex.columnNumber)) { // FireFox uses this awesome columnNumber property for its top frame // Also note, Firefox's column number is 0-based and everything else expects 1-based, diff --git a/packages/browser/test/index.test.ts b/packages/browser/test/index.test.ts index c5870e3f57aa..eec771a1ed77 100644 --- a/packages/browser/test/index.test.ts +++ b/packages/browser/test/index.test.ts @@ -146,14 +146,14 @@ describe('SentryBrowser', () => { getCurrentHub().bindClient( new BrowserClient({ afterSend: (event: SentryEvent) => { - expect(event.message).to.equal('test'); + expect(event.message).to.equal('event'); expect(event.exception).to.be.undefined; done(); }, dsn, }), ); - captureEvent({ message: 'test' }); + captureEvent({ message: 'event' }); getCurrentHub().popScope(); }); }); diff --git a/packages/browser/test/integration/123 b/packages/browser/test/integration/123 new file mode 100644 index 000000000000..190a18037c64 --- /dev/null +++ b/packages/browser/test/integration/123 @@ -0,0 +1 @@ +123 diff --git a/packages/browser/test/integration/example.json b/packages/browser/test/integration/example.json new file mode 100644 index 000000000000..c8c4105eb57c --- /dev/null +++ b/packages/browser/test/integration/example.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/packages/browser/test/integration/frame.html b/packages/browser/test/integration/frame.html new file mode 100644 index 000000000000..8dab2ef71339 --- /dev/null +++ b/packages/browser/test/integration/frame.html @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + +
+ +
+
+ +
+
+
+
+
+ + + diff --git a/packages/browser/test/integration/polyfills/es6-promise-4.2.4.js b/packages/browser/test/integration/polyfills/es6-promise-4.2.4.js new file mode 100644 index 000000000000..c3bbb9fb1676 --- /dev/null +++ b/packages/browser/test/integration/polyfills/es6-promise-4.2.4.js @@ -0,0 +1,1178 @@ +/*! + * @overview es6-promise - a tiny implementation of Promises/A+. + * @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald) + * @license Licensed under MIT license + * See https://raw.githubusercontent.com/stefanpenner/es6-promise/master/LICENSE + * @version v4.2.4+314e4831 + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.ES6Promise = factory()); +}(this, (function () { 'use strict'; + +function objectOrFunction(x) { + var type = typeof x; + return x !== null && (type === 'object' || type === 'function'); +} + +function isFunction(x) { + return typeof x === 'function'; +} + + + +var _isArray = void 0; +if (Array.isArray) { + _isArray = Array.isArray; +} else { + _isArray = function (x) { + return Object.prototype.toString.call(x) === '[object Array]'; + }; +} + +var isArray = _isArray; + +var len = 0; +var vertxNext = void 0; +var customSchedulerFn = void 0; + +var asap = function asap(callback, arg) { + queue[len] = callback; + queue[len + 1] = arg; + len += 2; + if (len === 2) { + // If len is 2, that means that we need to schedule an async flush. + // If additional callbacks are queued before the queue is flushed, they + // will be processed by this flush that we are scheduling. + if (customSchedulerFn) { + customSchedulerFn(flush); + } else { + scheduleFlush(); + } + } +}; + +function setScheduler(scheduleFn) { + customSchedulerFn = scheduleFn; +} + +function setAsap(asapFn) { + asap = asapFn; +} + +var browserWindow = typeof window !== 'undefined' ? window : undefined; +var browserGlobal = browserWindow || {}; +var BrowserMutationObserver = browserGlobal.MutationObserver || browserGlobal.WebKitMutationObserver; +var isNode = typeof self === 'undefined' && typeof process !== 'undefined' && {}.toString.call(process) === '[object process]'; + +// test for web worker but not in IE10 +var isWorker = typeof Uint8ClampedArray !== 'undefined' && typeof importScripts !== 'undefined' && typeof MessageChannel !== 'undefined'; + +// node +function useNextTick() { + // node version 0.10.x displays a deprecation warning when nextTick is used recursively + // see https://github.com/cujojs/when/issues/410 for details + return function () { + return process.nextTick(flush); + }; +} + +// vertx +function useVertxTimer() { + if (typeof vertxNext !== 'undefined') { + return function () { + vertxNext(flush); + }; + } + + return useSetTimeout(); +} + +function useMutationObserver() { + var iterations = 0; + var observer = new BrowserMutationObserver(flush); + var node = document.createTextNode(''); + observer.observe(node, { characterData: true }); + + return function () { + node.data = iterations = ++iterations % 2; + }; +} + +// web worker +function useMessageChannel() { + var channel = new MessageChannel(); + channel.port1.onmessage = flush; + return function () { + return channel.port2.postMessage(0); + }; +} + +function useSetTimeout() { + // Store setTimeout reference so es6-promise will be unaffected by + // other code modifying setTimeout (like sinon.useFakeTimers()) + var globalSetTimeout = setTimeout; + return function () { + return globalSetTimeout(flush, 1); + }; +} + +var queue = new Array(1000); +function flush() { + for (var i = 0; i < len; i += 2) { + var callback = queue[i]; + var arg = queue[i + 1]; + + callback(arg); + + queue[i] = undefined; + queue[i + 1] = undefined; + } + + len = 0; +} + +function attemptVertx() { + try { + var vertx = Function('return this')().require('vertx'); + vertxNext = vertx.runOnLoop || vertx.runOnContext; + return useVertxTimer(); + } catch (e) { + return useSetTimeout(); + } +} + +var scheduleFlush = void 0; +// Decide what async method to use to triggering processing of queued callbacks: +if (isNode) { + scheduleFlush = useNextTick(); +} else if (BrowserMutationObserver) { + scheduleFlush = useMutationObserver(); +} else if (isWorker) { + scheduleFlush = useMessageChannel(); +} else if (browserWindow === undefined && typeof require === 'function') { + scheduleFlush = attemptVertx(); +} else { + scheduleFlush = useSetTimeout(); +} + +function then(onFulfillment, onRejection) { + var parent = this; + + var child = new this.constructor(noop); + + if (child[PROMISE_ID] === undefined) { + makePromise(child); + } + + var _state = parent._state; + + + if (_state) { + var callback = arguments[_state - 1]; + asap(function () { + return invokeCallback(_state, child, callback, parent._result); + }); + } else { + subscribe(parent, child, onFulfillment, onRejection); + } + + return child; +} + +/** + `Promise.resolve` returns a promise that will become resolved with the + passed `value`. It is shorthand for the following: + + ```javascript + let promise = new Promise(function(resolve, reject){ + resolve(1); + }); + + promise.then(function(value){ + // value === 1 + }); + ``` + + Instead of writing the above, your code now simply becomes the following: + + ```javascript + let promise = Promise.resolve(1); + + promise.then(function(value){ + // value === 1 + }); + ``` + + @method resolve + @static + @param {Any} value value that the returned promise will be resolved with + Useful for tooling. + @return {Promise} a promise that will become fulfilled with the given + `value` +*/ +function resolve$1(object) { + /*jshint validthis:true */ + var Constructor = this; + + if (object && typeof object === 'object' && object.constructor === Constructor) { + return object; + } + + var promise = new Constructor(noop); + resolve(promise, object); + return promise; +} + +var PROMISE_ID = Math.random().toString(36).substring(2); + +function noop() {} + +var PENDING = void 0; +var FULFILLED = 1; +var REJECTED = 2; + +var TRY_CATCH_ERROR = { error: null }; + +function selfFulfillment() { + return new TypeError("You cannot resolve a promise with itself"); +} + +function cannotReturnOwn() { + return new TypeError('A promises callback cannot return that same promise.'); +} + +function getThen(promise) { + try { + return promise.then; + } catch (error) { + TRY_CATCH_ERROR.error = error; + return TRY_CATCH_ERROR; + } +} + +function tryThen(then$$1, value, fulfillmentHandler, rejectionHandler) { + try { + then$$1.call(value, fulfillmentHandler, rejectionHandler); + } catch (e) { + return e; + } +} + +function handleForeignThenable(promise, thenable, then$$1) { + asap(function (promise) { + var sealed = false; + var error = tryThen(then$$1, thenable, function (value) { + if (sealed) { + return; + } + sealed = true; + if (thenable !== value) { + resolve(promise, value); + } else { + fulfill(promise, value); + } + }, function (reason) { + if (sealed) { + return; + } + sealed = true; + + reject(promise, reason); + }, 'Settle: ' + (promise._label || ' unknown promise')); + + if (!sealed && error) { + sealed = true; + reject(promise, error); + } + }, promise); +} + +function handleOwnThenable(promise, thenable) { + if (thenable._state === FULFILLED) { + fulfill(promise, thenable._result); + } else if (thenable._state === REJECTED) { + reject(promise, thenable._result); + } else { + subscribe(thenable, undefined, function (value) { + return resolve(promise, value); + }, function (reason) { + return reject(promise, reason); + }); + } +} + +function handleMaybeThenable(promise, maybeThenable, then$$1) { + if (maybeThenable.constructor === promise.constructor && then$$1 === then && maybeThenable.constructor.resolve === resolve$1) { + handleOwnThenable(promise, maybeThenable); + } else { + if (then$$1 === TRY_CATCH_ERROR) { + reject(promise, TRY_CATCH_ERROR.error); + TRY_CATCH_ERROR.error = null; + } else if (then$$1 === undefined) { + fulfill(promise, maybeThenable); + } else if (isFunction(then$$1)) { + handleForeignThenable(promise, maybeThenable, then$$1); + } else { + fulfill(promise, maybeThenable); + } + } +} + +function resolve(promise, value) { + if (promise === value) { + reject(promise, selfFulfillment()); + } else if (objectOrFunction(value)) { + handleMaybeThenable(promise, value, getThen(value)); + } else { + fulfill(promise, value); + } +} + +function publishRejection(promise) { + if (promise._onerror) { + promise._onerror(promise._result); + } + + publish(promise); +} + +function fulfill(promise, value) { + if (promise._state !== PENDING) { + return; + } + + promise._result = value; + promise._state = FULFILLED; + + if (promise._subscribers.length !== 0) { + asap(publish, promise); + } +} + +function reject(promise, reason) { + if (promise._state !== PENDING) { + return; + } + promise._state = REJECTED; + promise._result = reason; + + asap(publishRejection, promise); +} + +function subscribe(parent, child, onFulfillment, onRejection) { + var _subscribers = parent._subscribers; + var length = _subscribers.length; + + + parent._onerror = null; + + _subscribers[length] = child; + _subscribers[length + FULFILLED] = onFulfillment; + _subscribers[length + REJECTED] = onRejection; + + if (length === 0 && parent._state) { + asap(publish, parent); + } +} + +function publish(promise) { + var subscribers = promise._subscribers; + var settled = promise._state; + + if (subscribers.length === 0) { + return; + } + + var child = void 0, + callback = void 0, + detail = promise._result; + + for (var i = 0; i < subscribers.length; i += 3) { + child = subscribers[i]; + callback = subscribers[i + settled]; + + if (child) { + invokeCallback(settled, child, callback, detail); + } else { + callback(detail); + } + } + + promise._subscribers.length = 0; +} + +function tryCatch(callback, detail) { + try { + return callback(detail); + } catch (e) { + TRY_CATCH_ERROR.error = e; + return TRY_CATCH_ERROR; + } +} + +function invokeCallback(settled, promise, callback, detail) { + var hasCallback = isFunction(callback), + value = void 0, + error = void 0, + succeeded = void 0, + failed = void 0; + + if (hasCallback) { + value = tryCatch(callback, detail); + + if (value === TRY_CATCH_ERROR) { + failed = true; + error = value.error; + value.error = null; + } else { + succeeded = true; + } + + if (promise === value) { + reject(promise, cannotReturnOwn()); + return; + } + } else { + value = detail; + succeeded = true; + } + + if (promise._state !== PENDING) { + // noop + } else if (hasCallback && succeeded) { + resolve(promise, value); + } else if (failed) { + reject(promise, error); + } else if (settled === FULFILLED) { + fulfill(promise, value); + } else if (settled === REJECTED) { + reject(promise, value); + } +} + +function initializePromise(promise, resolver) { + try { + resolver(function resolvePromise(value) { + resolve(promise, value); + }, function rejectPromise(reason) { + reject(promise, reason); + }); + } catch (e) { + reject(promise, e); + } +} + +var id = 0; +function nextId() { + return id++; +} + +function makePromise(promise) { + promise[PROMISE_ID] = id++; + promise._state = undefined; + promise._result = undefined; + promise._subscribers = []; +} + +function validationError() { + return new Error('Array Methods must be provided an Array'); +} + +var Enumerator = function () { + function Enumerator(Constructor, input) { + this._instanceConstructor = Constructor; + this.promise = new Constructor(noop); + + if (!this.promise[PROMISE_ID]) { + makePromise(this.promise); + } + + if (isArray(input)) { + this.length = input.length; + this._remaining = input.length; + + this._result = new Array(this.length); + + if (this.length === 0) { + fulfill(this.promise, this._result); + } else { + this.length = this.length || 0; + this._enumerate(input); + if (this._remaining === 0) { + fulfill(this.promise, this._result); + } + } + } else { + reject(this.promise, validationError()); + } + } + + Enumerator.prototype._enumerate = function _enumerate(input) { + for (var i = 0; this._state === PENDING && i < input.length; i++) { + this._eachEntry(input[i], i); + } + }; + + Enumerator.prototype._eachEntry = function _eachEntry(entry, i) { + var c = this._instanceConstructor; + var resolve$$1 = c.resolve; + + + if (resolve$$1 === resolve$1) { + var _then = getThen(entry); + + if (_then === then && entry._state !== PENDING) { + this._settledAt(entry._state, i, entry._result); + } else if (typeof _then !== 'function') { + this._remaining--; + this._result[i] = entry; + } else if (c === Promise$2) { + var promise = new c(noop); + handleMaybeThenable(promise, entry, _then); + this._willSettleAt(promise, i); + } else { + this._willSettleAt(new c(function (resolve$$1) { + return resolve$$1(entry); + }), i); + } + } else { + this._willSettleAt(resolve$$1(entry), i); + } + }; + + Enumerator.prototype._settledAt = function _settledAt(state, i, value) { + var promise = this.promise; + + + if (promise._state === PENDING) { + this._remaining--; + + if (state === REJECTED) { + reject(promise, value); + } else { + this._result[i] = value; + } + } + + if (this._remaining === 0) { + fulfill(promise, this._result); + } + }; + + Enumerator.prototype._willSettleAt = function _willSettleAt(promise, i) { + var enumerator = this; + + subscribe(promise, undefined, function (value) { + return enumerator._settledAt(FULFILLED, i, value); + }, function (reason) { + return enumerator._settledAt(REJECTED, i, reason); + }); + }; + + return Enumerator; +}(); + +/** + `Promise.all` accepts an array of promises, and returns a new promise which + is fulfilled with an array of fulfillment values for the passed promises, or + rejected with the reason of the first passed promise to be rejected. It casts all + elements of the passed iterable to promises as it runs this algorithm. + + Example: + + ```javascript + let promise1 = resolve(1); + let promise2 = resolve(2); + let promise3 = resolve(3); + let promises = [ promise1, promise2, promise3 ]; + + Promise.all(promises).then(function(array){ + // The array here would be [ 1, 2, 3 ]; + }); + ``` + + If any of the `promises` given to `all` are rejected, the first promise + that is rejected will be given as an argument to the returned promises's + rejection handler. For example: + + Example: + + ```javascript + let promise1 = resolve(1); + let promise2 = reject(new Error("2")); + let promise3 = reject(new Error("3")); + let promises = [ promise1, promise2, promise3 ]; + + Promise.all(promises).then(function(array){ + // Code here never runs because there are rejected promises! + }, function(error) { + // error.message === "2" + }); + ``` + + @method all + @static + @param {Array} entries array of promises + @param {String} label optional string for labeling the promise. + Useful for tooling. + @return {Promise} promise that is fulfilled when all `promises` have been + fulfilled, or rejected if any of them become rejected. + @static +*/ +function all(entries) { + return new Enumerator(this, entries).promise; +} + +/** + `Promise.race` returns a new promise which is settled in the same way as the + first passed promise to settle. + + Example: + + ```javascript + let promise1 = new Promise(function(resolve, reject){ + setTimeout(function(){ + resolve('promise 1'); + }, 200); + }); + + let promise2 = new Promise(function(resolve, reject){ + setTimeout(function(){ + resolve('promise 2'); + }, 100); + }); + + Promise.race([promise1, promise2]).then(function(result){ + // result === 'promise 2' because it was resolved before promise1 + // was resolved. + }); + ``` + + `Promise.race` is deterministic in that only the state of the first + settled promise matters. For example, even if other promises given to the + `promises` array argument are resolved, but the first settled promise has + become rejected before the other promises became fulfilled, the returned + promise will become rejected: + + ```javascript + let promise1 = new Promise(function(resolve, reject){ + setTimeout(function(){ + resolve('promise 1'); + }, 200); + }); + + let promise2 = new Promise(function(resolve, reject){ + setTimeout(function(){ + reject(new Error('promise 2')); + }, 100); + }); + + Promise.race([promise1, promise2]).then(function(result){ + // Code here never runs + }, function(reason){ + // reason.message === 'promise 2' because promise 2 became rejected before + // promise 1 became fulfilled + }); + ``` + + An example real-world use case is implementing timeouts: + + ```javascript + Promise.race([ajax('foo.json'), timeout(5000)]) + ``` + + @method race + @static + @param {Array} promises array of promises to observe + Useful for tooling. + @return {Promise} a promise which settles in the same way as the first passed + promise to settle. +*/ +function race(entries) { + /*jshint validthis:true */ + var Constructor = this; + + if (!isArray(entries)) { + return new Constructor(function (_, reject) { + return reject(new TypeError('You must pass an array to race.')); + }); + } else { + return new Constructor(function (resolve, reject) { + var length = entries.length; + for (var i = 0; i < length; i++) { + Constructor.resolve(entries[i]).then(resolve, reject); + } + }); + } +} + +/** + `Promise.reject` returns a promise rejected with the passed `reason`. + It is shorthand for the following: + + ```javascript + let promise = new Promise(function(resolve, reject){ + reject(new Error('WHOOPS')); + }); + + promise.then(function(value){ + // Code here doesn't run because the promise is rejected! + }, function(reason){ + // reason.message === 'WHOOPS' + }); + ``` + + Instead of writing the above, your code now simply becomes the following: + + ```javascript + let promise = Promise.reject(new Error('WHOOPS')); + + promise.then(function(value){ + // Code here doesn't run because the promise is rejected! + }, function(reason){ + // reason.message === 'WHOOPS' + }); + ``` + + @method reject + @static + @param {Any} reason value that the returned promise will be rejected with. + Useful for tooling. + @return {Promise} a promise rejected with the given `reason`. +*/ +function reject$1(reason) { + /*jshint validthis:true */ + var Constructor = this; + var promise = new Constructor(noop); + reject(promise, reason); + return promise; +} + +function needsResolver() { + throw new TypeError('You must pass a resolver function as the first argument to the promise constructor'); +} + +function needsNew() { + throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function."); +} + +/** + Promise objects represent the eventual result of an asynchronous operation. The + primary way of interacting with a promise is through its `then` method, which + registers callbacks to receive either a promise's eventual value or the reason + why the promise cannot be fulfilled. + + Terminology + ----------- + + - `promise` is an object or function with a `then` method whose behavior conforms to this specification. + - `thenable` is an object or function that defines a `then` method. + - `value` is any legal JavaScript value (including undefined, a thenable, or a promise). + - `exception` is a value that is thrown using the throw statement. + - `reason` is a value that indicates why a promise was rejected. + - `settled` the final resting state of a promise, fulfilled or rejected. + + A promise can be in one of three states: pending, fulfilled, or rejected. + + Promises that are fulfilled have a fulfillment value and are in the fulfilled + state. Promises that are rejected have a rejection reason and are in the + rejected state. A fulfillment value is never a thenable. + + Promises can also be said to *resolve* a value. If this value is also a + promise, then the original promise's settled state will match the value's + settled state. So a promise that *resolves* a promise that rejects will + itself reject, and a promise that *resolves* a promise that fulfills will + itself fulfill. + + + Basic Usage: + ------------ + + ```js + let promise = new Promise(function(resolve, reject) { + // on success + resolve(value); + + // on failure + reject(reason); + }); + + promise.then(function(value) { + // on fulfillment + }, function(reason) { + // on rejection + }); + ``` + + Advanced Usage: + --------------- + + Promises shine when abstracting away asynchronous interactions such as + `XMLHttpRequest`s. + + ```js + function getJSON(url) { + return new Promise(function(resolve, reject){ + let xhr = new XMLHttpRequest(); + + xhr.open('GET', url); + xhr.onreadystatechange = handler; + xhr.responseType = 'json'; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.send(); + + function handler() { + if (this.readyState === this.DONE) { + if (this.status === 200) { + resolve(this.response); + } else { + reject(new Error('getJSON: `' + url + '` failed with status: [' + this.status + ']')); + } + } + }; + }); + } + + getJSON('/posts.json').then(function(json) { + // on fulfillment + }, function(reason) { + // on rejection + }); + ``` + + Unlike callbacks, promises are great composable primitives. + + ```js + Promise.all([ + getJSON('/posts'), + getJSON('/comments') + ]).then(function(values){ + values[0] // => postsJSON + values[1] // => commentsJSON + + return values; + }); + ``` + + @class Promise + @param {Function} resolver + Useful for tooling. + @constructor +*/ + +var Promise$2 = function () { + function Promise(resolver) { + this[PROMISE_ID] = nextId(); + this._result = this._state = undefined; + this._subscribers = []; + + if (noop !== resolver) { + typeof resolver !== 'function' && needsResolver(); + this instanceof Promise ? initializePromise(this, resolver) : needsNew(); + } + } + + /** + The primary way of interacting with a promise is through its `then` method, + which registers callbacks to receive either a promise's eventual value or the + reason why the promise cannot be fulfilled. + ```js + findUser().then(function(user){ + // user is available + }, function(reason){ + // user is unavailable, and you are given the reason why + }); + ``` + Chaining + -------- + The return value of `then` is itself a promise. This second, 'downstream' + promise is resolved with the return value of the first promise's fulfillment + or rejection handler, or rejected if the handler throws an exception. + ```js + findUser().then(function (user) { + return user.name; + }, function (reason) { + return 'default name'; + }).then(function (userName) { + // If `findUser` fulfilled, `userName` will be the user's name, otherwise it + // will be `'default name'` + }); + findUser().then(function (user) { + throw new Error('Found user, but still unhappy'); + }, function (reason) { + throw new Error('`findUser` rejected and we're unhappy'); + }).then(function (value) { + // never reached + }, function (reason) { + // if `findUser` fulfilled, `reason` will be 'Found user, but still unhappy'. + // If `findUser` rejected, `reason` will be '`findUser` rejected and we're unhappy'. + }); + ``` + If the downstream promise does not specify a rejection handler, rejection reasons will be propagated further downstream. + ```js + findUser().then(function (user) { + throw new PedagogicalException('Upstream error'); + }).then(function (value) { + // never reached + }).then(function (value) { + // never reached + }, function (reason) { + // The `PedgagocialException` is propagated all the way down to here + }); + ``` + Assimilation + ------------ + Sometimes the value you want to propagate to a downstream promise can only be + retrieved asynchronously. This can be achieved by returning a promise in the + fulfillment or rejection handler. The downstream promise will then be pending + until the returned promise is settled. This is called *assimilation*. + ```js + findUser().then(function (user) { + return findCommentsByAuthor(user); + }).then(function (comments) { + // The user's comments are now available + }); + ``` + If the assimliated promise rejects, then the downstream promise will also reject. + ```js + findUser().then(function (user) { + return findCommentsByAuthor(user); + }).then(function (comments) { + // If `findCommentsByAuthor` fulfills, we'll have the value here + }, function (reason) { + // If `findCommentsByAuthor` rejects, we'll have the reason here + }); + ``` + Simple Example + -------------- + Synchronous Example + ```javascript + let result; + try { + result = findResult(); + // success + } catch(reason) { + // failure + } + ``` + Errback Example + ```js + findResult(function(result, err){ + if (err) { + // failure + } else { + // success + } + }); + ``` + Promise Example; + ```javascript + findResult().then(function(result){ + // success + }, function(reason){ + // failure + }); + ``` + Advanced Example + -------------- + Synchronous Example + ```javascript + let author, books; + try { + author = findAuthor(); + books = findBooksByAuthor(author); + // success + } catch(reason) { + // failure + } + ``` + Errback Example + ```js + function foundBooks(books) { + } + function failure(reason) { + } + findAuthor(function(author, err){ + if (err) { + failure(err); + // failure + } else { + try { + findBoooksByAuthor(author, function(books, err) { + if (err) { + failure(err); + } else { + try { + foundBooks(books); + } catch(reason) { + failure(reason); + } + } + }); + } catch(error) { + failure(err); + } + // success + } + }); + ``` + Promise Example; + ```javascript + findAuthor(). + then(findBooksByAuthor). + then(function(books){ + // found books + }).catch(function(reason){ + // something went wrong + }); + ``` + @method then + @param {Function} onFulfilled + @param {Function} onRejected + Useful for tooling. + @return {Promise} + */ + + /** + `catch` is simply sugar for `then(undefined, onRejection)` which makes it the same + as the catch block of a try/catch statement. + ```js + function findAuthor(){ + throw new Error('couldn't find that author'); + } + // synchronous + try { + findAuthor(); + } catch(reason) { + // something went wrong + } + // async with promises + findAuthor().catch(function(reason){ + // something went wrong + }); + ``` + @method catch + @param {Function} onRejection + Useful for tooling. + @return {Promise} + */ + + + Promise.prototype.catch = function _catch(onRejection) { + return this.then(null, onRejection); + }; + + /** + `finally` will be invoked regardless of the promise's fate just as native + try/catch/finally behaves + + Synchronous example: + + ```js + findAuthor() { + if (Math.random() > 0.5) { + throw new Error(); + } + return new Author(); + } + + try { + return findAuthor(); // succeed or fail + } catch(error) { + return findOtherAuther(); + } finally { + // always runs + // doesn't affect the return value + } + ``` + + Asynchronous example: + + ```js + findAuthor().catch(function(reason){ + return findOtherAuther(); + }).finally(function(){ + // author was either found, or not + }); + ``` + + @method finally + @param {Function} callback + @return {Promise} + */ + + + Promise.prototype.finally = function _finally(callback) { + var promise = this; + var constructor = promise.constructor; + + return promise.then(function (value) { + return constructor.resolve(callback()).then(function () { + return value; + }); + }, function (reason) { + return constructor.resolve(callback()).then(function () { + throw reason; + }); + }); + }; + + return Promise; +}(); + +Promise$2.prototype.then = then; +Promise$2.all = all; +Promise$2.race = race; +Promise$2.resolve = resolve$1; +Promise$2.reject = reject$1; +Promise$2._setScheduler = setScheduler; +Promise$2._setAsap = setAsap; +Promise$2._asap = asap; + +/*global self*/ +function polyfill() { + var local = void 0; + + if (typeof global !== 'undefined') { + local = global; + } else if (typeof self !== 'undefined') { + local = self; + } else { + try { + local = Function('return this')(); + } catch (e) { + throw new Error('polyfill failed because global object is unavailable in this environment'); + } + } + + var P = local.Promise; + + if (P) { + var promiseToString = null; + try { + promiseToString = Object.prototype.toString.call(P.resolve()); + } catch (e) { + // silently ignored + } + + if (promiseToString === '[object Promise]' && !P.cast) { + return; + } + } + + local.Promise = Promise$2; +} + +// Strange compat.. +Promise$2.polyfill = polyfill; +Promise$2.Promise = Promise$2; + +Promise$2.polyfill(); + +return Promise$2; + +}))); + diff --git a/packages/browser/test/integration/polyfills/whatwg-fetch-2.0.4.js b/packages/browser/test/integration/polyfills/whatwg-fetch-2.0.4.js new file mode 100644 index 000000000000..f2f466d7b38e --- /dev/null +++ b/packages/browser/test/integration/polyfills/whatwg-fetch-2.0.4.js @@ -0,0 +1,466 @@ +(function(self) { + 'use strict'; + + if (self.fetch) { + return + } + + var support = { + searchParams: 'URLSearchParams' in self, + iterable: 'Symbol' in self && 'iterator' in Symbol, + blob: 'FileReader' in self && 'Blob' in self && (function() { + try { + new Blob() + return true + } catch(e) { + return false + } + })(), + formData: 'FormData' in self, + arrayBuffer: 'ArrayBuffer' in self + } + + if (support.arrayBuffer) { + var viewClasses = [ + '[object Int8Array]', + '[object Uint8Array]', + '[object Uint8ClampedArray]', + '[object Int16Array]', + '[object Uint16Array]', + '[object Int32Array]', + '[object Uint32Array]', + '[object Float32Array]', + '[object Float64Array]' + ] + + var isDataView = function(obj) { + return obj && DataView.prototype.isPrototypeOf(obj) + } + + var isArrayBufferView = ArrayBuffer.isView || function(obj) { + return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1 + } + } + + function normalizeName(name) { + if (typeof name !== 'string') { + name = String(name) + } + if (/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(name)) { + throw new TypeError('Invalid character in header field name') + } + return name.toLowerCase() + } + + function normalizeValue(value) { + if (typeof value !== 'string') { + value = String(value) + } + return value + } + + // Build a destructive iterator for the value list + function iteratorFor(items) { + var iterator = { + next: function() { + var value = items.shift() + return {done: value === undefined, value: value} + } + } + + if (support.iterable) { + iterator[Symbol.iterator] = function() { + return iterator + } + } + + return iterator + } + + function Headers(headers) { + this.map = {} + + if (headers instanceof Headers) { + headers.forEach(function(value, name) { + this.append(name, value) + }, this) + } else if (Array.isArray(headers)) { + headers.forEach(function(header) { + this.append(header[0], header[1]) + }, this) + } else if (headers) { + Object.getOwnPropertyNames(headers).forEach(function(name) { + this.append(name, headers[name]) + }, this) + } + } + + Headers.prototype.append = function(name, value) { + name = normalizeName(name) + value = normalizeValue(value) + var oldValue = this.map[name] + this.map[name] = oldValue ? oldValue+','+value : value + } + + Headers.prototype['delete'] = function(name) { + delete this.map[normalizeName(name)] + } + + Headers.prototype.get = function(name) { + name = normalizeName(name) + return this.has(name) ? this.map[name] : null + } + + Headers.prototype.has = function(name) { + return this.map.hasOwnProperty(normalizeName(name)) + } + + Headers.prototype.set = function(name, value) { + this.map[normalizeName(name)] = normalizeValue(value) + } + + Headers.prototype.forEach = function(callback, thisArg) { + for (var name in this.map) { + if (this.map.hasOwnProperty(name)) { + callback.call(thisArg, this.map[name], name, this) + } + } + } + + Headers.prototype.keys = function() { + var items = [] + this.forEach(function(value, name) { items.push(name) }) + return iteratorFor(items) + } + + Headers.prototype.values = function() { + var items = [] + this.forEach(function(value) { items.push(value) }) + return iteratorFor(items) + } + + Headers.prototype.entries = function() { + var items = [] + this.forEach(function(value, name) { items.push([name, value]) }) + return iteratorFor(items) + } + + if (support.iterable) { + Headers.prototype[Symbol.iterator] = Headers.prototype.entries + } + + function consumed(body) { + if (body.bodyUsed) { + return Promise.reject(new TypeError('Already read')) + } + body.bodyUsed = true + } + + function fileReaderReady(reader) { + return new Promise(function(resolve, reject) { + reader.onload = function() { + resolve(reader.result) + } + reader.onerror = function() { + reject(reader.error) + } + }) + } + + function readBlobAsArrayBuffer(blob) { + var reader = new FileReader() + var promise = fileReaderReady(reader) + reader.readAsArrayBuffer(blob) + return promise + } + + function readBlobAsText(blob) { + var reader = new FileReader() + var promise = fileReaderReady(reader) + reader.readAsText(blob) + return promise + } + + function readArrayBufferAsText(buf) { + var view = new Uint8Array(buf) + var chars = new Array(view.length) + + for (var i = 0; i < view.length; i++) { + chars[i] = String.fromCharCode(view[i]) + } + return chars.join('') + } + + function bufferClone(buf) { + if (buf.slice) { + return buf.slice(0) + } else { + var view = new Uint8Array(buf.byteLength) + view.set(new Uint8Array(buf)) + return view.buffer + } + } + + function Body() { + this.bodyUsed = false + + this._initBody = function(body) { + this._bodyInit = body + if (!body) { + this._bodyText = '' + } else if (typeof body === 'string') { + this._bodyText = body + } else if (support.blob && Blob.prototype.isPrototypeOf(body)) { + this._bodyBlob = body + } else if (support.formData && FormData.prototype.isPrototypeOf(body)) { + this._bodyFormData = body + } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { + this._bodyText = body.toString() + } else if (support.arrayBuffer && support.blob && isDataView(body)) { + this._bodyArrayBuffer = bufferClone(body.buffer) + // IE 10-11 can't handle a DataView body. + this._bodyInit = new Blob([this._bodyArrayBuffer]) + } else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) { + this._bodyArrayBuffer = bufferClone(body) + } else { + throw new Error('unsupported BodyInit type') + } + + if (!this.headers.get('content-type')) { + if (typeof body === 'string') { + this.headers.set('content-type', 'text/plain;charset=UTF-8') + } else if (this._bodyBlob && this._bodyBlob.type) { + this.headers.set('content-type', this._bodyBlob.type) + } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { + this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8') + } + } + } + + if (support.blob) { + this.blob = function() { + var rejected = consumed(this) + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return Promise.resolve(this._bodyBlob) + } else if (this._bodyArrayBuffer) { + return Promise.resolve(new Blob([this._bodyArrayBuffer])) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as blob') + } else { + return Promise.resolve(new Blob([this._bodyText])) + } + } + + this.arrayBuffer = function() { + if (this._bodyArrayBuffer) { + return consumed(this) || Promise.resolve(this._bodyArrayBuffer) + } else { + return this.blob().then(readBlobAsArrayBuffer) + } + } + } + + this.text = function() { + var rejected = consumed(this) + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return readBlobAsText(this._bodyBlob) + } else if (this._bodyArrayBuffer) { + return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer)) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as text') + } else { + return Promise.resolve(this._bodyText) + } + } + + if (support.formData) { + this.formData = function() { + return this.text().then(decode) + } + } + + this.json = function() { + return this.text().then(JSON.parse) + } + + return this + } + + // HTTP methods whose capitalization should be normalized + var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] + + function normalizeMethod(method) { + var upcased = method.toUpperCase() + return (methods.indexOf(upcased) > -1) ? upcased : method + } + + function Request(input, options) { + options = options || {} + var body = options.body + + if (input instanceof Request) { + if (input.bodyUsed) { + throw new TypeError('Already read') + } + this.url = input.url + this.credentials = input.credentials + if (!options.headers) { + this.headers = new Headers(input.headers) + } + this.method = input.method + this.mode = input.mode + if (!body && input._bodyInit != null) { + body = input._bodyInit + input.bodyUsed = true + } + } else { + this.url = String(input) + } + + this.credentials = options.credentials || this.credentials || 'omit' + if (options.headers || !this.headers) { + this.headers = new Headers(options.headers) + } + this.method = normalizeMethod(options.method || this.method || 'GET') + this.mode = options.mode || this.mode || null + this.referrer = null + + if ((this.method === 'GET' || this.method === 'HEAD') && body) { + throw new TypeError('Body not allowed for GET or HEAD requests') + } + this._initBody(body) + } + + Request.prototype.clone = function() { + return new Request(this, { body: this._bodyInit }) + } + + function decode(body) { + var form = new FormData() + body.trim().split('&').forEach(function(bytes) { + if (bytes) { + var split = bytes.split('=') + var name = split.shift().replace(/\+/g, ' ') + var value = split.join('=').replace(/\+/g, ' ') + form.append(decodeURIComponent(name), decodeURIComponent(value)) + } + }) + return form + } + + function parseHeaders(rawHeaders) { + var headers = new Headers() + // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space + // https://tools.ietf.org/html/rfc7230#section-3.2 + var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ') + preProcessedHeaders.split(/\r?\n/).forEach(function(line) { + var parts = line.split(':') + var key = parts.shift().trim() + if (key) { + var value = parts.join(':').trim() + headers.append(key, value) + } + }) + return headers + } + + Body.call(Request.prototype) + + function Response(bodyInit, options) { + if (!options) { + options = {} + } + + this.type = 'default' + this.status = options.status === undefined ? 200 : options.status + this.ok = this.status >= 200 && this.status < 300 + this.statusText = 'statusText' in options ? options.statusText : 'OK' + this.headers = new Headers(options.headers) + this.url = options.url || '' + this._initBody(bodyInit) + } + + Body.call(Response.prototype) + + Response.prototype.clone = function() { + return new Response(this._bodyInit, { + status: this.status, + statusText: this.statusText, + headers: new Headers(this.headers), + url: this.url + }) + } + + Response.error = function() { + var response = new Response(null, {status: 0, statusText: ''}) + response.type = 'error' + return response + } + + var redirectStatuses = [301, 302, 303, 307, 308] + + Response.redirect = function(url, status) { + if (redirectStatuses.indexOf(status) === -1) { + throw new RangeError('Invalid status code') + } + + return new Response(null, {status: status, headers: {location: url}}) + } + + self.Headers = Headers + self.Request = Request + self.Response = Response + + self.fetch = function(input, init) { + return new Promise(function(resolve, reject) { + var request = new Request(input, init) + var xhr = new XMLHttpRequest() + + xhr.onload = function() { + var options = { + status: xhr.status, + statusText: xhr.statusText, + headers: parseHeaders(xhr.getAllResponseHeaders() || '') + } + options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL') + var body = 'response' in xhr ? xhr.response : xhr.responseText + resolve(new Response(body, options)) + } + + xhr.onerror = function() { + reject(new TypeError('Network request failed')) + } + + xhr.ontimeout = function() { + reject(new TypeError('Network request failed')) + } + + xhr.open(request.method, request.url, true) + + if (request.credentials === 'include') { + xhr.withCredentials = true + } else if (request.credentials === 'omit') { + xhr.withCredentials = false + } + + if ('responseType' in xhr && support.blob) { + xhr.responseType = 'blob' + } + + request.headers.forEach(function(value, name) { + xhr.setRequestHeader(name, value) + }) + + xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit) + }) + } + self.fetch.polyfill = true +})(typeof self !== 'undefined' ? self : this); diff --git a/packages/browser/test/integration/test.js b/packages/browser/test/integration/test.js new file mode 100644 index 000000000000..1fe5c47973c7 --- /dev/null +++ b/packages/browser/test/integration/test.js @@ -0,0 +1,1281 @@ +/*global assert*/ +function iframeExecute(iframe, done, execute, assertCallback) { + iframe.contentWindow.done = function() { + try { + assertCallback(iframe); + done(); + } catch (e) { + done(e); + } + }; + // use setTimeout so stack trace doesn't go all the way back to mocha test runner + iframe.contentWindow.eval('window.originalBuiltIns.setTimeout.call(window, ' + execute.toString() + ');'); +} + +function createIframe(done) { + var iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = './base/test/integration/frame.html'; + iframe.onload = function() { + done(); + }; + document.body.appendChild(iframe); + return iframe; +} + +var anchor = document.createElement('a'); +function parseUrl(url) { + var out = { pathname: '', origin: '', protocol: '' }; + if (!url) anchor.href = url; + for (var key in out) { + out[key] = anchor[key]; + } + return out; +} + +function isBelowIE11() { + return /*@cc_on!@*/ false == !false; +} + +function isEdge14() { + return window.navigator.userAgent.indexOf('Edge/14') !== -1; +} + +// Thanks for nothing IE! +// (╯°□°)╯︵ ┻━┻ +function canReadFunctionName() { + function foo() {} + if (foo.name === 'foo') return true; + return false; +} + +describe('integration', function() { + this.timeout(30000); + + beforeEach(function(done) { + this.iframe = createIframe(done); + }); + + afterEach(function() { + document.body.removeChild(this.iframe); + }); + + describe('API', function() { + it('should capture Sentry.captureMessage', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + Sentry.captureMessage('Hello'); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.equal(sentryData.message, 'Hello'); + }, + ); + }); + + it('should capture Sentry.captureException', function(done) { + var iframe = this.iframe; + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + try { + foo(); + } catch (e) { + Sentry.captureException(e); + } + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.isAtLeast(sentryData.exception.values[0].stacktrace.frames.length, 2); + assert.isAtMost(sentryData.exception.values[0].stacktrace.frames.length, 4); + }, + ); + }); + + it('should generate a synthetic trace for captureException w/ non-errors', function(done) { + var iframe = this.iframe; + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + Sentry.captureException({ foo: 'bar' }); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.isAtLeast(sentryData.stacktrace.frames.length, 1); + assert.isAtMost(sentryData.stacktrace.frames.length, 3); + }, + ); + }); + + it('should reject duplicate, back-to-back errors from captureError', function(done) { + var iframe = this.iframe; + iframeExecute( + iframe, + done, + function() { + var count = 5; + setTimeout(function invoke() { + // use setTimeout to capture new error objects that have + // identical stack traces (can't call sequentially or callsite + // line number will change) + // + // order: + // Error: foo + // Error: foo (suppressed) + // Error: foo (suppressed) + // Error: bar + // Error: foo + if (count === 2) { + Sentry.captureException(new Error('bar')); + } else { + Sentry.captureException(new Error('foo')); + } + + if (--count === 0) return setTimeout(done); + else setTimeout(invoke); + }); + }, + function() { + var sentryData = iframe.contentWindow.sentryData; + assert.equal(sentryData.length, 3); + assert.equal(sentryData[0].exception.values[0].value, 'foo'); + assert.equal(sentryData[1].exception.values[0].value, 'bar'); + assert.equal(sentryData[2].exception.values[0].value, 'foo'); + }, + ); + }); + + it('should not reject back-to-back errors with different stack traces', function(done) { + var iframe = this.iframe; + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + // same error message, but different stacks means that these are considered + // different errors + + // stack: + // bar + try { + bar(); // declared in frame.html + } catch (e) { + Sentry.captureException(e); + } + + // stack (different # frames): + // bar + // foo + try { + foo(); // declared in frame.html + } catch (e) { + Sentry.captureException(e); + } + + // stack (same # frames, different frames): + // bar + // foo2 + try { + foo2(); // declared in frame.html + } catch (e) { + Sentry.captureException(e); + } + }, + function() { + var sentryData = iframe.contentWindow.sentryData; + // NOTE: regex because exact error message differs per-browser + assert.equal(sentryData.length, 3); + assert.match(sentryData[0].exception.values[0].value, /^baz/); + assert.equal(sentryData[0].exception.values[0].type, 'ReferenceError'); + assert.match(sentryData[1].exception.values[0].value, /^baz/); + assert.equal(sentryData[1].exception.values[0].type, 'ReferenceError'); + assert.match(sentryData[2].exception.values[0].value, /^baz/); + assert.equal(sentryData[2].exception.values[0].type, 'ReferenceError'); + }, + ); + }); + + it('should reject duplicate, back-to-back messages from captureMessage', function(done) { + var iframe = this.iframe; + iframeExecute( + iframe, + done, + function() { + var count = 2; + setTimeout(function invoke() { + // use setTimeout to capture new error objects that have + // identical stack traces (can't call sequentially or callsite + // line number will change) + Sentry.captureMessage('this is fine ' + Date.now()); // this will be called twice with different messages, but same stacktrace + if (count === 1) Sentry.captureMessage('this is fine'); // suppressed + if (count === 1) Sentry.captureMessage('this is fine'); // suppressed + if (count === 1) Sentry.captureMessage("i'm okay with the events that are unfolding currently"); + if (count === 1) Sentry.captureMessage("that's okay, things are going to be okay"); + + if (--count === 0) return setTimeout(done); + else setTimeout(invoke); + }); + }, + function() { + var sentryData = iframe.contentWindow.sentryData; + // NOTE: regex because exact error message differs per-browser + assert.equal(sentryData.length, 4); + assert.match(sentryData[0].message, /this is fine \d+/); + assert.equal(sentryData[1].message, 'this is fine'); + assert.equal(sentryData[2].message, "i'm okay with the events that are unfolding currently"); + assert.equal(sentryData[3].message, "that's okay, things are going to be okay"); + }, + ); + }); + }); + + describe('window.onerror', function() { + it('should catch syntax errors', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + eval('foo{};'); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + // ¯\_(ツ)_/¯ + if (isBelowIE11() || isEdge14()) { + assert.equal(sentryData.exception.values[0].type, undefined); + } else { + assert.match(sentryData.exception.values[0].type, /SyntaxError/); + } + assert.equal(sentryData.exception.values[0].stacktrace.frames.length, 1); // just one frame + }, + ); + }); + + it('should catch thrown strings', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + // intentionally loading this error via a script file to make + // sure it is 1) not caught by instrumentation 2) doesn't trigger + // "Script error" + var script = document.createElement('script'); + script.src = 'throw-string.js'; + script.onload = function() { + done(); + }; + document.head.appendChild(script); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.match(sentryData.exception.values[0].value, /stringError$/); + assert.equal(sentryData.exception.values[0].stacktrace.frames.length, 1); // always 1 because thrown strings can't provide > 1 frame + + // some browsers extract proper url, line, and column for thrown strings + // but not all - falls back to frame url + assert.match(sentryData.exception.values[0].stacktrace.frames[0].filename, /\/test\/integration\//); + assert.match( + sentryData.exception.values[0].stacktrace.frames[0]['function'], + /throwStringError|\?|global code/i, + ); + }, + ); + }); + + it('should catch thrown objects', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + // intentionally loading this error via a script file to make + // sure it is 1) not caught by instrumentation 2) doesn't trigger + // "Script error" + var script = document.createElement('script'); + script.src = 'throw-object.js'; + script.onload = function() { + done(); + }; + document.head.appendChild(script); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.equal(sentryData.exception.values[0].type, undefined); + + // # is covering default Android 4.4 and 5.1 browser + assert.match(sentryData.exception.values[0].value, /^(\[object Object\]|#)$/); + assert.equal(sentryData.exception.values[0].stacktrace.frames.length, 1); // always 1 because thrown objects can't provide > 1 frame + + // some browsers extract proper url, line, and column for thrown objects + // but not all - falls back to frame url + assert.match(sentryData.exception.values[0].stacktrace.frames[0].filename, /\/test\/integration\//); + assert.match( + sentryData.exception.values[0].stacktrace.frames[0]['function'], + /throwStringError|\?|global code/i, + ); + }, + ); + }); + + it('should catch thrown errors', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + // intentionally loading this error via a script file to make + // sure it is 1) not caught by instrumentation 2) doesn't trigger + // "Script error" + var script = document.createElement('script'); + script.src = 'throw-error.js'; + script.onload = function() { + done(); + }; + document.head.appendChild(script); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + // ¯\_(ツ)_/¯ + if (isBelowIE11() || isEdge14()) { + assert.equal(sentryData.exception.values[0].type, undefined); + } else { + assert.match(sentryData.exception.values[0].type, /^Error/); + } + assert.match(sentryData.exception.values[0].value, /realError$/); + // 1 or 2 depending on platform + assert.isAtLeast(sentryData.exception.values[0].stacktrace.frames.length, 1); + assert.isAtMost(sentryData.exception.values[0].stacktrace.frames.length, 2); + assert.match( + sentryData.exception.values[0].stacktrace.frames[0].filename, + /\/test\/integration\/throw-error\.js/, + ); + assert.match( + sentryData.exception.values[0].stacktrace.frames[0]['function'], + /\?|global code|throwRealError/i, + ); + }, + ); + }); + + it('should NOT catch an exception already caught via Sentry.wrap', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + Sentry.wrap(function() { + foo(); + })(); + }, + function() { + var sentryData = iframe.contentWindow.sentryData; + assert.equal(sentryData.length, 1); // one caught error + }, + ); + }); + + it('should NOT catch an exception already caught [but rethrown] via Sentry.captureException', function(done) { + var iframe = this.iframe; + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + try { + foo(); + } catch (e) { + Sentry.captureException(e); + throw e; // intentionally re-throw + } + }, + function() { + var sentryData = iframe.contentWindow.sentryData; + assert.equal(sentryData.length, 1); + }, + ); + }); + }); + + describe('wrapped built-ins', function() { + it('should capture exceptions from event listeners', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + var div = document.createElement('div'); + document.body.appendChild(div); + div.addEventListener( + 'click', + function() { + foo(); + }, + false, + ); + + var click = new MouseEvent('click'); + div.dispatchEvent(click); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.isAtLeast(sentryData.exception.values[0].stacktrace.frames.length, 3); + assert.isAtMost(sentryData.exception.values[0].stacktrace.frames.length, 5); + }, + ); + }); + + it('should transparently remove event listeners from wrapped functions', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + var div = document.createElement('div'); + document.body.appendChild(div); + var fooFn = function() { + foo(); + }; + div.addEventListener('click', fooFn, false); + div.removeEventListener('click', fooFn); + + var click = new MouseEvent('click'); + div.dispatchEvent(click); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.equal(sentryData, null); // should never trigger error + }, + ); + }); + + it('should capture exceptions inside setTimeout', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(function() { + setTimeout(done); + foo(); + }, 10); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.isAtLeast(sentryData.exception.values[0].stacktrace.frames.length, 3); + assert.isAtMost(sentryData.exception.values[0].stacktrace.frames.length, 4); + }, + ); + }); + + it('should capture exceptions inside setInterval', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + var exceptionInterval = setInterval(function() { + setTimeout(done); + clearInterval(exceptionInterval); + foo(); + }, 10); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.isAtLeast(sentryData.exception.values[0].stacktrace.frames.length, 3); + assert.isAtMost(sentryData.exception.values[0].stacktrace.frames.length, 4); + }, + ); + }); + + it('should capture exceptions inside requestAnimationFrame', function(done) { + var iframe = this.iframe; + // needs to be visible or requestAnimationFrame won't ever fire + iframe.style.display = 'block'; + + iframeExecute( + iframe, + done, + function() { + requestAnimationFrame(function() { + setTimeout(done); + foo(); + }); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + assert.isAtLeast(sentryData.exception.values[0].stacktrace.frames.length, 3); + assert.isAtMost(sentryData.exception.values[0].stacktrace.frames.length, 4); + }, + ); + }); + + it('should capture exceptions from XMLHttpRequest event handlers (e.g. onreadystatechange)', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + var xhr = new XMLHttpRequest(); + + // intentionally assign event handlers *after* XMLHttpRequest.prototype.open, + // since this is what jQuery does + // https://github.com/jquery/jquery/blob/master/src/ajax/xhr.js#L37 + + xhr.open('GET', 'example.json'); + xhr.onreadystatechange = function() { + setTimeout(done); + // replace onreadystatechange with no-op so exception doesn't + // fire more than once as XHR changes loading state + xhr.onreadystatechange = function() {}; + foo(); + }; + xhr.send(); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + // # of frames alter significantly between chrome/firefox & safari + assert.isAtLeast(sentryData.exception.values[0].stacktrace.frames.length, 3); + assert.isAtMost(sentryData.exception.values[0].stacktrace.frames.length, 4); + }, + ); + }); + + it("should capture built-in's mechanism type as instrument", function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(function() { + setTimeout(done); + foo(); + }); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + + var fn = sentryData.exception.mechanism.data.function; + delete sentryData.exception.mechanism.data; + + if (canReadFunctionName()) { + assert.equal(fn, 'setTimeout'); + } else { + assert.equal(fn, ''); + } + + assert.deepEqual(sentryData.exception.mechanism, { + type: 'instrument', + handled: true, + }); + }, + ); + }); + + it("should capture built-in's handlers fn name in mechanism data", function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + var div = document.createElement('div'); + document.body.appendChild(div); + div.addEventListener( + 'click', + function namedFunction() { + foo(); + }, + false, + ); + + var click = new MouseEvent('click'); + div.dispatchEvent(click); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + + var handler = sentryData.exception.mechanism.data.handler; + delete sentryData.exception.mechanism.data.handler; + var target = sentryData.exception.mechanism.data.target; + delete sentryData.exception.mechanism.data.target; + + if (canReadFunctionName()) { + assert.equal(handler, 'namedFunction'); + } else { + assert.equal(handler, ''); + } + + // IE vs. Rest of the world + assert.oneOf(target, ['Node', 'EventTarget']); + assert.deepEqual(sentryData.exception.mechanism, { + type: 'instrument', + handled: true, + data: { + function: 'addEventListener', + }, + }); + }, + ); + }); + + it('should fallback to fn name in mechanism data if one is unavailable', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + var div = document.createElement('div'); + document.body.appendChild(div); + div.addEventListener( + 'click', + function() { + foo(); + }, + false, + ); + + var click = new MouseEvent('click'); + div.dispatchEvent(click); + }, + function() { + var sentryData = iframe.contentWindow.sentryData[0]; + + var target = sentryData.exception.mechanism.data.target; + delete sentryData.exception.mechanism.data.target; + + // IE vs. Rest of the world + assert.oneOf(target, ['Node', 'EventTarget']); + assert.deepEqual(sentryData.exception.mechanism, { + type: 'instrument', + handled: true, + data: { + function: 'addEventListener', + handler: '', + }, + }); + }, + ); + }); + }); + + describe('breadcrumbs', function() { + it('should record an XMLHttpRequest', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + var xhr = new XMLHttpRequest(); + + xhr.open('GET', 'example.json'); + xhr.setRequestHeader('Content-type', 'application/json'); + xhr.onreadystatechange = function() { + // don't fire `done` handler until at least *one* onreadystatechange + // has occurred (doesn't actually need to finish) + if (xhr.readyState === 4) { + setTimeout(done); + } + }; + xhr.send(); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + assert.equal(breadcrumbs[0].type, 'http'); + assert.equal(breadcrumbs[0].data.method, 'GET'); + }, + ); + }); + + it('should record an XMLHttpRequest without any handlers set', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + // I hate to do a time-based "done" trigger, but unfortunately we can't + // set an onload/onreadystatechange handler on XHR to verify that it finished + // - that's the whole point of this test! :( + setTimeout(done, 1000); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + var xhr = new XMLHttpRequest(); + + xhr.open('GET', 'example.json'); + xhr.setRequestHeader('Content-type', 'application/json'); + xhr.send(); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].type, 'http'); + assert.equal(breadcrumbs[0].category, 'xhr'); + assert.equal(breadcrumbs[0].data.method, 'GET'); + }, + ); + }); + + it('should NOT denote XMLHttpRequests to the Sentry store endpoint as requiring breadcrumb capture', function(done) { + var iframe = this.iframe; + iframeExecute( + iframe, + done, + function() { + var xhr = new XMLHttpRequest(); + xhr.open('GET', 'http://example.com/api/1/store/?sentry_key=public'); + + // can't actually transmit an XHR (breadcrumb isnt recorded until + // onreadystatechange fires), so enough to just verify that + // __sentry_xhr wasn't set on xhr object + + window.sentryData = xhr.hasOwnProperty('__sentry_xhr__'); + setTimeout(done); + }, + function() { + assert.isFalse(iframe.contentWindow.sentryData); + }, + ); + }); + + it('should record a fetch request', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + fetch('example.json').then( + function() { + setTimeout(done); + }, + function() { + setTimeout(done); + }, + ); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + var breadcrumbUrl = 'example.json'; + + if ('fetch' in window) { + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].type, 'http'); + assert.equal(breadcrumbs[0].category, 'fetch'); + assert.equal(breadcrumbs[0].data.method, 'GET'); + assert.equal(breadcrumbs[0].data.url, breadcrumbUrl); + } else { + // otherwise we use a fetch polyfill based on xhr + assert.equal(breadcrumbs.length, 2); + + assert.equal(breadcrumbs[0].type, 'http'); + assert.equal(breadcrumbs[0].category, 'xhr'); + assert.equal(breadcrumbs[0].data.method, 'GET'); + assert.equal(breadcrumbs[0].data.url, breadcrumbUrl); + + assert.equal(breadcrumbs[1].type, 'http'); + assert.equal(breadcrumbs[1].category, 'fetch'); + assert.equal(breadcrumbs[1].data.method, 'GET'); + assert.equal(breadcrumbs[1].data.url, breadcrumbUrl); + } + }, + ); + }); + + it('should record a fetch request with Request obj instead of URL string', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + fetch(new Request('example.json')).then( + function() { + setTimeout(done); + }, + function() { + setTimeout(done); + }, + ); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + var breadcrumbUrl = 'example.json'; + + if ('fetch' in window) { + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].type, 'http'); + assert.equal(breadcrumbs[0].category, 'fetch'); + assert.equal(breadcrumbs[0].data.method, 'GET'); + // Request constructor normalizes the url + assert.ok(breadcrumbs[0].data.url.indexOf(breadcrumbUrl) !== -1); + } else { + // otherwise we use a fetch polyfill based on xhr + assert.equal(breadcrumbs.length, 2); + + assert.equal(breadcrumbs[0].type, 'http'); + assert.equal(breadcrumbs[0].category, 'xhr'); + assert.equal(breadcrumbs[0].data.method, 'GET'); + assert.ok(breadcrumbs[0].data.url.indexOf(breadcrumbUrl) !== -1); + + assert.equal(breadcrumbs[1].type, 'http'); + assert.equal(breadcrumbs[1].category, 'fetch'); + assert.equal(breadcrumbs[1].data.method, 'GET'); + assert.ok(breadcrumbs[1].data.url.indexOf(breadcrumbUrl) !== -1); + } + }, + ); + }); + + it('should record a fetch request with an arbitrary type argument', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + fetch(123).then( + function() { + setTimeout(done); + }, + function() { + setTimeout(done); + }, + ); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + var breadcrumbUrl = '123'; + + if ('fetch' in window) { + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].type, 'http'); + assert.equal(breadcrumbs[0].category, 'fetch'); + assert.equal(breadcrumbs[0].data.method, 'GET'); + // Request constructor normalizes the url + assert.ok(breadcrumbs[0].data.url.indexOf(breadcrumbUrl) !== -1); + } else { + // otherwise we use a fetch polyfill based on xhr + assert.equal(breadcrumbs.length, 2); + + assert.equal(breadcrumbs[0].type, 'http'); + assert.equal(breadcrumbs[0].category, 'xhr'); + assert.equal(breadcrumbs[0].data.method, 'GET'); + assert.ok(breadcrumbs[0].data.url.indexOf(breadcrumbUrl) !== -1); + + assert.equal(breadcrumbs[1].type, 'http'); + assert.equal(breadcrumbs[1].category, 'fetch'); + assert.equal(breadcrumbs[1].data.method, 'GET'); + assert.ok(breadcrumbs[1].data.url.indexOf(breadcrumbUrl) !== -1); + } + }, + ); + }); + + it('should record a mouse click on element WITH click handler present', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + // add an event listener to the input. we want to make sure that + // our breadcrumbs still work even if the page has an event listener + // on an element that cancels event bubbling + var input = document.getElementsByTagName('input')[0]; + var clickHandler = function(evt) { + evt.stopPropagation(); // don't bubble + }; + input.addEventListener('click', clickHandler); + + // click + var click = new MouseEvent('click'); + input.dispatchEvent(click); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].category, 'ui.click'); + assert.equal(breadcrumbs[0].message, 'body > form#foo-form > input[name="foo"]'); + }, + ); + }); + + it('should record a mouse click on element WITHOUT click handler present', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + // click + var click = new MouseEvent('click'); + var input = document.getElementsByTagName('input')[0]; + input.dispatchEvent(click); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].category, 'ui.click'); + assert.equal(breadcrumbs[0].message, 'body > form#foo-form > input[name="foo"]'); + }, + ); + }); + + it('should only record a SINGLE mouse click for a tree of elements with event listeners', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + var clickHandler = function(evt) { + //evt.stopPropagation(); + }; + + // mousemove event shouldnt clobber subsequent "breadcrumbed" events (see #724) + document.querySelector('.a').addEventListener('mousemove', clickHandler); + + document.querySelector('.a').addEventListener('click', clickHandler); + document.querySelector('.b').addEventListener('click', clickHandler); + document.querySelector('.c').addEventListener('click', clickHandler); + + // click + var click = new MouseEvent('click'); + var input = document.querySelector('.a'); // leaf node + input.dispatchEvent(click); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].category, 'ui.click'); + assert.equal(breadcrumbs[0].message, 'body > div.c > div.b > div.a'); + }, + ); + }); + + it('should bail out if accessing the `type` and `target` properties of an event throw an exception', function(done) { + // see: https://github.com/getsentry/sentry-javascript/issues/768 + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + // click + var click = new MouseEvent('click'); + function kaboom() { + throw new Error('lol'); + } + Object.defineProperty(click, 'type', { get: kaboom }); + Object.defineProperty(click, 'target', { get: kaboom }); + + var input = document.querySelector('.a'); // leaf node + input.dispatchEvent(click); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + assert.equal(breadcrumbs[0].category, 'ui.click'); + assert.equal(breadcrumbs[0].message, ''); + }, + ); + }); + + it('should record consecutive keypress events into a single "input" breadcrumb', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + // keypress twice + var keypress1 = new KeyboardEvent('keypress'); + var keypress2 = new KeyboardEvent('keypress'); + + var input = document.getElementsByTagName('input')[0]; + input.dispatchEvent(keypress1); + input.dispatchEvent(keypress2); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].category, 'ui.input'); + assert.equal(breadcrumbs[0].message, 'body > form#foo-form > input[name="foo"]'); + }, + ); + }); + + it('should flush keypress breadcrumbs when an error is thrown', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + // keypress + var keypress = new KeyboardEvent('keypress'); + + var input = document.getElementsByTagName('input')[0]; + input.dispatchEvent(keypress); + + foo(); // throw exception + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + assert.equal(breadcrumbs[0].category, 'ui.input'); + assert.equal(breadcrumbs[0].message, 'body > form#foo-form > input[name="foo"]'); + }, + ); + }); + + it('should flush keypress breadcrumb when input event occurs immediately after', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + // 1st keypress + var keypress1 = new KeyboardEvent('keypress'); + // click + var click = new MouseEvent('click'); + // 2nd keypress + var keypress2 = new KeyboardEvent('keypress'); + + var input = document.getElementsByTagName('input')[0]; + input.dispatchEvent(keypress1); + input.dispatchEvent(click); + input.dispatchEvent(keypress2); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + // 2x `ui_event` + assert.equal(breadcrumbs.length, 3); + + assert.equal(breadcrumbs[0].category, 'ui.input'); + assert.equal(breadcrumbs[0].message, 'body > form#foo-form > input[name="foo"]'); + + assert.equal(breadcrumbs[1].category, 'ui.click'); + assert.equal(breadcrumbs[1].message, 'body > form#foo-form > input[name="foo"]'); + + assert.equal(breadcrumbs[2].category, 'ui.input'); + assert.equal(breadcrumbs[2].message, 'body > form#foo-form > input[name="foo"]'); + }, + ); + }); + + it('should record consecutive keypress events in a contenteditable into a single "input" breadcrumb', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + // keypress twice + var keypress1 = new KeyboardEvent('keypress'); + var keypress2 = new KeyboardEvent('keypress'); + + var div = document.querySelector('[contenteditable]'); + div.dispatchEvent(keypress1); + div.dispatchEvent(keypress2); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].category, 'ui.input'); + assert.equal(breadcrumbs[0].message, 'body > form#foo-form > div.contenteditable'); + }, + ); + }); + + it('should record history.[pushState|replaceState] changes as navigation breadcrumbs', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + // Sentry._breadcrumbs = []; + + history.pushState({}, '', '/foo'); + history.pushState({}, '', '/bar?a=1#fragment'); + history.pushState({}, '', {}); // pushState calls toString on non-string args + history.pushState({}, '', null); // does nothing / no-op + + // can't call history.back() because it will change url of parent document + // (e.g. document running mocha) ... instead just "emulate" a back button + // press by calling replaceState + history.replaceState({}, '', '/bar?a=1#fragment'); + }, + function() { + var Sentry = iframe.contentWindow.Sentry; + var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; + + assert.equal(breadcrumbs.length, 4); + assert.equal(breadcrumbs[0].category, 'navigation'); // (start) => foo + assert.equal(breadcrumbs[1].category, 'navigation'); // foo => bar?a=1#fragment + assert.equal(breadcrumbs[2].category, 'navigation'); // bar?a=1#fragment => [object%20Object] + assert.equal(breadcrumbs[3].category, 'navigation'); // [object%20Object] => bar?a=1#fragment (back button) + + assert.ok(/\/test\/integration\/frame\.html$/.test(breadcrumbs[0].data.from), "'from' url is incorrect"); + assert.ok(/\/foo$/.test(breadcrumbs[0].data.to), "'to' url is incorrect"); + + assert.ok(/\/foo$/.test(breadcrumbs[1].data.from), "'from' url is incorrect"); + assert.ok(/\/bar\?a=1#fragment$/.test(breadcrumbs[1].data.to), "'to' url is incorrect"); + + assert.ok(/\/bar\?a=1#fragment$/.test(breadcrumbs[2].data.from), "'from' url is incorrect"); + assert.ok(/\[object Object\]$/.test(breadcrumbs[2].data.to), "'to' url is incorrect"); + + assert.ok(/\[object Object\]$/.test(breadcrumbs[3].data.from), "'from' url is incorrect"); + assert.ok(/\/bar\?a=1#fragment/.test(breadcrumbs[3].data.to), "'to' url is incorrect"); + }, + ); + }); + + it('should preserve native code detection compatibility', function(done) { + var iframe = this.iframe; + + iframeExecute( + iframe, + done, + function() { + done(); + }, + function() { + assert.include(Function.prototype.toString.call(window.setTimeout), '[native code]'); + assert.include(Function.prototype.toString.call(window.setInterval), '[native code]'); + assert.include(Function.prototype.toString.call(window.addEventListener), '[native code]'); + assert.include(Function.prototype.toString.call(window.removeEventListener), '[native code]'); + assert.include(Function.prototype.toString.call(window.requestAnimationFrame), '[native code]'); + if ('fetch' in window) { + assert.include(Function.prototype.toString.call(window.fetch), '[native code]'); + } + }, + ); + }); + }); +}); diff --git a/packages/browser/test/integration/throw-error.js b/packages/browser/test/integration/throw-error.js new file mode 100644 index 000000000000..453a10c41f36 --- /dev/null +++ b/packages/browser/test/integration/throw-error.js @@ -0,0 +1,5 @@ +function throwRealError() { + throw new Error('realError'); +} + +throwRealError(); diff --git a/packages/browser/test/integration/throw-object.js b/packages/browser/test/integration/throw-object.js new file mode 100644 index 000000000000..7fe17ebe766f --- /dev/null +++ b/packages/browser/test/integration/throw-object.js @@ -0,0 +1,7 @@ +function throwStringError() { + // never do this; just making sure Raven.js handles this case + // gracefully + throw {error: 'stuff is broken'}; +} + +throwStringError(); diff --git a/packages/browser/test/integration/throw-string.js b/packages/browser/test/integration/throw-string.js new file mode 100644 index 000000000000..7429303cab1a --- /dev/null +++ b/packages/browser/test/integration/throw-string.js @@ -0,0 +1,5 @@ +function throwStringError() { + throw 'stringError'; +} + +throwStringError(); diff --git a/packages/hub/src/scope.ts b/packages/hub/src/scope.ts index 38de5a2f8849..45edab8703ac 100644 --- a/packages/hub/src/scope.ts +++ b/packages/hub/src/scope.ts @@ -192,11 +192,8 @@ export class Scope { if (this.fingerprint && event.fingerprint === undefined) { event.fingerprint = this.fingerprint; } - // We only want to set breadcrumbs in the event if there are none - const hasNoBreadcrumbs = - !event.breadcrumbs || - event.breadcrumbs.length === 0 || - (event.breadcrumbs.values && event.breadcrumbs.values.length === 0); + + const hasNoBreadcrumbs = !event.breadcrumbs || event.breadcrumbs.length === 0; if (hasNoBreadcrumbs && this.breadcrumbs.length > 0) { event.breadcrumbs = maxBreadcrumbs !== undefined && maxBreadcrumbs >= 0 diff --git a/packages/hub/test/lib/hub.test.ts b/packages/hub/test/lib/hub.test.ts index 6d862947ce62..d6192b0b9c61 100644 --- a/packages/hub/test/lib/hub.test.ts +++ b/packages/hub/test/lib/hub.test.ts @@ -285,7 +285,7 @@ describe('Hub', () => { expect(callCounter.mock.calls[1][0]).toBe(2); expect(callCounter.mock.calls[2][0]).toBe(3); expect(callCounter.mock.calls[3][0]).toBe(4); - expect(final!.dist).toEqual('1'); + expect(final.dist).toEqual('1'); }); test('pushScope inherit processors', async () => { @@ -305,7 +305,7 @@ describe('Hub', () => { const pushedScope = hub.getStackTop().scope; if (pushedScope) { const final = await pushedScope.applyToEvent(event); - expect(final!.dist).toEqual('1'); + expect(final.dist).toEqual('1'); } }); diff --git a/packages/raven-js/package.json b/packages/raven-js/package.json index 7f3b3d2bef85..d6035387a2c4 100644 --- a/packages/raven-js/package.json +++ b/packages/raven-js/package.json @@ -1,14 +1,7 @@ { "name": "raven-js", "description": "JavaScript client for Sentry", - "keywords": [ - "debugging", - "errors", - "exceptions", - "logging", - "raven", - "sentry" - ], + "keywords": ["debugging", "errors", "exceptions", "logging", "raven", "sentry"], "version": "3.26.4", "repository": "git://github.com/getsentry/raven-js.git", "license": "BSD-2-Clause", @@ -18,16 +11,19 @@ "deploy": "npm run test && ./scripts/deploy.js", "lint": "eslint .", "publish": "grunt publish", - "test": "npm run lint && grunt build.test && npm run test:unit && npm run test:loader && npm run test:integration && npm run test:typescript", + "test": + "grunt build.test && npm run test:unit && npm run test:loader && npm run test:integration && npm run test:typescript", "test:unit": "karma start karma/karma.unit.config.js", "test:integration": "karma start karma/karma.integration.config.js", "test:integration-sauce": "karma start karma/karma.integration-sauce.config.js", "test:loader": "karma start karma/karma.loader.config.js", "test:loader-sauce": "karma start karma/karma.loader-sauce.config.js", "test:typescript": "tsc -p tsconfig.json", - "test:ci": "npm run lint && grunt test:ci && npm run test:loader-sauce && npm run test:integration-sauce", + "test:ci": + "grunt test:ci && npm run test:loader-sauce && npm run test:integration-sauce", "test:size": "grunt dist && bundlesize && git checkout -- dist/", - "loader": "cat src/loader.js | sed '/build_marker/{N;d;}' | npx google-closure-compiler-js | perl -e \"print ';'; print ;\"" + "loader": + "cat src/loader.js | sed '/build_marker/{N;d;}' | npx google-closure-compiler-js | perl -e \"print ';'; print ;\"" }, "devDependencies": { "bluebird": "^3.4.1", diff --git a/packages/raven-node/app.js b/packages/raven-node/app.js deleted file mode 100644 index 077b8a0f5a6a..000000000000 --- a/packages/raven-node/app.js +++ /dev/null @@ -1,19 +0,0 @@ -Error.stackTraceLimit = Infinity; - -const Raven = require('./lib/client'); - -Raven.config('https://363a337c11a64611be4845ad6e24f3ac@sentry.io/297378', { - stacktrace: true -}); - -// setTimeout(function() { -function foo() { - Raven.captureMessage('captureMessage'); -} -function bar() { - foo(); -} - -bar(); -// Sentry.captureException(new Error('captureException')); -// }, 5000); diff --git a/packages/utils/src/misc.ts b/packages/utils/src/misc.ts index 8cd9839543a3..4858a34c502f 100644 --- a/packages/utils/src/misc.ts +++ b/packages/utils/src/misc.ts @@ -159,6 +159,10 @@ export function parseUrl( protocol?: string; relative?: string; } { + if (!url) { + return {}; + } + const match = url.match(/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/); if (!match) { diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 0bea011738bc..8ed583b2787b 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -202,8 +202,6 @@ function serializeValue(value: T): T | string { /** JSDoc */ function serializeObject(value: T, depth: number): T | string | {} { - return value; - if (depth === 0) { return serializeValue(value); }