diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4adca974d830..ed8c098467c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -558,6 +558,60 @@ jobs: cd packages/browser-integration-tests yarn test:ci + job_browser_loader_tests: + name: Playwright Loader (${{ matrix.bundle }}) Tests + needs: [job_get_metadata, job_build] + if: needs.job_get_metadata.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' + runs-on: ubuntu-20.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + bundle: + - loader_base + - loader_eager + - loader_tracing + - loader_replay + - loader_tracing_replay + + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v3 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: volta-cli/action@v4 + - name: Restore caches + uses: ./.github/actions/restore-cache + env: + DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Get npm cache directory + id: npm-cache-dir + run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT + - name: Get Playwright version + id: playwright-version + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Check if Playwright browser is cached + id: playwright-cache + with: + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-Playwright-${{steps.playwright-version.outputs.version}} + - name: Install Playwright browser if not cached + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps + env: + PLAYWRIGHT_BROWSERS_PATH: ${{steps.npm-cache-dir.outputs.dir}} + - name: Install OS dependencies of Playwright if cache hit + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps + - name: Run Playwright Loader tests + env: + PW_BUNDLE: ${{ matrix.bundle }} + run: | + cd packages/browser-integration-tests + yarn test:loader + job_browser_integration_tests: name: Browser (${{ matrix.browser }}) Tests needs: [job_get_metadata, job_build] diff --git a/packages/browser-integration-tests/.eslintrc.js b/packages/browser-integration-tests/.eslintrc.js index 7952587cf973..977d75b6a27f 100644 --- a/packages/browser-integration-tests/.eslintrc.js +++ b/packages/browser-integration-tests/.eslintrc.js @@ -4,7 +4,13 @@ module.exports = { node: true, }, extends: ['../../.eslintrc.js'], - ignorePatterns: ['suites/**/subject.js', 'suites/**/dist/*', 'scripts/**'], + ignorePatterns: [ + 'suites/**/subject.js', + 'suites/**/dist/*', + 'loader-suites/**/dist/*', + 'loader-suites/**/subject.js', + 'scripts/**', + ], parserOptions: { sourceType: 'module', }, diff --git a/packages/browser-integration-tests/fixtures/loader.js b/packages/browser-integration-tests/fixtures/loader.js new file mode 100644 index 000000000000..731ee8af2a6c --- /dev/null +++ b/packages/browser-integration-tests/fixtures/loader.js @@ -0,0 +1,257 @@ +/* eslint-disable */ +// prettier-ignore +// Prettier disabled due to trailing comma not working in IE10/11 +(function( + _window, + _document, + _script, + _onerror, + _onunhandledrejection, + _namespace, + _publicKey, + _sdkBundleUrl, + _config, + _lazy +) { + var lazy = _lazy; + var forceLoad = false; + + for (var i = 0; i < document.scripts.length; i++) { + if (document.scripts[i].src.indexOf(_publicKey) > -1) { + // If lazy was set to true above, we need to check if the user has set data-lazy="no" + // to confirm that we should lazy load the CDN bundle + if (lazy && document.scripts[i].getAttribute('data-lazy') === 'no') { + lazy = false; + } + break; + } + } + + var injected = false; + var onLoadCallbacks = []; + + // Create a namespace and attach function that will store captured exception + // Because functions are also objects, we can attach the queue itself straight to it and save some bytes + var queue = function(content) { + // content.e = error + // content.p = promise rejection + // content.f = function call the Sentry + if ( + ('e' in content || + 'p' in content || + (content.f && content.f.indexOf('capture') > -1) || + (content.f && content.f.indexOf('showReportDialog') > -1)) && + lazy + ) { + // We only want to lazy inject/load the sdk bundle if + // an error or promise rejection occured + // OR someone called `capture...` on the SDK + injectSdk(onLoadCallbacks); + } + queue.data.push(content); + }; + queue.data = []; + + function injectSdk(callbacks) { + if (injected) { + return; + } + injected = true; + + // Create a `script` tag with provided SDK `url` and attach it just before the first, already existing `script` tag + // Scripts that are dynamically created and added to the document are async by default, + // they don't block rendering and execute as soon as they download, meaning they could + // come out in the wrong order. Because of that we don't need async=1 as GA does. + // it was probably(?) a legacy behavior that they left to not modify few years old snippet + // https://www.html5rocks.com/en/tutorials/speed/script-loading/ + var _currentScriptTag = _document.scripts[0]; + var _newScriptTag = _document.createElement(_script); + _newScriptTag.src = _sdkBundleUrl; + _newScriptTag.crossOrigin = 'anonymous'; + + // Once our SDK is loaded + _newScriptTag.addEventListener('load', function () { + try { + // Restore onerror/onunhandledrejection handlers - only if not mutated in the meanwhile + if (_window[_onerror] && _window[_onerror].__SENTRY_LOADER__) { + _window[_onerror] = _oldOnerror; + } + if (_window[_onunhandledrejection] && _window[_onunhandledrejection].__SENTRY_LOADER__) { + _window[_onunhandledrejection] = _oldOnunhandledrejection; + } + + // Add loader as SDK source + _window.SENTRY_SDK_SOURCE = 'loader'; + + var SDK = _window[_namespace]; + + var oldInit = SDK.init; + + var integrations = []; + if (_config.tracesSampleRate) { + integrations.push(new Sentry.BrowserTracing()); + } + + if (_config.replaysSessionSampleRate || _config.replaysOnErrorSampleRate) { + integrations.push(new Sentry.Replay()); + } + + if (integrations.length) { + _config.integrations = integrations; + } + + // Configure it using provided DSN and config object + SDK.init = function(options) { + var target = _config; + for (var key in options) { + if (Object.prototype.hasOwnProperty.call(options, key)) { + target[key] = options[key]; + } + } + oldInit(target); + }; + + sdkLoaded(callbacks, SDK); + } catch (o_O) { + console.error(o_O); + } + }); + + _currentScriptTag.parentNode.insertBefore(_newScriptTag, _currentScriptTag); + } + + function sdkIsLoaded() { + var __sentry = _window['__SENTRY__']; + // If there is a global __SENTRY__ that means that in any of the callbacks init() was already invoked + return !!(!(typeof __sentry === 'undefined') && __sentry.hub && __sentry.hub.getClient()); + } + + function sdkLoaded(callbacks, SDK) { + try { + // We have to make sure to call all callbacks first + for (var i = 0; i < callbacks.length; i++) { + if (typeof callbacks[i] === 'function') { + callbacks[i](); + } + } + + var data = queue.data; + + var initAlreadyCalled = sdkIsLoaded(); + + // Call init first, if provided + data.sort((a, b) => a.f === 'init' ? -1 : 0); + + // We want to replay all calls to Sentry and also make sure that `init` is called if it wasn't already + // We replay all calls to `Sentry.*` now + var calledSentry = false; + for (var i = 0; i < data.length; i++) { + if (data[i].f) { + calledSentry = true; + var call = data[i]; + if (initAlreadyCalled === false && call.f !== 'init') { + // First call always has to be init, this is a conveniece for the user so call to init is optional + SDK.init(); + } + initAlreadyCalled = true; + SDK[call.f].apply(SDK, call.a); + } + } + if (initAlreadyCalled === false && calledSentry === false) { + // Sentry has never been called but we need Sentry.init() so call it + SDK.init(); + } + + // Because we installed the SDK, at this point we have an access to TraceKit's handler, + // which can take care of browser differences (eg. missing exception argument in onerror) + var tracekitErrorHandler = _window[_onerror]; + var tracekitUnhandledRejectionHandler = _window[_onunhandledrejection]; + + // And now capture all previously caught exceptions + for (var i = 0; i < data.length; i++) { + if ('e' in data[i] && tracekitErrorHandler) { + tracekitErrorHandler.apply(_window, data[i].e); + } else if ('p' in data[i] && tracekitUnhandledRejectionHandler) { + tracekitUnhandledRejectionHandler.apply(_window, [data[i].p]); + } + } + } catch (o_O) { + console.error(o_O); + } + } + + // We make sure we do not overwrite window.Sentry since there could be already integrations in there + _window[_namespace] = _window[_namespace] || {}; + + _window[_namespace].onLoad = function (callback) { + onLoadCallbacks.push(callback); + if (lazy && !forceLoad) { + return; + } + injectSdk(onLoadCallbacks); + }; + + _window[_namespace].forceLoad = function() { + forceLoad = true; + if (lazy) { + setTimeout(function() { + injectSdk(onLoadCallbacks); + }); + } + }; + + [ + 'init', + 'addBreadcrumb', + 'captureMessage', + 'captureException', + 'captureEvent', + 'configureScope', + 'withScope', + 'showReportDialog' + ].forEach(function(f) { + _window[_namespace][f] = function() { + queue({ f: f, a: arguments }); + }; + }); + + // Store reference to the old `onerror` handler and override it with our own function + // that will just push exceptions to the queue and call through old handler if we found one + var _oldOnerror = _window[_onerror]; + _window[_onerror] = function() { + // Use keys as "data type" to save some characters" + queue({ + e: [].slice.call(arguments) + }); + + if (_oldOnerror) _oldOnerror.apply(_window, arguments); + }; + _window[_onerror].__SENTRY_LOADER__ = true; + + // Do the same store/queue/call operations for `onunhandledrejection` event + var _oldOnunhandledrejection = _window[_onunhandledrejection]; + _window[_onunhandledrejection] = function(e) { + queue({ + p: 'reason' in e ? e.reason : 'detail' in e && 'reason' in e.detail ? e.detail.reason : e + }); + if (_oldOnunhandledrejection) _oldOnunhandledrejection.apply(_window, arguments); + }; + _window[_onunhandledrejection].__SENTRY_LOADER__ = true; + + if (!lazy) { + setTimeout(function () { + injectSdk(onLoadCallbacks); + }); + } +})( + window, + document, + 'script', + 'onerror', + 'onunhandledrejection', + 'Sentry', + 'loader.js', + __LOADER_BUNDLE__, + __LOADER_OPTIONS__, + __LOADER_LAZY__ +); diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/captureException/subject.js b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/captureException/subject.js new file mode 100644 index 000000000000..fb0796f7f299 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/captureException/subject.js @@ -0,0 +1 @@ +Sentry.captureException('Test exception'); diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/captureException/test.ts b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/captureException/test.ts new file mode 100644 index 000000000000..e7441a654724 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/captureException/test.ts @@ -0,0 +1,15 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser,waitForErrorRequest } from '../../../../utils/helpers'; + +sentryTest('captureException works', async ({ getLocalTestUrl, page }) => { + const req = waitForErrorRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await req); + + expect(eventData.message).toBe('Test exception'); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandler/subject.js b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandler/subject.js new file mode 100644 index 000000000000..544cbfad3179 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandler/subject.js @@ -0,0 +1 @@ +window.doSomethingWrong(); diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandler/test.ts b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandler/test.ts new file mode 100644 index 000000000000..7c41e6993c20 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandler/test.ts @@ -0,0 +1,15 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser,waitForErrorRequest } from '../../../../utils/helpers'; + +sentryTest('error handler works', async ({ getLocalTestUrl, page }) => { + const req = waitForErrorRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await req); + expect(eventData.exception?.values?.length).toBe(1); + expect(eventData.exception?.values?.[0]?.value).toBe('window.doSomethingWrong is not a function'); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandlerLater/subject.js b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandlerLater/subject.js new file mode 100644 index 000000000000..71aaaeae4cf9 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandlerLater/subject.js @@ -0,0 +1,3 @@ +setTimeout(() => { + window.doSomethingWrong(); +}, 500); diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandlerLater/test.ts b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandlerLater/test.ts new file mode 100644 index 000000000000..760fba879b5c --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/errorHandlerLater/test.ts @@ -0,0 +1,16 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser,waitForErrorRequest } from '../../../../utils/helpers'; + +sentryTest('error handler works for later errors', async ({ getLocalTestUrl, page }) => { + const req = waitForErrorRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await req); + + expect(eventData.exception?.values?.length).toBe(1); + expect(eventData.exception?.values?.[0]?.value).toBe('window.doSomethingWrong is not a function'); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/init.js b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/init.js new file mode 100644 index 000000000000..aa799f411980 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/init.js @@ -0,0 +1,3 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/init.js b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/init.js new file mode 100644 index 000000000000..a44e16bcf7d9 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/init.js @@ -0,0 +1,4 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/test.ts b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/test.ts new file mode 100644 index 000000000000..d224c77bc9c1 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/test.ts @@ -0,0 +1,26 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser,shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('should create a pageload transaction', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const req = waitForTransactionRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await req); + const timeOrigin = await page.evaluate('window._testBaseTimestamp'); + + const { start_timestamp: startTimestamp } = eventData; + + expect(startTimestamp).toBeCloseTo(timeOrigin, 1); + + expect(eventData.contexts?.trace?.op).toBe('pageload'); + expect(eventData.spans?.length).toBeGreaterThan(0); + expect(eventData.transaction_info?.source).toEqual('url'); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/init.js b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/init.js new file mode 100644 index 000000000000..aa799f411980 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/init.js @@ -0,0 +1,3 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; diff --git a/packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/test.ts b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/test.ts new file mode 100644 index 000000000000..c162db40a9e5 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; + +sentryTest('should capture a replay', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const req = waitForReplayRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = getReplayEvent(await req); + + expect(eventData).toBeDefined(); + expect(eventData.segment_id).toBe(0); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/captureException/subject.js b/packages/browser-integration-tests/loader-suites/loader/onLoad/captureException/subject.js new file mode 100644 index 000000000000..fb0796f7f299 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/captureException/subject.js @@ -0,0 +1 @@ +Sentry.captureException('Test exception'); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/captureException/test.ts b/packages/browser-integration-tests/loader-suites/loader/onLoad/captureException/test.ts new file mode 100644 index 000000000000..e7441a654724 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/captureException/test.ts @@ -0,0 +1,15 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser,waitForErrorRequest } from '../../../../utils/helpers'; + +sentryTest('captureException works', async ({ getLocalTestUrl, page }) => { + const req = waitForErrorRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await req); + + expect(eventData.message).toBe('Test exception'); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandler/subject.js b/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandler/subject.js new file mode 100644 index 000000000000..544cbfad3179 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandler/subject.js @@ -0,0 +1 @@ +window.doSomethingWrong(); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandler/test.ts b/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandler/test.ts new file mode 100644 index 000000000000..116e65e94a3a --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandler/test.ts @@ -0,0 +1,16 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../utils/helpers'; + +sentryTest('error handler works', async ({ getLocalTestUrl, page }) => { + const req = waitForErrorRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await req); + + expect(eventData.exception?.values?.length).toBe(1); + expect(eventData.exception?.values?.[0]?.value).toBe('window.doSomethingWrong is not a function'); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandlerLater/subject.js b/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandlerLater/subject.js new file mode 100644 index 000000000000..71aaaeae4cf9 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandlerLater/subject.js @@ -0,0 +1,3 @@ +setTimeout(() => { + window.doSomethingWrong(); +}, 500); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandlerLater/test.ts b/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandlerLater/test.ts new file mode 100644 index 000000000000..760fba879b5c --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/errorHandlerLater/test.ts @@ -0,0 +1,16 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser,waitForErrorRequest } from '../../../../utils/helpers'; + +sentryTest('error handler works for later errors', async ({ getLocalTestUrl, page }) => { + const req = waitForErrorRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await req); + + expect(eventData.exception?.values?.length).toBe(1); + expect(eventData.exception?.values?.[0]?.value).toBe('window.doSomethingWrong is not a function'); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/init.js b/packages/browser-integration-tests/loader-suites/loader/onLoad/init.js new file mode 100644 index 000000000000..5a9398da8d47 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/init.js @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.onLoad(function () { + Sentry.init({}); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/pageloadTransaction/init.js b/packages/browser-integration-tests/loader-suites/loader/onLoad/pageloadTransaction/init.js new file mode 100644 index 000000000000..dcb6c3e90d0d --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/pageloadTransaction/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.onLoad(function () { + Sentry.init({}); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/pageloadTransaction/test.ts b/packages/browser-integration-tests/loader-suites/loader/onLoad/pageloadTransaction/test.ts new file mode 100644 index 000000000000..d224c77bc9c1 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/pageloadTransaction/test.ts @@ -0,0 +1,26 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser,shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('should create a pageload transaction', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const req = waitForTransactionRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await req); + const timeOrigin = await page.evaluate('window._testBaseTimestamp'); + + const { start_timestamp: startTimestamp } = eventData; + + expect(startTimestamp).toBeCloseTo(timeOrigin, 1); + + expect(eventData.contexts?.trace?.op).toBe('pageload'); + expect(eventData.spans?.length).toBeGreaterThan(0); + expect(eventData.transaction_info?.source).toEqual('url'); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/replay/init.js b/packages/browser-integration-tests/loader-suites/loader/onLoad/replay/init.js new file mode 100644 index 000000000000..5a9398da8d47 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/replay/init.js @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.onLoad(function () { + Sentry.init({}); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/replay/test.ts b/packages/browser-integration-tests/loader-suites/loader/onLoad/replay/test.ts new file mode 100644 index 000000000000..c162db40a9e5 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/replay/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; + +sentryTest('should capture a replay', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const req = waitForReplayRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = getReplayEvent(await req); + + expect(eventData).toBeDefined(); + expect(eventData.segment_id).toBe(0); +}); diff --git a/packages/browser-integration-tests/package.json b/packages/browser-integration-tests/package.json index aa87f1c628a9..da75e00b87b4 100644 --- a/packages/browser-integration-tests/package.json +++ b/packages/browser-integration-tests/package.json @@ -8,7 +8,7 @@ }, "private": true, "scripts": { - "clean": "rimraf -g suites/**/dist", + "clean": "rimraf -g suites/**/dist loader-suites/**/dist", "install-browsers": "playwright install --with-deps", "lint": "run-s lint:prettier lint:eslint", "lint:eslint": "eslint . --format stylish", @@ -33,6 +33,12 @@ "test:bundle:tracing:replay:es6:min": "PW_BUNDLE=bundle_tracing_replay_es6_min yarn test", "test:cjs": "PW_BUNDLE=cjs yarn test", "test:esm": "PW_BUNDLE=esm yarn test", + "test:loader": "playwright test ./loader-suites", + "test:loader:base": "PW_BUNDLE=loader_base yarn test:loader", + "test:loader:eager": "PW_BUNDLE=loader_eager yarn test:loader", + "test:loader:tracing": "PW_BUNDLE=loader_tracing yarn test:loader", + "test:loader:replay": "PW_BUNDLE=loader_replay yarn test:loader", + "test:loader:full": "PW_BUNDLE=loader_tracing_replay yarn test:loader", "test:ci": "playwright test ./suites --browser='all' --reporter='line'", "test:update-snapshots": "yarn test --update-snapshots --browser='all' && yarn test --update-snapshots", "test:detect-flaky": "ts-node scripts/detectFlakyTests.ts" diff --git a/packages/browser-integration-tests/utils/fixtures.ts b/packages/browser-integration-tests/utils/fixtures.ts index 05ac906ad2d2..250c9a1ac3df 100644 --- a/packages/browser-integration-tests/utils/fixtures.ts +++ b/packages/browser-integration-tests/utils/fixtures.ts @@ -3,7 +3,9 @@ import { test as base } from '@playwright/test'; import fs from 'fs'; import path from 'path'; -import { generatePage } from './generatePage'; +import { generateLoader, generatePage } from './generatePage'; + +export const TEST_HOST = 'http://sentry-test.io'; const getAsset = (assetDir: string, asset: string): string => { const assetPath = `${assetDir}/${asset}`; @@ -25,6 +27,7 @@ export type TestFixtures = { _autoSnapshotSuffix: void; testDir: string; getLocalTestPath: (options: { testDir: string }) => Promise; + getLocalTestUrl: (options: { testDir: string }) => Promise; forceFlushReplay: () => Promise; runInChromium: (fn: (...args: unknown[]) => unknown, args?: unknown[]) => unknown; runInFirefox: (fn: (...args: unknown[]) => unknown, args?: unknown[]) => unknown; @@ -45,32 +48,30 @@ const sentryTest = base.extend({ { auto: true }, ], - getLocalTestPath: ({}, use, testInfo) => { + getLocalTestUrl: ({ page }, use) => { return use(async ({ testDir }) => { - const pagePath = `file:///${path.resolve(testDir, './dist/index.html')}`; + const pagePath = `${TEST_HOST}/index.html`; - // Build test page if it doesn't exist - if (!fs.existsSync(pagePath)) { - const testDir = path.dirname(testInfo.file); - const subject = getAsset(testDir, 'subject.js'); - const template = getAsset(testDir, 'template.html'); - const init = getAsset(testDir, 'init.js'); + await build(testDir); + generateLoader(testDir); - await generatePage(init, subject, template, testDir); - } + // Serve all assets under + await page.route(`${TEST_HOST}/*.*`, route => { + const file = route.request().url().split('/').pop(); + const filePath = path.resolve(testDir, `./dist/${file}`); - const additionalPages = fs - .readdirSync(testDir) - .filter(filename => filename.startsWith('page-') && filename.endsWith('.html')); - - const outDir = path.dirname(testInfo.file); - for (const pageFilename of additionalPages) { - // create a new page with the same subject and init as before - const subject = getAsset(testDir, 'subject.js'); - const pageFile = getAsset(testDir, pageFilename); - const init = getAsset(testDir, 'init.js'); - await generatePage(init, subject, pageFile, outDir, pageFilename); - } + return fs.existsSync(filePath) ? route.fulfill({ path: filePath }) : route.continue(); + }); + + return pagePath; + }); + }, + + getLocalTestPath: ({}, use) => { + return use(async ({ testDir }) => { + const pagePath = `file:///${path.resolve(testDir, './dist/index.html')}`; + + await build(testDir); return pagePath; }); @@ -110,3 +111,23 @@ const sentryTest = base.extend({ }); export { sentryTest }; + +async function build(testDir: string): Promise { + const subject = getAsset(testDir, 'subject.js'); + const template = getAsset(testDir, 'template.html'); + const init = getAsset(testDir, 'init.js'); + + await generatePage(init, subject, template, testDir); + + const additionalPages = fs + .readdirSync(testDir) + .filter(filename => filename.startsWith('page-') && filename.endsWith('.html')); + + for (const pageFilename of additionalPages) { + // create a new page with the same subject and init as before + const subject = getAsset(testDir, 'subject.js'); + const pageFile = getAsset(testDir, pageFilename); + const init = getAsset(testDir, 'init.js'); + await generatePage(init, subject, pageFile, testDir, pageFilename); + } +} diff --git a/packages/browser-integration-tests/utils/generatePage.ts b/packages/browser-integration-tests/utils/generatePage.ts index e48485101dd3..a3bd1d7c8991 100644 --- a/packages/browser-integration-tests/utils/generatePage.ts +++ b/packages/browser-integration-tests/utils/generatePage.ts @@ -1,12 +1,85 @@ -import { existsSync, mkdirSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'; import HtmlWebpackPlugin from 'html-webpack-plugin'; +import path from 'path'; import webpack from 'webpack'; import webpackConfig from '../webpack.config'; +import { TEST_HOST } from './fixtures'; import SentryScenarioGenerationPlugin from './generatePlugin'; +const LOADER_TEMPLATE = readFileSync(path.join(__dirname, '../fixtures/loader.js'), 'utf-8'); + +const LOADER_CONFIGS: Record; lazy: boolean }> = { + loader_base: { + bundle: 'browser/build/bundles/bundle.es5.js', + options: {}, + lazy: true, + }, + loader_eager: { + bundle: 'browser/build/bundles/bundle.es5.js', + options: {}, + lazy: false, + }, + loader_tracing: { + bundle: 'browser/build/bundles/bundle.tracing.es5.js', + options: { tracesSampleRate: 1 }, + lazy: false, + }, + loader_replay: { + bundle: 'browser/build/bundles/bundle.replay.min.js', + options: { replaysSessionSampleRate: 1, replaysOnErrorSampleRate: 1 }, + lazy: false, + }, + loader_tracing_replay: { + bundle: 'browser/build/bundles/bundle.tracing.replay.debug.min.js', + options: { tracesSampleRate: 1, replaysSessionSampleRate: 1, replaysOnErrorSampleRate: 1, debug: true }, + lazy: false, + }, +}; + +const bundleKey = process.env.PW_BUNDLE || ''; + +export function generateLoader(outPath: string): void { + const localPath = `${outPath}/dist`; + + if (!existsSync(localPath)) { + return; + } + + // Generate loader files + const loaderConfig = LOADER_CONFIGS[bundleKey]; + + if (!loaderConfig) { + throw new Error(`Unknown loader bundle key: ${bundleKey}`); + } + + const localCdnBundlePath = path.join(localPath, 'cdn.bundle.js'); + + try { + unlinkSync(localCdnBundlePath); + } catch { + // ignore if this didn't exist + } + + const cdnSourcePath = path.resolve(__dirname, `../../${loaderConfig.bundle}`); + symlinkSync(cdnSourcePath, localCdnBundlePath); + + const loaderPath = path.join(localPath, 'loader.js'); + const loaderContent = LOADER_TEMPLATE.replace('__LOADER_BUNDLE__', `'${TEST_HOST}/cdn.bundle.js'`) + .replace( + '__LOADER_OPTIONS__', + JSON.stringify({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + ...loaderConfig.options, + }), + ) + .replace('__LOADER_LAZY__', loaderConfig.lazy ? 'true' : 'false'); + + writeFileSync(loaderPath, loaderContent, 'utf-8'); +} + export async function generatePage( - initializationPath: string, + initPath: string, subjectPath: string, templatePath: string, outPath: string, @@ -24,7 +97,7 @@ export async function generatePage( const compiler = webpack( webpackConfig({ entry: { - initialization: initializationPath, + init: initPath, subject: subjectPath, }, output: { diff --git a/packages/browser-integration-tests/utils/generatePlugin.ts b/packages/browser-integration-tests/utils/generatePlugin.ts index 7ca686992bc3..202ea6f6126d 100644 --- a/packages/browser-integration-tests/utils/generatePlugin.ts +++ b/packages/browser-integration-tests/utils/generatePlugin.ts @@ -1,5 +1,5 @@ import type { Package } from '@sentry/types'; -import { readdirSync, readFileSync } from 'fs'; +import fs from 'fs'; import HtmlWebpackPlugin, { createHtmlTagObject } from 'html-webpack-plugin'; import path from 'path'; import type { Compiler } from 'webpack'; @@ -9,13 +9,14 @@ const PACKAGES_DIR = '../../packages'; /** * Possible values: See BUNDLE_PATHS.browser */ -const bundleKey = process.env.PW_BUNDLE; +const bundleKey = process.env.PW_BUNDLE || ''; // `esm` and `cjs` builds are modules that can be imported / aliased by webpack const useCompiledModule = bundleKey === 'esm' || bundleKey === 'cjs'; // Bundles need to be injected into HTML before Sentry initialization. -const useBundle = bundleKey && !useCompiledModule; +const useBundleOrLoader = bundleKey && !useCompiledModule; +const useLoader = bundleKey.startsWith('loader'); const BUNDLE_PATHS: Record> = { browser: { @@ -63,7 +64,8 @@ const BUNDLE_PATHS: Record> = { * so that the compiled versions aren't included */ function generateSentryAlias(): Record { - const packageNames = readdirSync(PACKAGES_DIR, { withFileTypes: true }) + const packageNames = fs + .readdirSync(PACKAGES_DIR, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .filter(dir => !['apm', 'minimal', 'next-plugin-sentry'].includes(dir.name)) .map(dir => dir.name); @@ -71,7 +73,7 @@ function generateSentryAlias(): Record { return Object.fromEntries( packageNames.map(packageName => { const packageJSON: Package = JSON.parse( - readFileSync(path.resolve(PACKAGES_DIR, packageName, 'package.json'), { encoding: 'utf-8' }).toString(), + fs.readFileSync(path.resolve(PACKAGES_DIR, packageName, 'package.json'), { encoding: 'utf-8' }).toString(), ); const modulePath = path.resolve(PACKAGES_DIR, packageName); @@ -82,7 +84,7 @@ function generateSentryAlias(): Record { return [packageJSON['name'], bundlePath]; } - if (useBundle && bundleKey) { + if (useBundleOrLoader) { // If we're injecting a bundle, ignore the webpack imports. return [packageJSON['name'], false]; } @@ -100,17 +102,16 @@ class SentryScenarioGenerationPlugin { public apply(compiler: Compiler): void { compiler.options.resolve.alias = generateSentryAlias(); - compiler.options.externals = - useBundle && bundleKey - ? { - // To help Webpack resolve Sentry modules in `import` statements in cases where they're provided in bundles rather than in `node_modules` - '@sentry/browser': 'Sentry', - '@sentry/tracing': 'Sentry', - '@sentry/replay': 'Sentry', - '@sentry/integrations': 'Sentry.Integrations', - '@sentry/wasm': 'Sentry.Integrations', - } - : {}; + compiler.options.externals = useBundleOrLoader + ? { + // To help Webpack resolve Sentry modules in `import` statements in cases where they're provided in bundles rather than in `node_modules` + '@sentry/browser': 'Sentry', + '@sentry/tracing': 'Sentry', + '@sentry/replay': 'Sentry', + '@sentry/integrations': 'Sentry.Integrations', + '@sentry/wasm': 'Sentry.Integrations', + } + : {}; // Checking if the current scenario has imported `@sentry/tracing` or `@sentry/integrations`. compiler.hooks.normalModuleFactory.tap(this._name, factory => { @@ -131,16 +132,31 @@ class SentryScenarioGenerationPlugin { compiler.hooks.compilation.tap(this._name, compilation => { HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync(this._name, (data, cb) => { - if (useBundle && bundleKey) { + if (useBundleOrLoader) { const bundleName = 'browser'; const bundlePath = BUNDLE_PATHS[bundleName][bundleKey]; - // Convert e.g. bundle_tracing_es5_min to bundle_es5_min - const integrationBundleKey = bundleKey.replace('_replay', '').replace('_tracing', ''); + let bundleObject = + bundlePath && + createHtmlTagObject('script', { + src: path.resolve(PACKAGES_DIR, bundleName, bundlePath), + }); - const bundleObject = createHtmlTagObject('script', { - src: path.resolve(PACKAGES_DIR, bundleName, bundlePath), - }); + if (!bundleObject && useLoader) { + bundleObject = createHtmlTagObject('script', { + src: 'loader.js', + }); + } + + if (!bundleObject) { + throw new Error(`Could not find bundle or loader for key ${bundleKey}`); + } + + // Convert e.g. bundle_tracing_es5_min to bundle_es5_min + const integrationBundleKey = bundleKey + .replace('loader_', 'bundle_') + .replace('_replay', '') + .replace('_tracing', ''); this.requiredIntegrations.forEach(integration => { const integrationObject = createHtmlTagObject('script', { diff --git a/packages/browser-integration-tests/utils/helpers.ts b/packages/browser-integration-tests/utils/helpers.ts index bbdb0c2d0655..296b2fcdba91 100644 --- a/packages/browser-integration-tests/utils/helpers.ts +++ b/packages/browser-integration-tests/utils/helpers.ts @@ -125,6 +125,23 @@ export function waitForErrorRequest(page: Page): Promise { }); } +export function waitForTransactionRequest(page: Page): Promise { + return page.waitForRequest(req => { + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + const event = envelopeRequestParser(req); + + return event.type === 'transaction'; + } catch { + return false; + } + }); +} + /** * We can only test tracing tests in certain bundles/packages: * - NPM (ESM, CJS) diff --git a/packages/replay/src/util/sendReplayRequest.ts b/packages/replay/src/util/sendReplayRequest.ts index 2d512915ba47..f08063f38e45 100644 --- a/packages/replay/src/util/sendReplayRequest.ts +++ b/packages/replay/src/util/sendReplayRequest.ts @@ -41,7 +41,6 @@ export async function sendReplayRequest({ } const baseEvent: ReplayEvent = { - // @ts-ignore private api type: REPLAY_EVENT_NAME, ...(includeReplayStartTimestamp ? { replay_start_timestamp: initialTimestamp / 1000 } : {}), timestamp: timestamp / 1000, diff --git a/packages/types/src/replay.ts b/packages/types/src/replay.ts index 79bbce4c6eb4..975c1f0c8c59 100644 --- a/packages/types/src/replay.ts +++ b/packages/types/src/replay.ts @@ -6,6 +6,7 @@ import type { Event } from './event'; */ export interface ReplayEvent extends Event { urls: string[]; + replay_start_timestamp?: number; error_ids: string[]; trace_ids: string[]; replay_id: string; diff --git a/packages/utils/src/instrument.ts b/packages/utils/src/instrument.ts index e227016e1b6f..60401eafec97 100644 --- a/packages/utils/src/instrument.ts +++ b/packages/utils/src/instrument.ts @@ -578,12 +578,12 @@ function instrumentDOM(): void { }); } -let _oldOnErrorHandler: OnErrorEventHandler = null; +let _oldOnErrorHandler: typeof WINDOW['onerror'] | null = null; /** JSDoc */ function instrumentError(): void { _oldOnErrorHandler = WINDOW.onerror; - WINDOW.onerror = function (msg: any, url: any, line: any, column: any, error: any): boolean { + WINDOW.onerror = function (msg: unknown, url: unknown, line: unknown, column: unknown, error: unknown): boolean { triggerHandlers('error', { column, error, @@ -592,16 +592,18 @@ function instrumentError(): void { url, }); - if (_oldOnErrorHandler) { + if (_oldOnErrorHandler && !_oldOnErrorHandler.__SENTRY_LOADER__) { // eslint-disable-next-line prefer-rest-params return _oldOnErrorHandler.apply(this, arguments); } return false; }; + + WINDOW.onerror.__SENTRY_INSTRUMENTED__ = true; } -let _oldOnUnhandledRejectionHandler: ((e: any) => void) | null = null; +let _oldOnUnhandledRejectionHandler: typeof WINDOW['onunhandledrejection'] | null = null; /** JSDoc */ function instrumentUnhandledRejection(): void { _oldOnUnhandledRejectionHandler = WINDOW.onunhandledrejection; @@ -609,11 +611,13 @@ function instrumentUnhandledRejection(): void { WINDOW.onunhandledrejection = function (e: any): boolean { triggerHandlers('unhandledrejection', e); - if (_oldOnUnhandledRejectionHandler) { + if (_oldOnUnhandledRejectionHandler && !_oldOnUnhandledRejectionHandler.__SENTRY_LOADER__) { // eslint-disable-next-line prefer-rest-params return _oldOnUnhandledRejectionHandler.apply(this, arguments); } return true; }; + + WINDOW.onunhandledrejection.__SENTRY_INSTRUMENTED__ = true; } diff --git a/packages/utils/src/worldwide.ts b/packages/utils/src/worldwide.ts index 7b61814229cf..37a6deb30851 100644 --- a/packages/utils/src/worldwide.ts +++ b/packages/utils/src/worldwide.ts @@ -23,6 +23,16 @@ export interface InternalGlobal { Sentry?: { Integrations?: Integration[]; }; + onerror?: { + (msg: unknown, url: unknown, line: unknown, column: unknown, error: unknown): boolean; + __SENTRY_INSTRUMENTED__?: true; + __SENTRY_LOADER__?: true; + }; + onunhandledrejection?: { + (event: unknown): boolean; + __SENTRY_INSTRUMENTED__?: true; + __SENTRY_LOADER__?: true; + }; SENTRY_ENVIRONMENT?: string; SENTRY_DSN?: string; SENTRY_RELEASE?: {