From f92f88ec7a90617d644019a32baeab5fd9595201 Mon Sep 17 00:00:00 2001 From: Patrick Housley Date: Wed, 21 Jun 2023 14:24:22 -0500 Subject: [PATCH] feat: Remove img, jsonp, and xhrGet methods (#576) --- .github/workflows/ci.yml | 59 -- .github/workflows/jest.yml | 20 +- .github/workflows/pull-request-checks.yml | 118 ++- .github/workflows/wdio-all-browsers.yml | 8 + .github/workflows/wdio-single-browser.yml | 6 +- babel.config.js | 14 +- codecov.yml | 3 + jest.component-config.js | 2 +- package-lock.json | 12 +- src/common/browser-version/firefox-version.js | 10 - src/common/browser-version/ios-version.js | 11 - src/common/config/__mocks__/config.js | 11 + src/common/config/state/init.js | 1 + src/common/config/state/runtime.js | 2 +- src/common/constants/__mocks__/env.js | 3 + src/common/constants/__mocks__/runtime.js | 8 + src/common/constants/env.cdn.test.js | 7 + src/common/constants/env.npm.test.js | 7 + src/common/constants/env.test.js | 7 + src/common/constants/runtime.js | 69 ++ src/common/constants/runtime.test.js | 168 ++++ .../context/__mocks__/shared-context.js | 8 + .../__mocks__/event-listener-opts.js | 7 + .../event-listener/event-listener-opts.js | 2 +- src/common/harvest/__mocks__/harvest.js | 13 + .../harvest-scheduler.component-test.js | 25 - src/common/harvest/harvest-scheduler.js | 41 +- src/common/harvest/harvest-scheduler.test.js | 496 +++++++++++ src/common/harvest/harvest.component-test.js | 169 ---- src/common/harvest/harvest.js | 266 +++--- src/common/harvest/harvest.test.js | 818 ++++++++++++++++++ src/common/harvest/types.js | 47 + src/common/ids/id.js | 2 +- src/common/ids/unique-id.js | 2 +- .../session/__mocks__/session-entity.js | 25 + .../session/session-entity.component-test.js | 2 +- src/common/session/session-entity.js | 2 +- src/common/timer/interaction-timer.js | 2 +- src/common/timing/__mocks__/now.js | 1 + src/common/unload/__mocks__/eol.js | 1 + src/common/unload/eol.js | 3 +- src/common/url/__mocks__/clean-url.js | 1 + src/common/url/__mocks__/encode.js | 7 + src/common/url/__mocks__/location.js | 1 + src/common/url/canonicalize-url.js | 2 +- src/common/url/canonicalize-url.test.js | 4 +- src/common/url/parse-url.js | 2 +- src/common/url/parse-url.test.js | 6 +- src/common/url/protocol.js | 2 +- src/common/util/__mocks__/obfuscate.js | 10 + src/common/util/__mocks__/stringify.js | 1 + src/common/util/__mocks__/submit-data.js | 6 + src/common/util/__mocks__/traverse.js | 1 + src/common/util/global-scope.js | 23 - src/common/util/global-scope.test.js | 87 -- src/common/util/submit-data.js | 92 +- src/common/util/submit-data.test.js | 149 ++-- src/common/window/nreum.js | 2 +- src/common/wrap/wrap-events.js | 2 +- src/common/wrap/wrap-fetch.js | 2 +- src/common/wrap/wrap-history.js | 2 +- src/common/wrap/wrap-jsonp.js | 2 +- src/common/wrap/wrap-mutation.js | 2 +- src/common/wrap/wrap-promise.js | 2 +- src/common/wrap/wrap-promise.test.js | 4 +- src/common/wrap/wrap-raf.js | 2 +- src/common/wrap/wrap-timer.js | 2 +- src/common/wrap/wrap-xhr.js | 2 +- src/features/ajax/aggregate/index.js | 2 +- .../ajax/instrument/distributed-tracing.js | 2 +- src/features/ajax/instrument/index.js | 3 +- .../aggregate/compute-stack-trace.test.js | 2 +- src/features/jserrors/aggregate/index.js | 10 +- src/features/jserrors/instrument/index.js | 2 +- .../metrics/aggregate/framework-detection.js | 2 +- .../aggregate/framework-detection.test.js | 4 +- src/features/metrics/aggregate/index.js | 2 +- src/features/page_action/aggregate/index.js | 2 +- .../page_view_event/aggregate/index.js | 3 +- .../page_view_event/instrument/index.js | 2 +- .../page_view_timing/aggregate/index.js | 6 +- .../page_view_timing/instrument/index.js | 2 +- .../session_trace/instrument/index.js | 2 +- src/features/spa/aggregate/index.js | 2 +- src/features/spa/instrument/index.js | 2 +- src/features/utils/agent-session.test.js | 4 +- src/features/utils/instrument-base.js | 2 +- src/features/utils/instrument-base.test.js | 4 +- src/loaders/agent.js | 8 + src/loaders/api/api.js | 2 +- src/loaders/api/apiAsync.js | 6 +- src/loaders/configure/configure.js | 2 +- tests/browser/err/zz-error.browser.js | 2 +- tests/browser/harvest-scheduler.browser.js | 202 ----- tests/browser/harvest.browser.js | 713 --------------- tests/browser/xhr/index.browser.js | 2 +- .../browser/xhr/onreadystatechange.browser.js | 4 +- tests/functional/disable-harvest.test.js | 115 --- tests/functional/err/harvest.test.js | 59 -- tests/functional/final-harvest.test.js | 411 --------- tests/functional/harvest.test.js | 155 ---- .../ins/page-action-submission.test.js | 56 -- tests/functional/pvt/timings.test.js | 7 +- tests/functional/spa/harvest.test.js | 106 --- tests/functional/stn/harvest.test.js | 188 ---- tests/functional/xhr/harvest.test.js | 64 -- tests/specs/api.e2e.js | 6 +- tests/specs/err/bucketing.e2e.js | 10 +- tests/specs/err/error-payload.e2e.js | 8 +- tests/specs/err/retry-harvesting.e2e.js | 69 ++ tests/specs/framework-detection.e2e.js | 8 +- .../harvesting/disable-harvesting.e2e.js | 98 +++ .../specs/harvesting/final-harvesting.e2e.js | 197 +++++ tests/specs/harvesting/index.e2e.js | 329 +++++++ tests/specs/ins/retry-harvesting.e2e.js | 69 ++ tests/specs/metrics.e2e.js | 24 +- tests/specs/obfuscate.e2e.js | 4 +- tests/specs/rum/retry-harvesting.e2e.js | 60 ++ tests/specs/session-manager.e2e.js | 2 +- tests/specs/session-replay/harvest.e2e.js | 27 +- tests/specs/session-replay/helpers.js | 23 +- tests/specs/session-replay/ingest.e2e.js | 17 +- .../session-replay/initialization.e2e.js | 78 +- tests/specs/session-replay/mode.e2e.js | 61 +- tests/specs/session-replay/payload.e2e.js | 18 +- .../session-replay/rrweb-configuration.e2e.js | 182 ++-- .../specs/session-replay/session-pages.e2e.js | 77 +- .../specs/session-replay/session-state.e2e.js | 79 +- tests/specs/spa/harvesting.e2e.js | 27 + tests/specs/spa/retry-harvesting.e2e.js | 63 ++ tests/specs/stack-trace.e2e.js | 6 +- tests/specs/stn/retry-harvesting.e2e.js | 83 ++ .../third-party-compatibility/jspdf.e2e.js | 4 +- .../third-party-compatibility/mootools.e2e.js | 4 +- .../requirejs.e2e.js | 4 +- tests/specs/xhr/retry-harvesting.e2e.js | 111 +++ .../browser-agent-wrapper/package.json | 2 +- tools/test-builds/browser-tests/package.json | 2 +- .../test-builds/raw-src-wrapper/package.json | 2 +- .../vite-react-wrapper/package.json | 2 +- tools/testing-server/index.js | 9 +- tools/testing-server/logger.js | 4 +- .../plugins/agent-injector/debug-shim.js | 23 +- .../plugins/compression-interceptor/index.js | 1 + .../plugins/request-logger/index.js | 9 +- .../plugins/test-handle/index.js | 2 +- tools/testing-server/routes/bam-apis.js | 3 + tools/testing-server/routes/command-apis.js | 15 +- tools/testing-server/routes/mock-apis.js | 3 + tools/testing-server/test-handle.js | 12 +- tools/wdio/args.mjs | 1 - tools/wdio/config/base.conf.mjs | 2 +- tools/wdio/config/sauce.conf.mjs | 5 - tools/wdio/plugins/browser-matcher.mjs | 46 +- tools/wdio/plugins/custom-commands.mjs | 49 ++ tools/wdio/plugins/istanbul.mjs | 6 + .../wdio/plugins/newrelic-instrumentation.mjs | 11 + .../testing-server/default-asset-query.mjs | 4 +- tools/wdio/plugins/testing-server/index.mjs | 23 +- .../wdio/plugins/testing-server/launcher.mjs | 85 +- .../testing-server/test-handle-connector.mjs | 11 + tsconfig.json | 2 +- webpack.config.js | 5 +- 163 files changed, 3870 insertions(+), 3275 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 codecov.yml delete mode 100644 src/common/browser-version/firefox-version.js delete mode 100644 src/common/browser-version/ios-version.js create mode 100644 src/common/config/__mocks__/config.js create mode 100644 src/common/constants/__mocks__/env.js create mode 100644 src/common/constants/__mocks__/runtime.js create mode 100644 src/common/constants/env.cdn.test.js create mode 100644 src/common/constants/env.npm.test.js create mode 100644 src/common/constants/env.test.js create mode 100644 src/common/constants/runtime.js create mode 100644 src/common/constants/runtime.test.js create mode 100644 src/common/context/__mocks__/shared-context.js create mode 100644 src/common/event-listener/__mocks__/event-listener-opts.js create mode 100644 src/common/harvest/__mocks__/harvest.js delete mode 100644 src/common/harvest/harvest-scheduler.component-test.js create mode 100644 src/common/harvest/harvest-scheduler.test.js delete mode 100644 src/common/harvest/harvest.component-test.js create mode 100644 src/common/harvest/harvest.test.js create mode 100644 src/common/harvest/types.js create mode 100644 src/common/session/__mocks__/session-entity.js create mode 100644 src/common/timing/__mocks__/now.js create mode 100644 src/common/unload/__mocks__/eol.js create mode 100644 src/common/url/__mocks__/clean-url.js create mode 100644 src/common/url/__mocks__/encode.js create mode 100644 src/common/url/__mocks__/location.js create mode 100644 src/common/util/__mocks__/obfuscate.js create mode 100644 src/common/util/__mocks__/stringify.js create mode 100644 src/common/util/__mocks__/submit-data.js create mode 100644 src/common/util/__mocks__/traverse.js delete mode 100644 src/common/util/global-scope.js delete mode 100644 src/common/util/global-scope.test.js delete mode 100644 tests/browser/harvest-scheduler.browser.js delete mode 100644 tests/browser/harvest.browser.js delete mode 100644 tests/functional/disable-harvest.test.js delete mode 100644 tests/functional/err/harvest.test.js delete mode 100644 tests/functional/final-harvest.test.js delete mode 100644 tests/functional/harvest.test.js delete mode 100644 tests/functional/spa/harvest.test.js delete mode 100644 tests/functional/stn/harvest.test.js delete mode 100644 tests/functional/xhr/harvest.test.js create mode 100644 tests/specs/err/retry-harvesting.e2e.js create mode 100644 tests/specs/harvesting/disable-harvesting.e2e.js create mode 100644 tests/specs/harvesting/final-harvesting.e2e.js create mode 100644 tests/specs/harvesting/index.e2e.js create mode 100644 tests/specs/ins/retry-harvesting.e2e.js create mode 100644 tests/specs/rum/retry-harvesting.e2e.js create mode 100644 tests/specs/spa/harvesting.e2e.js create mode 100644 tests/specs/spa/retry-harvesting.e2e.js create mode 100644 tests/specs/stn/retry-harvesting.e2e.js create mode 100644 tests/specs/xhr/retry-harvesting.e2e.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index d13f5a984..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,59 +0,0 @@ -# This workflow runs sanity checks on pull requests to ensure they meet -# a minimum standard such as building, linting, and testing without error. -# This workflow also contains processing for decorating pull requests with -# additional information like package size increases. -# -# This workflow runs on main to update external services like codecov with -# the latest code, results, etc. -name: Browser Agent CI - -on: - workflow_dispatch: - push: - branches: - - 'main' - -jobs: - size-check: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 30 - container: - image: ubuntu:latest - defaults: - run: - shell: bash - steps: - - name: Setup container - run: apt update && apt install -y git - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: lts/* - - name: Install dependencies - run: npm ci - - name: Running cdn build - run: npm run cdn:build:dev - - name: Running npm build - run: | - npm run build:npm - npm run tools:test-builds - - name: Generating npm build stats - run: node ./tools/scripts/npm-build-stats.js - - name: Generating asset size report - run: node ./tools/scripts/diff-sizes.mjs -o build - - name: Creating pull request comment - run: | - node ./tools/scripts/comment-pr.mjs \ - --pull-request=${{ github.event.pull_request.number }} \ - --token=${{ secrets.GITHUB_TOKEN }} \ - --input=./build/size_report.md \ - --tag='' - - name: Archive asset size report results - uses: actions/upload-artifact@v3 - with: - name: asset-size-report - path: | - build/size_report.* - build/*.stats.html - build/*.stats.json diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml index b5fad08f4..09234ed11 100644 --- a/.github/workflows/jest.yml +++ b/.github/workflows/jest.yml @@ -10,10 +10,15 @@ on: description: 'The collection of jest tests to run' required: true type: choice - options: + options: - unit - component default: 'unit' + coverage: + description: 'Enable code coverage' + required: false + type: boolean + default: false workflow_call: inputs: ref: @@ -25,6 +30,11 @@ on: required: false type: string default: 'unit' + coverage: + description: 'Enable code coverage' + required: false + type: boolean + default: false jobs: run: @@ -36,6 +46,8 @@ jobs: defaults: run: shell: bash + env: + COVERAGE: ${{ inputs.coverage }} steps: - name: Setup container run: apt update && apt install -y git @@ -51,11 +63,12 @@ jobs: run: npm run test:${{ inputs.collection }} -- --coverage - name: Find pull request id: pull-request-target + if: ${{ inputs.coverage }} uses: ./.github/actions/find-pull-request with: token: ${{ secrets.GITHUB_TOKEN }} - name: Upload pr code coverage - if: ${{ steps.pull-request-target.outputs.results }} + if: ${{ inputs.coverage && steps.pull-request-target.outputs.results }} uses: codecov/codecov-action@v3 env: GITHUB_HEAD_REF: ${{ inputs.ref }} @@ -65,13 +78,14 @@ jobs: flags: jest-${{ inputs.collection }} verbose: true - name: Upload branch code coverage - if: ${{ !steps.pull-request-target.outputs.results }} + if: ${{ inputs.coverage && steps.pull-request-target.outputs.results }} uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} flags: jest-${{ inputs.collection }} verbose: true - name: Archive code coverage results + if: ${{ inputs.coverage }} uses: actions/upload-artifact@v3 with: name: jest-${{ inputs.collection }}-code-coverage-report diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml index 232954bef..9fbad8b05 100644 --- a/.github/workflows/pull-request-checks.yml +++ b/.github/workflows/pull-request-checks.yml @@ -28,8 +28,8 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} pr_required: true - comment-pull-request: - name: Comment pull request + pending-comment-pull-request: + name: Pending comment pull request needs: [find-pull-request] runs-on: ubuntu-latest container: @@ -53,7 +53,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} pr_number: ${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number }} comment: | - [![Pull Request Checks](https://github.com/newrelic/newrelic-browser-agent/actions/workflows/pull-request-checks.yml/badge.svg?branch=${{ github.ref_name }})](https://github.com/newrelic/newrelic-browser-agent/actions/runs/${{ github.run_id }}) + [![Static Badge](https://img.shields.io/badge/Pull_Request_Checks-Pending-yellow)](https://github.com/newrelic/newrelic-browser-agent/actions/runs/${{ github.run_id }}) Last ran on `${{ steps.workflow-time.outputs.results }}` Checking merge of (${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).head_sha }}) into [${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_ref }}](https://github.com/newrelic/newrelic-browser-agent/compare/${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).head_sha }}..${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_sha }}) (${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_sha }}) @@ -66,6 +66,7 @@ jobs: with: ref: 'refs/pull/${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number }}/merge' collection: 'unit' + coverage: true secrets: inherit jest-component: @@ -75,6 +76,7 @@ jobs: with: ref: 'refs/pull/${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number }}/merge' collection: 'component' + coverage: true secrets: inherit eslint: @@ -94,3 +96,113 @@ jobs: build-number: PR${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number}}-job-${{ github.run_number }}-attempt-${{ github.run_attempt }} coverage: true secrets: inherit + + size-check: + runs-on: ubuntu-latest + needs: find-pull-request + timeout-minutes: 30 + container: + image: ubuntu:latest + defaults: + run: + shell: bash + steps: + - name: Setup container + run: apt update && apt install -y git + - uses: actions/checkout@v3 + with: + ref: 'refs/pull/${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number }}/merge' + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Running cdn build + run: npm run cdn:build:dev + - name: Running npm build + run: | + npm run build:npm + npm run tools:test-builds + - name: Generating npm build stats + run: node ./tools/scripts/npm-build-stats.js + - name: Generating asset size report + run: node ./tools/scripts/diff-sizes.mjs -o build + - name: Reading asset size report + id: asset-size-report + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "results<<$EOF" >> "$GITHUB_OUTPUT" + cat ./build/size_report.md >> "$GITHUB_OUTPUT" + echo "$EOF" >> "$GITHUB_OUTPUT" + - name: Comment pull request + uses: ./.github/actions/comment-pull-request + with: + token: ${{ secrets.GITHUB_TOKEN }} + pr_number: ${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number }} + comment: ${{ steps.asset-size-report.outputs.results }} + comment_tag: + - name: Archive asset size report results + uses: actions/upload-artifact@v3 + with: + name: asset-size-report + path: | + build/size_report.* + build/*.stats.html + build/*.stats.json + + status-comment-pull-request: + name: Comment pull request + needs: [find-pull-request,jest-unit,jest-component,eslint,wdio-coverage] + if: ${{ always() && needs.find-pull-request.result == 'success' }} + runs-on: ubuntu-latest + container: + image: ubuntu:latest + defaults: + run: + shell: bash + steps: + - name: Setup container + run: apt update && DEBIAN_FRONTEND=noninteractive apt install -y git tzdata + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - name: Get workflow time + id: workflow-time + run: echo "results=$(TZ=America/Chicago date +'%B %d, %Y %H:%M:%S %Z')" >> $GITHUB_OUTPUT + - name: Comment pull request success + if: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + uses: ./.github/actions/comment-pull-request + with: + token: ${{ secrets.GITHUB_TOKEN }} + pr_number: ${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number }} + comment: | + [![Static Badge](https://img.shields.io/badge/Pull_Request_Checks-Success-green)](https://github.com/newrelic/newrelic-browser-agent/actions/runs/${{ github.run_id }}) + + Last ran on `${{ steps.workflow-time.outputs.results }}` + Checking merge of (${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).head_sha }}) into [${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_ref }}](https://github.com/newrelic/newrelic-browser-agent/compare/${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).head_sha }}..${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_sha }}) (${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_sha }}) + comment_tag: + - name: Comment pull request failed + if: ${{ contains(needs.*.result, 'failure') }} + uses: ./.github/actions/comment-pull-request + with: + token: ${{ secrets.GITHUB_TOKEN }} + pr_number: ${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number }} + comment: | + [![Static Badge](https://img.shields.io/badge/Pull_Request_Checks-Failure-red)](https://github.com/newrelic/newrelic-browser-agent/actions/runs/${{ github.run_id }}) + + Last ran on `${{ steps.workflow-time.outputs.results }}` + Checking merge of (${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).head_sha }}) into [${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_ref }}](https://github.com/newrelic/newrelic-browser-agent/compare/${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).head_sha }}..${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_sha }}) (${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_sha }}) + comment_tag: + - name: Comment pull request cancelled + if: ${{ contains(needs.*.result, 'cancelled') }} + uses: ./.github/actions/comment-pull-request + with: + token: ${{ secrets.GITHUB_TOKEN }} + pr_number: ${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).pr_number }} + comment: | + [![Static Badge](https://img.shields.io/badge/Pull_Request_Checks-Cancelled-orange)](https://github.com/newrelic/newrelic-browser-agent/actions/runs/${{ github.run_id }}) + + Last ran on `${{ steps.workflow-time.outputs.results }}` + Checking merge of (${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).head_sha }}) into [${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_ref }}](https://github.com/newrelic/newrelic-browser-agent/compare/${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).head_sha }}..${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_sha }}) (${{ fromJSON(needs.find-pull-request.outputs.pull-request-target).base_sha }}) + comment_tag: diff --git a/.github/workflows/wdio-all-browsers.yml b/.github/workflows/wdio-all-browsers.yml index fabe525a0..9d101f971 100644 --- a/.github/workflows/wdio-all-browsers.yml +++ b/.github/workflows/wdio-all-browsers.yml @@ -56,3 +56,11 @@ jobs: browser-target: android@* build-number: $BUILD_NUMBER secrets: inherit + + ie: + uses: ./.github/workflows/wdio-single-browser.yml + with: + browser-target: ie@11 + build-number: $BUILD_NUMBER + additional-flags: -P + secrets: inherit diff --git a/.github/workflows/wdio-single-browser.yml b/.github/workflows/wdio-single-browser.yml index 76ed9a629..f2dad0336 100644 --- a/.github/workflows/wdio-single-browser.yml +++ b/.github/workflows/wdio-single-browser.yml @@ -16,7 +16,7 @@ on: required: false type: string coverage: - description: 'Flag indicating if code coverage should be collected and reported to codecov' + description: 'Enable code coverage' required: false type: boolean default: false @@ -35,7 +35,7 @@ on: required: false type: string coverage: - description: 'Flag indicating if code coverage should be collected and reported to codecov' + description: 'Enable code coverage' required: false type: boolean default: false @@ -56,6 +56,7 @@ jobs: shell: bash env: BUILD_NUMBER: ${{ inputs.build-number }} + COVERAGE: ${{ inputs.coverage }} NEWRELIC_ENVIRONMENT: ci JIL_SAUCE_LABS_USERNAME: ${{ secrets.JIL_SAUCE_LABS_USERNAME }} JIL_SAUCE_LABS_ACCESS_KEY: ${{ secrets.JIL_SAUCE_LABS_ACCESS_KEY }} @@ -84,6 +85,7 @@ jobs: ${{ inputs.additional-flags || '' }} - name: Find pull request id: pull-request-target + if: ${{ inputs.coverage }} uses: ./.github/actions/find-pull-request with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/babel.config.js b/babel.config.js index e07dbb378..c7d3eaf52 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,12 +1,20 @@ const process = require('process') -module.exports = function (api) { +module.exports = function (api, ...args) { api.cache(true) if (!process.env.BUILD_VERSION) { process.env.BUILD_VERSION = process.env.VERSION_OVERRIDE || require('./package.json').version } + if (!process.env.BUILD_ENV) { + process.env.BUILD_ENV = 'CDN' + } + const ignore = [ + '**/*.test.js', + '**/*.component-test.js', + '**/__mocks__/*.js' + ] const presets = [ '@babel/preset-env' ] @@ -34,6 +42,7 @@ module.exports = function (api) { ] }, webpack: { + ignore, plugins: [ [ './tools/scripts/babel-plugin-transform-import', @@ -44,6 +53,7 @@ module.exports = function (api) { ] }, 'webpack-ie11': { + ignore, assumptions: { iterableIsArray: false }, @@ -72,6 +82,7 @@ module.exports = function (api) { ] }, 'npm-cjs': { + ignore, presets: [ [ '@babel/preset-env', { @@ -89,6 +100,7 @@ module.exports = function (api) { ] }, 'npm-esm': { + ignore, presets: [ [ '@babel/preset-env', { diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..b7052f68c --- /dev/null +++ b/codecov.yml @@ -0,0 +1,3 @@ +flag_management: + default_rules: + carryforward: true diff --git a/jest.component-config.js b/jest.component-config.js index d60e055f5..5bd894828 100644 --- a/jest.component-config.js +++ b/jest.component-config.js @@ -3,8 +3,8 @@ module.exports = { coverageDirectory: 'coverage', collectCoverageFrom: [ 'src/**/*.js', - '!src/**/*.component-test.js', '!src/**/*.test.js', + '!src/**/*.component-test.js', '!src/index.js', '!src/cdn/**/*.js', '!src/features/*/index.js', diff --git a/package-lock.json b/package-lock.json index f513f8299..7abf11dfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9687,9 +9687,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001503", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001503.tgz", - "integrity": "sha512-Sf9NiF+wZxPfzv8Z3iS0rXM1Do+iOy2Lxvib38glFX+08TCYYYGR5fRJXk4d77C4AYwhUjgYgMsMudbh2TqCKw==", + "version": "1.0.30001505", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001505.tgz", + "integrity": "sha512-jaAOR5zVtxHfL0NjZyflVTtXm3D3J9P15zSJ7HmQF8dSKGA6tqzQq+0ZI3xkjyQj46I4/M0K2GbMpcAFOcbr3A==", "dev": true, "funding": [ { @@ -34042,9 +34042,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001503", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001503.tgz", - "integrity": "sha512-Sf9NiF+wZxPfzv8Z3iS0rXM1Do+iOy2Lxvib38glFX+08TCYYYGR5fRJXk4d77C4AYwhUjgYgMsMudbh2TqCKw==", + "version": "1.0.30001505", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001505.tgz", + "integrity": "sha512-jaAOR5zVtxHfL0NjZyflVTtXm3D3J9P15zSJ7HmQF8dSKGA6tqzQq+0ZI3xkjyQj46I4/M0K2GbMpcAFOcbr3A==", "dev": true }, "capital-case": { diff --git a/src/common/browser-version/firefox-version.js b/src/common/browser-version/firefox-version.js deleted file mode 100644 index 0b1e46cb4..000000000 --- a/src/common/browser-version/firefox-version.js +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable no-useless-escape */ - -export var ffVersion = 0 -var match = navigator.userAgent.match(/Firefox[\/\s](\d+\.\d+)/) -if (match) ffVersion = +match[1] diff --git a/src/common/browser-version/ios-version.js b/src/common/browser-version/ios-version.js deleted file mode 100644 index a28980ddf..000000000 --- a/src/common/browser-version/ios-version.js +++ /dev/null @@ -1,11 +0,0 @@ -export const isiOS = /(iPad|iPhone|iPod)/g.test(navigator.userAgent) - -/* Feature detection to get our version(s). */ - -// Shared Web Workers introduced in iOS 16.0+ and n/a in 15.6- -export const iOS_below16 = isiOS && Boolean(typeof SharedWorker === 'undefined') -/* - ^ It was discovered in Safari 14 (https://bugs.webkit.org/show_bug.cgi?id=225305) that the buffered flag in PerformanceObserver - did not work. This affects our onFCP metric in particular since web-vitals uses that flag to retrieve paint timing entries. - This was fixed in v16+. -*/ diff --git a/src/common/config/__mocks__/config.js b/src/common/config/__mocks__/config.js new file mode 100644 index 000000000..1cf4b6f0d --- /dev/null +++ b/src/common/config/__mocks__/config.js @@ -0,0 +1,11 @@ +export const isConfigured = jest.fn() +export const getInfo = jest.fn() +export const setInfo = jest.fn() +export const getConfiguration = jest.fn() +export const getConfigurationValue = jest.fn() +export const setConfiguration = jest.fn() +export const getLoaderConfig = jest.fn() +export const setLoaderConfig = jest.fn() +export const originals = {} +export const getRuntime = jest.fn() +export const setRuntime = jest.fn() diff --git a/src/common/config/state/init.js b/src/common/config/state/init.js index fad2f605c..7a6c68c10 100644 --- a/src/common/config/state/init.js +++ b/src/common/config/state/init.js @@ -31,6 +31,7 @@ const model = () => { page_view_event: { enabled: true }, page_view_timing: { enabled: true, harvestTimeSeconds: 30, long_task: false }, session_trace: { enabled: true, harvestTimeSeconds: 10 }, + harvest: { tooManyRequestsDelay: 60 }, session_replay: { // feature settings enabled: false, diff --git a/src/common/config/state/runtime.js b/src/common/config/state/runtime.js index fce9f0fc3..2b5ff3ef1 100644 --- a/src/common/config/state/runtime.js +++ b/src/common/config/state/runtime.js @@ -1,6 +1,6 @@ import { getModeledObject } from './configurable' import { gosNREUMInitializedAgents } from '../../window/nreum' -import { globalScope } from '../../util/global-scope' +import { globalScope } from '../../constants/runtime' import { BUILD_ENV, DIST_METHOD, VERSION } from '../../constants/env' const model = { diff --git a/src/common/constants/__mocks__/env.js b/src/common/constants/__mocks__/env.js new file mode 100644 index 000000000..9e12f89b5 --- /dev/null +++ b/src/common/constants/__mocks__/env.js @@ -0,0 +1,3 @@ +export const VERSION = '0.0.0' +export const BUILD_ENV = 'TEST' +export const DIST_METHOD = 'TEST' diff --git a/src/common/constants/__mocks__/runtime.js b/src/common/constants/__mocks__/runtime.js new file mode 100644 index 000000000..00bca45c8 --- /dev/null +++ b/src/common/constants/__mocks__/runtime.js @@ -0,0 +1,8 @@ +export const isBrowserScope = true +export const isWorkerScope = false +export const globalScope = window +export const initialLocation = '' + globalScope?.location +export const isiOS = false +export const iOS_below16 = false +export const ffVersion = 0 +export const supportsSendBeacon = true diff --git a/src/common/constants/env.cdn.test.js b/src/common/constants/env.cdn.test.js new file mode 100644 index 000000000..6b8243b40 --- /dev/null +++ b/src/common/constants/env.cdn.test.js @@ -0,0 +1,7 @@ +import * as env from './env.cdn' + +test('should default environment variables to CDN values', async () => { + expect(env.VERSION).toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}/) + expect(env.BUILD_ENV).toEqual('CDN') + expect(env.DIST_METHOD).toEqual('CDN') +}) diff --git a/src/common/constants/env.npm.test.js b/src/common/constants/env.npm.test.js new file mode 100644 index 000000000..aeb3732d8 --- /dev/null +++ b/src/common/constants/env.npm.test.js @@ -0,0 +1,7 @@ +import * as env from './env.npm' + +test('should default environment variables to NPM values', () => { + expect(env.VERSION).toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}/) + expect(env.BUILD_ENV).toEqual('NPM') + expect(env.DIST_METHOD).toEqual('NPM') +}) diff --git a/src/common/constants/env.test.js b/src/common/constants/env.test.js new file mode 100644 index 000000000..7603a859f --- /dev/null +++ b/src/common/constants/env.test.js @@ -0,0 +1,7 @@ +import * as env from './env' + +test('should default environment variables to NPM values', () => { + expect(env.VERSION).toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}/) + expect(env.BUILD_ENV).toEqual('NPM') + expect(env.DIST_METHOD).toEqual('NPM') +}) diff --git a/src/common/constants/runtime.js b/src/common/constants/runtime.js new file mode 100644 index 000000000..abb346823 --- /dev/null +++ b/src/common/constants/runtime.js @@ -0,0 +1,69 @@ +/** + * @file Contains constants about the environment the agent is running + * within. These values are derived at the time the agent is first loaded. + * @copyright 2023 New Relic Corporation. All rights reserved. + * @license Apache-2.0 + */ + +/** + * Indicates if the agent is running within a normal browser window context. + */ +export const isBrowserScope = + typeof window !== 'undefined' && + !!window.document + +/** + * Indicates if the agent is running within a worker context. + */ +export const isWorkerScope = + typeof WorkerGlobalScope !== 'undefined' && + ( + ( + typeof self !== 'undefined' && + self instanceof WorkerGlobalScope && + self.navigator instanceof WorkerNavigator + ) || + ( + ( + typeof globalThis !== 'undefined' && + globalThis instanceof WorkerGlobalScope && + globalThis.navigator instanceof WorkerNavigator + ) + ) + ) + +export const globalScope = isBrowserScope + ? window + : typeof WorkerGlobalScope !== 'undefined' && (( + typeof self !== 'undefined' && + self instanceof WorkerGlobalScope && + self + ) || ( + typeof globalThis !== 'undefined' && + globalThis instanceof WorkerGlobalScope && + globalThis + )) + +export const initialLocation = '' + globalScope?.location + +export const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent) + +/** + * Shared Web Workers introduced in iOS 16.0+ and n/a in 15.6- + * + * It was discovered in Safari 14 (https://bugs.webkit.org/show_bug.cgi?id=225305) that the buffered flag in PerformanceObserver + * did not work. This affects our onFCP metric in particular since web-vitals uses that flag to retrieve paint timing entries. + * This was fixed in v16+. + */ +export const iOS_below16 = (isiOS && typeof SharedWorker === 'undefined') + +export const ffVersion = (() => { + const match = navigator.userAgent.match(/Firefox[/\s](\d+\.\d+)/) + if (Array.isArray(match) && match.length >= 2) { + return +match[1] + } + + return 0 +})() + +export const supportsSendBeacon = !!navigator.sendBeacon diff --git a/src/common/constants/runtime.test.js b/src/common/constants/runtime.test.js new file mode 100644 index 000000000..52b8a4ad7 --- /dev/null +++ b/src/common/constants/runtime.test.js @@ -0,0 +1,168 @@ +/** + * The runtime module exports variables whose values are set at the time of import and + * can depend on the environment the agent is running within. To make testing this module + * easier, use async import to import the module and only use the node jest environment so + * we can more easily define environment variables. + * @jest-environment node + */ + +import { faker } from '@faker-js/faker' + +beforeEach(() => { + // We assume every runtime has a global navigator variable + global.navigator = { + userAgent: faker.lorem.sentence() + } +}) + +afterEach(() => { + delete global.navigator + jest.resetModules() +}) + +test('should indicate agent is running in a browser scope', async () => { + const mockedWindow = global.window = { + [faker.datatype.uuid()]: faker.lorem.sentence, + document: { + [faker.datatype.uuid()]: faker.lorem.sentence + } + } + + const runtime = await import('./runtime') + + delete global.window + + expect(runtime.isBrowserScope).toEqual(true) + expect(runtime.isWorkerScope).toEqual(false) + expect(runtime.globalScope).toEqual(mockedWindow) +}) + +test('should indicate agent is running in a worker scope using global self variable', async () => { + global.WorkerGlobalScope = class WorkerGlobalScope {} + global.WorkerNavigator = class WorkerNavigator {} + const mockedGlobalSelf = global.self = new global.WorkerGlobalScope() + mockedGlobalSelf.navigator = new global.WorkerNavigator() + + const runtime = await import('./runtime') + + delete global.WorkerGlobalScope + delete global.WorkerNavigator + delete global.self + + expect(runtime.isBrowserScope).toEqual(false) + expect(runtime.isWorkerScope).toEqual(true) + expect(runtime.globalScope).toEqual(mockedGlobalSelf) +}) + +test('should indicate agent is running in a worker scope using global self variable', async () => { + global.WorkerGlobalScope = class WorkerGlobalScope {} + global.WorkerNavigator = class WorkerNavigator {} + const cachedGlobalThis = global.globalThis + const mockedGlobalThis = global.globalThis = new WorkerGlobalScope() + Object.defineProperties(global.globalThis, Object.getOwnPropertyDescriptors(cachedGlobalThis)) + global.globalThis.navigator = new WorkerNavigator() + + const runtime = await import('./runtime') + + delete global.WorkerGlobalScope + delete global.WorkerNavigator + global.globalThis = cachedGlobalThis + + expect(runtime.isBrowserScope).toEqual(false) + expect(runtime.isWorkerScope).toEqual(true) + expect(runtime.globalScope).toEqual(mockedGlobalThis) +}) + +test('should store the initial page location', async () => { + const initialLocation = faker.internet.url() + const mockedWindow = global.window = { + [faker.datatype.uuid()]: faker.lorem.sentence, + document: { + [faker.datatype.uuid()]: faker.lorem.sentence + }, + location: { + href: initialLocation, + toString () { + return this.href + } + } + } + + const runtime = await import('./runtime') + mockedWindow.location.href = faker.internet.url() + + delete global.window + + expect(runtime.initialLocation).toEqual(initialLocation) + expect(runtime.initialLocation).not.toEqual(mockedWindow.location.href) +}) + +test.each([ + { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: true }, + { userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: true }, + { userAgent: 'Mozilla/5.0 (iPod touch; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: true }, + { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15', expected: false }, + { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0', expected: false }, + { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13.4; rv:109.0) Gecko/20100101 Firefox/114.0', expected: false } +])('should set isiOS to $expected for $userAgent', async ({ userAgent, expected }) => { + global.navigator.userAgent = userAgent + + const runtime = await import('./runtime') + + expect(runtime.isiOS).toEqual(expected) +}) + +test.each([ + { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: false }, + { userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: false }, + { userAgent: 'Mozilla/5.0 (iPod touch; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: false }, + { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1', expected: true }, + { userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1', expected: true }, + { userAgent: 'Mozilla/5.0 (iPod touch; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1', expected: true }, + { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15', expected: false }, + { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0', expected: false }, + { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13.4; rv:109.0) Gecko/20100101 Firefox/114.0', expected: false } +])('should set iOS_below16 to $expected for $userAgent', async ({ userAgent, expected }) => { + if (!expected) { + global.SharedWorker = class SharedWorker {} + } + global.navigator.userAgent = userAgent + + const runtime = await import('./runtime') + + delete global.SharedWorker + + expect(runtime.iOS_below16).toEqual(expected) +}) + +test.each([ + { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: 0 }, + { userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: 0 }, + { userAgent: 'Mozilla/5.0 (iPod touch; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', expected: 0 }, + { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15', expected: 0 }, + { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0', expected: 114 }, + { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13.4; rv:109.0) Gecko/20100101 Firefox/114.0', expected: 114 } +])('should set ffVersion to $expected for $userAgent', async ({ userAgent, expected }) => { + global.navigator.userAgent = userAgent + + const runtime = await import('./runtime') + + expect(runtime.ffVersion).toEqual(expected) +}) + +test('should set supportsSendBeacon to false', async () => { + // Ensure we don't have a sendBeacon function + delete global.navigator.sendBeacon + + const runtime = await import('./runtime') + + expect(runtime.supportsSendBeacon).toEqual(false) +}) + +test('should set supportsSendBeacon to true', async () => { + global.navigator.sendBeacon = jest.fn() + + const runtime = await import('./runtime') + + expect(runtime.supportsSendBeacon).toEqual(true) +}) diff --git a/src/common/context/__mocks__/shared-context.js b/src/common/context/__mocks__/shared-context.js new file mode 100644 index 000000000..11259b931 --- /dev/null +++ b/src/common/context/__mocks__/shared-context.js @@ -0,0 +1,8 @@ +export const SharedContext = jest.fn(function () { + this.sharedContext = { + agentIdentifier: 'abcd', + ee: { + on: jest.fn() + } + } +}) diff --git a/src/common/event-listener/__mocks__/event-listener-opts.js b/src/common/event-listener/__mocks__/event-listener-opts.js new file mode 100644 index 000000000..298ea37d0 --- /dev/null +++ b/src/common/event-listener/__mocks__/event-listener-opts.js @@ -0,0 +1,7 @@ +export const eventListenerOpts = jest.fn((useCapture, abortSignal) => ({ + capture: !!useCapture, + passive: true, + signal: abortSignal +})) +export const windowAddEventListener = jest.fn() +export const documentAddEventListener = jest.fn() diff --git a/src/common/event-listener/event-listener-opts.js b/src/common/event-listener/event-listener-opts.js index 4c5c2968a..b36b9b309 100644 --- a/src/common/event-listener/event-listener-opts.js +++ b/src/common/event-listener/event-listener-opts.js @@ -1,4 +1,4 @@ -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' /* * See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#safely_detecting_option_support diff --git a/src/common/harvest/__mocks__/harvest.js b/src/common/harvest/__mocks__/harvest.js new file mode 100644 index 000000000..e0239735a --- /dev/null +++ b/src/common/harvest/__mocks__/harvest.js @@ -0,0 +1,13 @@ +export const Harvest = jest.fn(function () { + this.sharedContext = { + agentIdentifier: 'abcd' + } + this.sendX = jest.fn() + this.send = jest.fn() + this.obfuscateAndSend = jest.fn() + this._send = jest.fn() + this.baseQueryString = jest.fn() + this.createPayload = jest.fn() + this.cleanPayload = jest.fn() + this.on = jest.fn() +}) diff --git a/src/common/harvest/harvest-scheduler.component-test.js b/src/common/harvest/harvest-scheduler.component-test.js deleted file mode 100644 index 7d9124926..000000000 --- a/src/common/harvest/harvest-scheduler.component-test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { setConfiguration } from '../config/state/init' -import { HarvestScheduler } from './harvest-scheduler' - -describe('runHarvest', () => { - test('should re-schedule harvest even if there is no accumulated data', () => { - setConfiguration('asdf', {}) - const scheduler = new HarvestScheduler('events', { getPayload: jest.fn() }, { agentIdentifier: 'asdf', ee: { on: jest.fn() } }) - scheduler.started = true - jest.spyOn(scheduler, 'scheduleHarvest') - scheduler.runHarvest() - expect(scheduler.opts.getPayload()).toBeFalsy() - expect(scheduler.scheduleHarvest).toHaveBeenCalledTimes(1) - }) - - test('should also re-schedule harvest if there is accumulated data', () => { - setConfiguration('asdf', {}) - const scheduler = new HarvestScheduler('events', { getPayload: jest.fn().mockImplementation(() => 'payload') }, { agentIdentifier: 'asdf', ee: { on: jest.fn() } }) - scheduler.started = true - scheduler.harvest._send = () => {} - jest.spyOn(scheduler, 'scheduleHarvest') - scheduler.runHarvest() - expect(scheduler.opts.getPayload()).toBeTruthy() - expect(scheduler.scheduleHarvest).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/common/harvest/harvest-scheduler.js b/src/common/harvest/harvest-scheduler.js index f6bcf4a6e..68f940d0a 100644 --- a/src/common/harvest/harvest-scheduler.js +++ b/src/common/harvest/harvest-scheduler.js @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { submitData } from '../util/submit-data' +import * as submitData from '../util/submit-data' import { SharedContext } from '../context/shared-context' -import { Harvest, getSubmitMethod } from './harvest' +import { Harvest } from './harvest' import { subscribeToEOL } from '../unload/eol' import { getConfigurationValue } from '../config/config' import { SESSION_EVENTS } from '../session/session-entity' @@ -71,30 +71,39 @@ export class HarvestScheduler extends SharedContext { scheduleHarvest (delay, opts) { if (this.timeoutHandle) return - var timer = this if (delay == null) { delay = this.interval } this.timeoutHandle = setTimeout(() => { - timer.timeoutHandle = null - timer.runHarvest(opts) + this.timeoutHandle = null + this.runHarvest(opts) }, delay * 1000) } runHarvest (opts) { if (this.aborted) return - var scheduler = this + + /** + * This is executed immediately after harvest sends the data via XHR, or if there's nothing to send. Note that this excludes on unloading / sendBeacon. + * @param {Object} result + */ + const cbRanAfterSend = (result) => { + if (opts?.forceNoRetry) result.retry = false // discard unsent data rather than re-queuing for next harvest attempt + this.onHarvestFinished(opts, result) + } let harvests = [] let submitMethod + let payload - if (this.opts.getPayload) { // Ajax & PVT & SR - submitMethod = getSubmitMethod(this.endpoint, opts) + if (this.opts.getPayload) { + // Ajax & PVT & SR features provide a callback function to get data for harvesting + submitMethod = submitData.getSubmitMethod({ isFinalHarvest: opts?.unload }) if (!submitMethod) return false - const retry = submitMethod.method === submitData.xhr - var payload = this.opts.getPayload({ retry: retry }) + const retry = !opts?.unload && submitMethod === submitData.xhr + payload = this.opts.getPayload({ retry: retry }) if (!payload) { if (this.started) { @@ -134,16 +143,8 @@ export class HarvestScheduler extends SharedContext { if (this.started) { this.scheduleHarvest() } - return - /** - * This is executed immediately after harvest sends the data via XHR, or if there's nothing to send. Note that this excludes on unloading / sendBeacon. - * @param {Object} result - */ - function cbRanAfterSend (result) { - if (opts?.forceNoRetry) result.retry = false // discard unsent data rather than re-queuing for next harvest attempt - scheduler.onHarvestFinished(opts, result) - } + return } onHarvestFinished (opts, result) { @@ -152,7 +153,7 @@ export class HarvestScheduler extends SharedContext { } if (result.sent && result.retry) { - var delay = result.delay || this.opts.retryDelay + const delay = result.delay || this.opts.retryDelay // reschedule next harvest if should be delayed longer if (this.started && delay) { clearTimeout(this.timeoutHandle) diff --git a/src/common/harvest/harvest-scheduler.test.js b/src/common/harvest/harvest-scheduler.test.js new file mode 100644 index 000000000..5aeb1df4c --- /dev/null +++ b/src/common/harvest/harvest-scheduler.test.js @@ -0,0 +1,496 @@ +import { faker } from '@faker-js/faker' + +import * as submitData from '../util/submit-data' +import { subscribeToEOL } from '../unload/eol' +import { getConfigurationValue } from '../config/config' +import { Harvest } from './harvest' + +import { HarvestScheduler } from './harvest-scheduler' + +jest.enableAutomock() +jest.unmock('./harvest-scheduler') +jest.useFakeTimers() + +let harvestSchedulerInstance +let harvestInstance + +beforeEach(() => { + harvestSchedulerInstance = new HarvestScheduler() + harvestInstance = jest.mocked(Harvest).mock.instances[0] +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('unload', () => { + let eolSubscribeFn + + beforeEach(() => { + eolSubscribeFn = jest.mocked(subscribeToEOL).mock.calls[0][0] + jest.spyOn(harvestSchedulerInstance, 'runHarvest').mockImplementation(jest.fn()) + }) + + test('should subscribe to eol with allow_bfcache setting', () => { + const mockedBFCacheSetting = faker.datatype.uuid() + jest.mocked(getConfigurationValue).mockReturnValue(mockedBFCacheSetting) + + new HarvestScheduler() + + expect(subscribeToEOL).toHaveBeenCalledWith(expect.any(Function), mockedBFCacheSetting) + }) + + test('should run onUnload callback', () => { + harvestSchedulerInstance.opts.onUnload = jest.fn() + + eolSubscribeFn() + + expect(harvestSchedulerInstance.opts.onUnload).toHaveBeenCalledTimes(1) + }) + + test('should run harvest when not aborted', () => { + harvestSchedulerInstance.aborted = false + + eolSubscribeFn() + + expect(harvestSchedulerInstance.runHarvest).toHaveBeenCalledWith({ unload: true }) + }) + + test('should not run harvest when aborted', () => { + harvestSchedulerInstance.aborted = true + jest.spyOn(harvestSchedulerInstance, 'runHarvest').mockImplementation(jest.fn()) + + eolSubscribeFn() + + expect(harvestSchedulerInstance.runHarvest).not.toHaveBeenCalled() + }) +}) + +describe('startTimer', () => { + beforeEach(() => { + jest.spyOn(harvestSchedulerInstance, 'scheduleHarvest').mockImplementation(jest.fn()) + }) + + test('should use provided delay to schedule harvest', () => { + const interval = faker.datatype.number({ min: 100, max: 1000 }) + const initialDelay = faker.datatype.number({ min: 100, max: 1000 }) + + harvestSchedulerInstance.startTimer(interval, initialDelay) + + expect(harvestSchedulerInstance.interval).toEqual(interval) + expect(harvestSchedulerInstance.started).toEqual(true) + expect(harvestSchedulerInstance.scheduleHarvest).toHaveBeenCalledWith(initialDelay) + }) + + test('should use provided interval to schedule harvest when initialDelay is null', () => { + const interval = faker.datatype.number({ min: 100, max: 1000 }) + + harvestSchedulerInstance.startTimer(interval, null) + + expect(harvestSchedulerInstance.interval).toEqual(interval) + expect(harvestSchedulerInstance.started).toEqual(true) + expect(harvestSchedulerInstance.scheduleHarvest).toHaveBeenCalledWith(interval) + }) +}) + +describe('stopTimer', () => { + beforeEach(() => { + jest.spyOn(harvestSchedulerInstance, 'scheduleHarvest').mockImplementation(jest.fn()) + }) + + test.each([ + false, undefined + ])('should not abort the scheduler when permanently param is %s', (permanently) => { + harvestSchedulerInstance.stopTimer(permanently) + + expect(harvestSchedulerInstance.aborted).toEqual(false) + expect(harvestSchedulerInstance.started).toEqual(false) + }) + + test('should abort the scheduler when permanently param is true', () => { + harvestSchedulerInstance.stopTimer(true) + + expect(harvestSchedulerInstance.aborted).toEqual(true) + expect(harvestSchedulerInstance.started).toEqual(false) + }) + + test('should clear the timeoutHandle', () => { + jest.spyOn(global, 'clearTimeout') + const timeoutHandle = setTimeout(jest.fn(), 1000000) + harvestSchedulerInstance.timeoutHandle = timeoutHandle + + harvestSchedulerInstance.stopTimer() + + expect(global.clearTimeout).toHaveBeenCalledWith(timeoutHandle) + }) +}) + +describe('scheduleHarvest', () => { + beforeEach(() => { + jest.spyOn(harvestSchedulerInstance, 'runHarvest').mockImplementation(jest.fn()) + }) + + test('should runHarvest after the provided delay in seconds', () => { + const delay = faker.datatype.number({ min: 100, max: 1000 }) + const opts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.scheduleHarvest(delay, opts) + + expect(harvestSchedulerInstance.timeoutHandle).toEqual(expect.any(Number)) + expect(harvestSchedulerInstance.runHarvest).not.toHaveBeenCalled() + + jest.advanceTimersByTime(delay * 1000) + + expect(harvestSchedulerInstance.timeoutHandle).toEqual(null) + expect(harvestSchedulerInstance.runHarvest).toHaveBeenCalledWith(opts) + }) + + test('should default delay to internal interval', () => { + const interval = faker.datatype.number({ min: 100, max: 1000 }) + harvestSchedulerInstance.interval = interval + const opts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.scheduleHarvest(null, opts) + + expect(harvestSchedulerInstance.timeoutHandle).toEqual(expect.any(Number)) + expect(harvestSchedulerInstance.runHarvest).not.toHaveBeenCalled() + + jest.advanceTimersByTime(interval * 1000) + + expect(harvestSchedulerInstance.timeoutHandle).toEqual(null) + expect(harvestSchedulerInstance.runHarvest).toHaveBeenCalledWith(opts) + }) + + test('should not call setTimeout when timeoutHandle already exists', () => { + jest.spyOn(global, 'setTimeout') + const timeoutHandle = setTimeout(jest.fn(), 1000000) + harvestSchedulerInstance.timeoutHandle = timeoutHandle + + harvestSchedulerInstance.scheduleHarvest(null) + + expect(global.setTimeout).toHaveBeenCalledTimes(1) + }) +}) + +describe('runHarvest', () => { + beforeEach(() => { + jest.spyOn(harvestSchedulerInstance, 'scheduleHarvest').mockImplementation(jest.fn()) + jest.spyOn(harvestSchedulerInstance, 'onHarvestFinished').mockImplementation(jest.fn()) + }) + + test('should not run harvest when scheduler is aborted', () => { + harvestSchedulerInstance.aborted = true + harvestSchedulerInstance.runHarvest({}) + + expect(harvestInstance.sendX).not.toHaveBeenCalled() + expect(harvestInstance.send).not.toHaveBeenCalled() + }) + + test.each([ + null, undefined + ])('should use sendX for harvesting when getPayload is %s', (getPayload) => { + harvestSchedulerInstance.endpoint = faker.datatype.uuid() + harvestSchedulerInstance.opts.getPayload = getPayload + const harvestRunOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.runHarvest(harvestRunOpts) + + expect(harvestInstance.sendX).toHaveBeenCalledWith({ + cbFinished: expect.any(Function), + customUrl: undefined, + endpoint: harvestSchedulerInstance.endpoint, + opts: harvestRunOpts, + payload: undefined, + raw: undefined, + submitMethod: undefined + }) + }) + + test('should use send for harvesting when getPayload is defined', () => { + const payload = { + body: { + [faker.datatype.uuid()]: faker.lorem.sentence() + }, + qs: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + harvestSchedulerInstance.endpoint = faker.datatype.uuid() + harvestSchedulerInstance.opts.getPayload = jest.fn().mockReturnValue(payload) + const harvestRunOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.runHarvest(harvestRunOpts) + + expect(harvestInstance.send).toHaveBeenCalledWith({ + cbFinished: expect.any(Function), + customUrl: undefined, + endpoint: harvestSchedulerInstance.endpoint, + opts: harvestRunOpts, + payload, + raw: undefined, + submitMethod: expect.any(Function) + }) + }) + + test('should use _send for harvesting when opts.raw is true', () => { + const payload = { + body: { + [faker.datatype.uuid()]: faker.lorem.sentence() + }, + qs: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + harvestSchedulerInstance.endpoint = faker.datatype.uuid() + harvestSchedulerInstance.opts.raw = true + harvestSchedulerInstance.opts.getPayload = jest.fn().mockReturnValue(payload) + const harvestRunOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.runHarvest(harvestRunOpts) + + expect(harvestInstance._send).toHaveBeenCalledWith({ + cbFinished: expect.any(Function), + customUrl: undefined, + endpoint: harvestSchedulerInstance.endpoint, + opts: harvestRunOpts, + payload, + raw: true, + submitMethod: expect.any(Function) + }) + }) + + test('should rescheduled harvesting when getPayload returns no data', () => { + harvestSchedulerInstance.started = true + harvestSchedulerInstance.opts.getPayload = jest.fn().mockReturnValue() + + harvestSchedulerInstance.runHarvest({}) + + expect(harvestInstance.sendX).not.toHaveBeenCalled() + expect(harvestInstance.send).not.toHaveBeenCalled() + expect(harvestSchedulerInstance.scheduleHarvest).toHaveBeenCalled() + }) + + test('should schedule the next harvest after running harvest', () => { + const payload = { + body: { + [faker.datatype.uuid()]: faker.lorem.sentence() + }, + qs: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + harvestSchedulerInstance.started = true + harvestSchedulerInstance.endpoint = faker.datatype.uuid() + harvestSchedulerInstance.opts.getPayload = jest.fn().mockReturnValue(payload) + const harvestRunOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.runHarvest(harvestRunOpts) + + expect(harvestInstance.send).toHaveBeenCalled() + expect(harvestSchedulerInstance.scheduleHarvest).toHaveBeenCalled() + }) + + test.each([ + null, undefined, false + ])('should set retry to true unload opt is %s', (unload) => { + jest.mocked(submitData.getSubmitMethod).mockReturnValue(submitData.xhr) + harvestSchedulerInstance.opts.getPayload = jest.fn().mockReturnValue() + + harvestSchedulerInstance.runHarvest({ unload }) + + expect(harvestSchedulerInstance.opts.getPayload).toHaveBeenCalledWith({ retry: true }) + }) + + test('should set retry to false when submitMethod is not xhr', () => { + jest.mocked(submitData.getSubmitMethod).mockReturnValue(jest.fn()) + harvestSchedulerInstance.opts.getPayload = jest.fn().mockReturnValue() + + harvestSchedulerInstance.runHarvest({ unload: false }) + + expect(harvestSchedulerInstance.opts.getPayload).toHaveBeenCalledWith({ retry: false }) + }) + + test('should run onHarvestFinished after harvest finishes', () => { + const harvestRunOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.runHarvest(harvestRunOpts) + const cbFinishedFn = jest.mocked(harvestInstance.sendX).mock.calls[0][0].cbFinished + cbFinishedFn(result) + + expect(harvestSchedulerInstance.onHarvestFinished).toHaveBeenCalledWith(harvestRunOpts, result) + }) + + test('should disable retry in harvest callback when forceNoRetry is true', () => { + const harvestRunOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence(), + forceNoRetry: true + } + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.runHarvest(harvestRunOpts) + const cbFinishedFn = jest.mocked(harvestInstance.sendX).mock.calls[0][0].cbFinished + cbFinishedFn(result) + + expect(harvestSchedulerInstance.onHarvestFinished).toHaveBeenCalledWith(harvestRunOpts, { + ...result, + retry: false + }) + }) +}) + +describe('onHarvestFinished', () => { + beforeEach(() => { + jest.spyOn(global, 'clearTimeout') + jest.spyOn(harvestSchedulerInstance, 'scheduleHarvest').mockImplementation(jest.fn()) + }) + + test('should call onFinished callback', () => { + harvestSchedulerInstance.opts.onFinished = jest.fn() + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestSchedulerInstance.onHarvestFinished({}, result) + + expect(harvestSchedulerInstance.opts.onFinished).toHaveBeenCalledWith(result) + }) + + test.each([ + null, undefined, false + ])('should not reschedule harvest when result.sent is %s', (sent) => { + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence(), + sent, + retry: true + } + + harvestSchedulerInstance.onHarvestFinished({}, result) + + expect(harvestSchedulerInstance.scheduleHarvest).not.toHaveBeenCalled() + }) + + test.each([ + null, undefined, false + ])('should not reschedule harvest when result.retry is %s', (retry) => { + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence(), + sent: true, + retry + } + + harvestSchedulerInstance.onHarvestFinished({}, result) + + expect(harvestSchedulerInstance.scheduleHarvest).not.toHaveBeenCalled() + }) + + test('should reschedule harvest using result.delay', () => { + const harvestOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence(), + sent: true, + retry: true, + delay: faker.datatype.number({ min: 100, max: 1000 }) + } + + harvestSchedulerInstance.onHarvestFinished(harvestOpts, result) + + expect(harvestSchedulerInstance.scheduleHarvest).toHaveBeenCalledWith(result.delay, harvestOpts) + }) + + test('should reschedule harvest using instance retryDelay opt', () => { + harvestSchedulerInstance.opts.retryDelay = faker.datatype.number({ min: 100, max: 1000 }) + const harvestOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence(), + sent: true, + retry: true + } + + harvestSchedulerInstance.onHarvestFinished(harvestOpts, result) + + expect(harvestSchedulerInstance.scheduleHarvest).toHaveBeenCalledWith(harvestSchedulerInstance.opts.retryDelay, harvestOpts) + }) + + test.each([ + null, undefined, false, 0 + ])('should not reschedule harvest when delay is %s and scheduler not started', (delay) => { + harvestSchedulerInstance.opts.retryDelay = delay + const harvestOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence(), + sent: true, + retry: true, + delay + } + + harvestSchedulerInstance.onHarvestFinished(harvestOpts, result) + + expect(harvestSchedulerInstance.scheduleHarvest).not.toHaveBeenCalled() + }) + + test.each([ + null, undefined, false, 0 + ])('should not reschedule harvest when delay is %s and scheduler started', (delay) => { + harvestSchedulerInstance.started = true + harvestSchedulerInstance.opts.retryDelay = delay + const harvestOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence(), + sent: true, + retry: true, + delay + } + + harvestSchedulerInstance.onHarvestFinished(harvestOpts, result) + + expect(harvestSchedulerInstance.scheduleHarvest).not.toHaveBeenCalled() + }) + + test('should clear the current timeout handle and reschedule the harvest', () => { + const timeoutHandle = setTimeout(jest.fn(), 100000) + harvestSchedulerInstance.opts.retryDelay = faker.datatype.number({ min: 100, max: 1000 }) + harvestSchedulerInstance.timeoutHandle = timeoutHandle + harvestSchedulerInstance.started = true + const harvestOpts = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + const result = { + [faker.datatype.uuid()]: faker.lorem.sentence(), + sent: true, + retry: true + } + + harvestSchedulerInstance.onHarvestFinished(harvestOpts, result) + + expect(global.clearTimeout).toHaveBeenCalledWith(timeoutHandle) + expect(harvestSchedulerInstance.timeoutHandle).toEqual(null) + expect(harvestSchedulerInstance.scheduleHarvest).toHaveBeenCalledWith(harvestSchedulerInstance.opts.retryDelay, harvestOpts) + }) +}) diff --git a/src/common/harvest/harvest.component-test.js b/src/common/harvest/harvest.component-test.js deleted file mode 100644 index 67311625e..000000000 --- a/src/common/harvest/harvest.component-test.js +++ /dev/null @@ -1,169 +0,0 @@ -import { submitData } from '../util/submit-data' -import { Harvest } from './harvest' - -jest.mock('../context/shared-context', () => ({ - __esModule: true, - SharedContext: function () { - this.sharedContext = { - agentIdentifier: 'abcd' - } - } -})) -jest.mock('../config/config', () => ({ - __esModule: true, - getConfigurationValue: jest.fn(), - getInfo: jest.fn().mockReturnValue({ - errorBeacon: 'example.com', - licenseKey: 'abcd' - }), - getRuntime: jest.fn().mockReturnValue({ - bytesSent: {}, - queryBytesSent: {} - }) -})) -jest.mock('../util/submit-data', () => ({ - __esModule: true, - submitData: { - xhr: jest.fn(() => ({ - addEventListener: jest.fn() - })), - beacon: jest.fn(), - img: jest.fn() - } -})) - -describe('sendX', () => { - test.each([null, undefined, false])('should not send request when body is empty and sendEmptyBody is %s', (sendEmptyBody) => { - const sendCallback = jest.fn() - const harvester = new Harvest() - harvester.on('jserrors', () => ({ - body: {}, - qs: {} - })) - - harvester.sendX({ endpoint: 'jserrors', cbFinished: sendCallback }) - - expect(sendCallback).toHaveBeenCalledWith({ sent: false }) - expect(submitData.xhr).not.toHaveBeenCalled() - expect(submitData.img).not.toHaveBeenCalled() - expect(submitData.beacon).not.toHaveBeenCalled() - }) - - test('should send request when body is empty and sendEmptyBody is true', () => { - const harvester = new Harvest() - harvester.on('jserrors', () => ({ - body: {}, - qs: {} - })) - - harvester.sendX({ endpoint: 'jserrors', opts: { sendEmptyBody: true }, cbFinished: jest.fn() }) - - expect(submitData.xhr).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('https://example.com/jserrors/1/abcd?'), - body: undefined - })) - expect(submitData.img).not.toHaveBeenCalled() - expect(submitData.beacon).not.toHaveBeenCalled() - }) - - test.each([null, undefined, []])('should remove %s values from the body and query string when sending', (emptyValue) => { - const harvester = new Harvest() - harvester.on('jserrors', () => ({ - body: { bar: 'foo', empty: emptyValue }, - qs: { foo: 'bar', empty: emptyValue } - })) - - harvester.sendX({ endpoint: 'jserrors', cbFinished: jest.fn() }) - - expect(submitData.xhr).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('&foo=bar'), - body: JSON.stringify({ bar: 'foo' }) - })) - expect(submitData.xhr).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.not.stringContaining('&empty'), - body: expect.not.stringContaining('empty') - })) - expect(submitData.img).not.toHaveBeenCalled() - expect(submitData.beacon).not.toHaveBeenCalled() - }) - - test.each([1, false, true])('should not remove value %s (when it doesn\'t have a length) from the body and query string when sending', (nonStringValue) => { - const harvester = new Harvest() - harvester.on('jserrors', () => ({ - body: { bar: 'foo', nonString: nonStringValue }, - qs: { foo: 'bar', nonString: nonStringValue } - })) - - harvester.sendX({ endpoint: 'jserrors', cbFinished: jest.fn() }) - - expect(submitData.xhr).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining(`&nonString=${nonStringValue}`), - body: JSON.stringify({ bar: 'foo', nonString: nonStringValue }) - })) - expect(submitData.img).not.toHaveBeenCalled() - expect(submitData.beacon).not.toHaveBeenCalled() - }) -}) - -describe('send', () => { - test.each([null, undefined, false])('should not send request when body is empty and sendEmptyBody is %s', (sendEmptyBody) => { - const sendCallback = jest.fn() - const harvester = new Harvest() - - harvester.send({ endpoint: 'rum', payload: { qs: {}, body: {} }, opts: { sendEmptyBody }, cbFinished: sendCallback }) - - expect(sendCallback).toHaveBeenCalledWith({ sent: false }) - expect(submitData.xhr).not.toHaveBeenCalled() - expect(submitData.img).not.toHaveBeenCalled() - expect(submitData.beacon).not.toHaveBeenCalled() - }) - - test('should send request when body is empty and sendEmptyBody is true', () => { - const harvester = new Harvest() - - harvester.send({ endpoint: 'rum', payload: { qs: {}, body: {} }, opts: { sendEmptyBody: true } }) - - expect(submitData.xhr).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('https://example.com/1/abcd?'), - body: undefined - })) - expect(submitData.img).not.toHaveBeenCalled() - expect(submitData.beacon).not.toHaveBeenCalled() - }) - - test.each([null, undefined, []])('should remove %s values from the body and query string when sending', (emptyValue) => { - const harvester = new Harvest() - - harvester.send({ - endpoint: 'rum', - payload: { qs: { foo: 'bar', empty: emptyValue }, body: { bar: 'foo', empty: emptyValue } } - }) - - expect(submitData.xhr).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('&foo=bar'), - body: JSON.stringify({ bar: 'foo' }) - })) - expect(submitData.xhr).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.not.stringContaining('&empty'), - body: expect.not.stringContaining('empty') - })) - expect(submitData.img).not.toHaveBeenCalled() - expect(submitData.beacon).not.toHaveBeenCalled() - }) - - test.each([1, false, true])('should not remove value %s (when it doesn\'t have a length) from the body and query string when sending', (nonStringValue) => { - const harvester = new Harvest() - - harvester.send({ - endpoint: 'rum', - payload: { qs: { foo: 'bar', nonString: nonStringValue }, body: { bar: 'foo', nonString: nonStringValue } } - }) - - expect(submitData.xhr).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining(`&nonString=${nonStringValue}`), - body: JSON.stringify({ bar: 'foo', nonString: nonStringValue }) - })) - expect(submitData.img).not.toHaveBeenCalled() - expect(submitData.beacon).not.toHaveBeenCalled() - }) -}) diff --git a/src/common/harvest/harvest.js b/src/common/harvest/harvest.js index 2f5393103..6f8faabb3 100644 --- a/src/common/harvest/harvest.js +++ b/src/common/harvest/harvest.js @@ -3,10 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { mapOwn } from '../util/map-own' import { obj as encodeObj, param as encodeParam } from '../url/encode' import { stringify } from '../util/stringify' -import { submitData } from '../util/submit-data' +import * as submitData from '../util/submit-data' import { getLocation } from '../url/location' import { getInfo, getConfigurationValue, getRuntime } from '../config/config' import { cleanURL } from '../url/clean-url' @@ -16,27 +15,16 @@ import { Obfuscator } from '../util/obfuscate' import { applyFnToProps } from '../util/traverse' import { SharedContext } from '../context/shared-context' import { VERSION } from '../constants/env' -import { isBrowserScope, isWorkerScope } from '../util/global-scope' +import { isWorkerScope } from '../constants/runtime' /** - * @typedef {object} NetworkSendSpec - * @param {string} endpoint The endpoint to use (jserrors, events, resources etc.) - * @param {object} payload Object representing payload. - * @param {object} payload.qs Map of values that should be sent as part of the request query string. - * @param {string} payload.body String that should be sent as the body of the request. - * @param {string} payload.body.e Special case of body used for browser interactions. - * @param {object} opts Additional options for sending data - * @param {boolean} opts.needResponse Specify whether the caller expects a response data. - * @param {boolean} opts.unload Specify whether the call is a final harvest during page unload. - * @param {boolean} opts.sendEmptyBody Specify whether the call should be made even if the body is empty. Useful for rum calls. - * @param {function} submitMethod The submit method to use {@link ../util/submit-data} - * @param {string} customUrl Override the beacon url the data is sent to; must include protocol if defined - * @param {boolean} gzip Enabled gzip compression on the body of the request before it is sent - * @param {boolean} includeBaseParams Enables the use of base query parameters in the beacon url {@see Harvest.baseQueryString} + * @typedef {import('./types.js').NetworkSendSpec} NetworkSendSpec + * @typedef {import('./types.js').HarvestEndpointIdentifier} HarvestEndpointIdentifier + * @typedef {import('./types.js').HarvestPayload} HarvestPayload + * @typedef {import('./types.js').FeatureHarvestCallback} FeatureHarvestCallback + * @typedef {import('./types.js').FeatureHarvestCallbackOptions} FeatureHarvestCallbackOptions */ -const haveSendBeacon = !!navigator.sendBeacon // only the web window obj has sendBeacon at this time, so 'false' for other envs - export class Harvest extends SharedContext { constructor (parent) { super(parent) // gets any allowed properties from the parent and stores them in `sharedContext` @@ -51,17 +39,16 @@ export class Harvest extends SharedContext { /** * Initiate a harvest from multiple sources. An event that corresponds to the endpoint * name is emitted, which gives any listeners the opportunity to provide payload data. + * Note: Used by page_action * @param {NetworkSendSpec} spec Specification for sending data */ - sendX (spec) { - const { endpoint, opts } = spec - var submitMethod = getSubmitMethod(endpoint, opts) - if (!submitMethod) return false - var options = { - retry: submitMethod.method === submitData.xhr + sendX (spec = {}) { + const submitMethod = submitData.getSubmitMethod({ isFinalHarvest: spec.opts?.unload }) + const options = { + retry: !spec.opts?.unload && submitMethod === submitData.xhr } - const payload = this.createPayload(endpoint, options) - var caller = this.obfuscator.shouldObfuscate() ? this.obfuscateAndSend.bind(this) : this._send.bind(this) + const payload = this.createPayload(spec.endpoint, options) + const caller = this.obfuscator.shouldObfuscate() ? this.obfuscateAndSend.bind(this) : this._send.bind(this) return caller({ ...spec, payload, submitMethod }) } @@ -69,69 +56,71 @@ export class Harvest extends SharedContext { * Initiate a harvest call. * @param {NetworkSendSpec} spec Specification for sending data */ - send (spec) { - const { payload = {} } = spec - var makeBody = createAccumulator() - var makeQueryString = createAccumulator() - if (payload.body) mapOwn(payload.body, makeBody) - if (payload.qs) mapOwn(payload.qs, makeQueryString) + send (spec = {}) { + const caller = this.obfuscator.shouldObfuscate() ? this.obfuscateAndSend.bind(this) : this._send.bind(this) - var newPayload = { body: makeBody(), qs: makeQueryString() } - var caller = this.obfuscator.shouldObfuscate() ? this.obfuscateAndSend.bind(this) : this._send.bind(this) - - return caller({ ...spec, payload: newPayload }) + return caller({ ...spec, payload: this.cleanPayload(spec.payload) }) } /** * Apply obfuscation rules to the payload and then initial the harvest network call. * @param {NetworkSendSpec} spec Specification for sending data */ - obfuscateAndSend (spec) { + obfuscateAndSend (spec = {}) { const { payload = {} } = spec applyFnToProps(payload, (...args) => this.obfuscator.obfuscateString(...args), 'string', ['e']) return this._send({ ...spec, payload }) } + /** + * Initiate a harvest call. Typically used by `sendX` and `send` methods or called directly + * for raw network calls. + * @param {NetworkSendSpec} param0 Specification for sending data + * @returns {boolean} True if the network call succeeded. For final harvest calls, the return + * value should not be relied upon because network calls will be made asynchronously. + */ _send ({ endpoint, payload = {}, opts = {}, submitMethod, cbFinished, customUrl, raw, includeBaseParams = true }) { - var info = getInfo(this.sharedContext.agentIdentifier) + const info = getInfo(this.sharedContext.agentIdentifier) if (!info.errorBeacon) return false - var agentRuntime = getRuntime(this.sharedContext.agentIdentifier) + const agentRuntime = getRuntime(this.sharedContext.agentIdentifier) + let { body, qs } = this.cleanPayload(payload) - if (!payload.body && !opts?.sendEmptyBody) { // no payload body? nothing to send, just run onfinish stuff and return + if (Object.keys(body).length === 0 && !opts?.sendEmptyBody) { // no payload body? nothing to send, just run onfinish stuff and return if (cbFinished) { cbFinished({ sent: false }) } return false } - let url = '' + let url = `${this.getScheme()}://${info.errorBeacon}${endpoint !== 'rum' ? `/${endpoint}` : ''}/1/${info.licenseKey}` if (customUrl) url = customUrl - else if (raw) url = `${this.getScheme()}://${info.errorBeacon}/${endpoint}` - else url = `${this.getScheme()}://${info.errorBeacon}${endpoint !== 'rum' ? `/${endpoint}` : ''}/1/${info.licenseKey}` + if (raw) url = `${this.getScheme()}://${info.errorBeacon}/${endpoint}` - var baseParams = !raw && includeBaseParams ? this.baseQueryString() : '' - var payloadParams = payload.qs ? encodeObj(payload.qs, agentRuntime.maxBytes) : '' + const baseParams = !raw && includeBaseParams ? this.baseQueryString() : '' + let payloadParams = encodeObj(qs, agentRuntime.maxBytes) if (!submitMethod) { - submitMethod = getSubmitMethod(endpoint, opts) + submitMethod = submitData.getSubmitMethod({ isFinalHarvest: opts.unload }) + } + if (baseParams === '' && payloadParams.startsWith('&')) { + payloadParams = payloadParams.substring(1) } - var method = submitMethod.method - var useBody = submitMethod.useBody - - var body - var fullUrl = `${url}?${baseParams}${payloadParams}` - const gzip = payload?.qs?.content_encoding === 'gzip' + const fullUrl = `${url}?${baseParams}${payloadParams}` + const gzip = qs.content_encoding === 'gzip' if (!gzip) { - if (useBody && endpoint === 'events') { - body = payload.body.e - } else if (useBody) { - body = stringify(payload.body) + if (endpoint === 'events') { + body = body.e } else { - fullUrl = fullUrl + encodeObj(payload.body, agentRuntime.maxBytes) + body = stringify(body) } - } else body = payload.body + } + + if (!body || body.length === 0 || body === '{}' || body === '[]') { + // If body is null, undefined, or an empty object or array, send an empty string instead + body = '' + } // Get bytes harvested per endpoint as a supportability metric. See metrics aggregator (on unload). agentRuntime.bytesSent[endpoint] = (agentRuntime.bytesSent[endpoint] || 0) + body?.length || 0 @@ -146,30 +135,38 @@ export class Harvest extends SharedContext { Because they still do permit synch XHR, the idea is that at final harvest time (worker is closing), we just make a BLOCKING request--trivial impact--with the remaining data as a temp fill-in for sendBeacon. */ - var result = method({ url: fullUrl, body, sync: opts.unload && isWorkerScope, headers }) + let result = submitMethod({ url: fullUrl, body, sync: opts.unload && isWorkerScope, headers }) - if (cbFinished && method === submitData.xhr) { - var xhr = result - xhr.addEventListener('load', function () { - var result = { sent: true, status: this.status } + if (!opts.unload && cbFinished && submitMethod === submitData.xhr) { + const harvestScope = this + result.addEventListener('load', function () { + // `this` refers to the XHR object in this scope, do not change this to a fat arrow + const cbResult = { sent: true, status: this.status } if (this.status === 429) { - result.retry = true - result.delay = this.tooManyRequestsDelay + cbResult.retry = true + cbResult.delay = harvestScope.tooManyRequestsDelay } else if (this.status === 408 || this.status === 500 || this.status === 503) { - result.retry = true + cbResult.retry = true } if (opts.needResponse) { - result.responseText = this.responseText + cbResult.responseText = this.responseText } - cbFinished(result) + cbFinished(cbResult) }, eventListenerOpts(false)) } // if beacon request failed, retry with an alternative method -- will not happen for workers - if (!result && method === submitData.beacon) { - method = submitData.img - result = method({ url: fullUrl + encodeObj(payload.body, agentRuntime.maxBytes) }) + if (!result && submitMethod === submitData.beacon) { + // browsers that support sendBeacon also support fetch with keepalive - IE will not retry unload calls + submitMethod = submitData.fetchKeepAlive + try { + submitMethod({ url: fullUrl, body, headers }) + } catch (e) { + // Ignore error in final harvest + } finally { + result = true + } } return result @@ -177,11 +174,11 @@ export class Harvest extends SharedContext { // The stuff that gets sent every time. baseQueryString () { - var runtime = getRuntime(this.sharedContext.agentIdentifier) - var info = getInfo(this.sharedContext.agentIdentifier) + const runtime = getRuntime(this.sharedContext.agentIdentifier) + const info = getInfo(this.sharedContext.agentIdentifier) - var location = cleanURL(getLocation()) - var ref = this.obfuscator.shouldObfuscate() ? this.obfuscator.obfuscateString(location) : location + const location = cleanURL(getLocation()) + const ref = this.obfuscator.shouldObfuscate() ? this.obfuscator.obfuscateString(location) : location return ([ 'a=' + info.applicationID, @@ -197,51 +194,82 @@ export class Harvest extends SharedContext { ].join('')) } - createPayload (type, options) { - var makeBody = createAccumulator() - var makeQueryString = createAccumulator() - var listeners = ((this._events[type] && this._events[type]) || []) + /** + * Calls and accumulates data from registered harvesting functions based on + * the endpoint being harvested. + * @param {HarvestEndpointIdentifier} endpoint BAM endpoint identifier. + * @param {FeatureHarvestCallbackOptions} options Options to be passed to the + * feature harvest listener callback. + * @returns {HarvestPayload} Payload object to transmit to the bam endpoint. + */ + createPayload (endpoint, options) { + const listeners = this._events[endpoint] + const payload = { + body: {}, + qs: {} + } - for (var i = 0; i < listeners.length; i++) { - var singlePayload = listeners[i](options) - if (!singlePayload) continue - if (singlePayload.body) mapOwn(singlePayload.body, makeBody) - if (singlePayload.qs) mapOwn(singlePayload.qs, makeQueryString) + if (Array.isArray(listeners) && listeners.length > 0) { + for (let i = 0; i < listeners.length; i++) { + const singlePayload = listeners[i](options) + + if (singlePayload) { + payload.body = { + ...payload.body, + ...(singlePayload.body || {}) + } + payload.qs = { + ...payload.qs, + ...(singlePayload.qs || {}) + } + } + } } - return { body: makeBody(), qs: makeQueryString() } + return payload } - on (type, listener) { - var listeners = (this._events[type] || (this._events[type] = [])) - listeners.push(listener) - } + /** + * Cleans and returns a payload object containing a body and qs + * object with key/value pairs. KV pairs where the value is null, + * undefined, or an empty string are removed to save on transmission + * size. + * @param {HarvestPayload} payload Payload to be sent to the endpoint. + * @returns {HarvestPayload} Cleaned payload payload to be sent to the endpoint. + */ + cleanPayload (payload = {}) { + const clean = (input) => { + if (typeof Uint8Array !== 'undefined' && input instanceof Uint8Array) { + return input.length > 0 ? input : null + } + return Object.entries(input || {}) + .reduce((accumulator, [key, value]) => { + if (value !== null && value !== undefined && value.toString()?.length) { + accumulator[key] = value + } + + return accumulator + }, {}) + } - resetListeners () { - mapOwn(this._events, (key) => { - this._events[key] = [] - }) + return { + body: clean(payload.body), + qs: clean(payload.qs) + } } -} -export function getSubmitMethod (endpoint, opts) { - opts = opts || {} - var method - var useBody - - if (opts.unload && isBrowserScope) { // all the features' final harvest; neither methods work outside window context - useBody = haveSendBeacon - method = haveSendBeacon ? submitData.beacon : submitData.img // really only IE doesn't have Beacon API for web browsers - } else { - // `submitData.beacon` was removed, there is an upper limit to the - // number of data allowed before it starts failing, so we save it only for page unloading - useBody = true - method = submitData.xhr - } + /** + * Registers a function to be called when harvesting is triggered for a specific + * endpoint. + * @param {HarvestEndpointIdentifier} endpoint + * @param {FeatureHarvestCallback} listener + */ + on (endpoint, listener) { + if (!Array.isArray(this._events[endpoint])) { + this._events[endpoint] = [] + } - return { - method: method, - useBody: useBody + this._events[endpoint].push(listener) } } @@ -252,17 +280,3 @@ function transactionNameParam (info) { if (info.transactionName) return encodeParam('to', info.transactionName) return encodeParam('t', info.tNamePlain || 'Unnamed Transaction') } - -// returns a function that can be called to accumulate values to a single object -// when the function is called without parameters, then the accumulator is returned -function createAccumulator () { - var accumulator = {} - var hasData = false - return function (key, val) { - if (val !== null && val !== undefined && val.toString()?.length) { - accumulator[key] = val - hasData = true - } - if (hasData) return accumulator - } -} diff --git a/src/common/harvest/harvest.test.js b/src/common/harvest/harvest.test.js new file mode 100644 index 000000000..11d4ad3df --- /dev/null +++ b/src/common/harvest/harvest.test.js @@ -0,0 +1,818 @@ +import { faker } from '@faker-js/faker' + +import * as encodeModule from '../url/encode' +import * as submitDataModule from '../util/submit-data' +import * as configModule from '../config/config' +import { applyFnToProps } from '../util/traverse' +import * as runtimeModule from '../constants/runtime' + +import { Harvest } from './harvest' + +jest.enableAutomock() +jest.unmock('./harvest') + +let harvestInstance + +beforeEach(() => { + harvestInstance = new Harvest() +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('sendX', () => { + beforeEach(() => { + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(jest.fn()) + jest.spyOn(harvestInstance, '_send').mockImplementation(jest.fn()) + jest.spyOn(harvestInstance, 'obfuscateAndSend').mockImplementation(jest.fn()) + jest.spyOn(harvestInstance, 'createPayload').mockReturnValue({}) + }) + + test('should pass spec settings on to _send method', async () => { + const endpoint = faker.datatype.uuid() + const spec = { + endpoint, + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestInstance.sendX(spec) + + expect(harvestInstance._send).toHaveBeenCalledWith(expect.objectContaining(spec)) + }) + + test('should create payload with retry true', async () => { + const endpoint = faker.datatype.uuid() + const spec = { + endpoint, + opts: { + unload: false + } + } + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.xhr) + + harvestInstance.sendX(spec) + + expect(harvestInstance.createPayload).toHaveBeenCalledWith(spec.endpoint, { retry: true }) + }) + + test('should not use obfuscateAndSend', async () => { + jest.mocked(harvestInstance.obfuscator.shouldObfuscate).mockReturnValue(false) + + const endpoint = faker.datatype.uuid() + harvestInstance.sendX({ endpoint }) + + expect(harvestInstance._send).toHaveBeenCalledWith({ + endpoint, + payload: {}, + submitMethod: expect.any(Function) + }) + expect(harvestInstance.obfuscateAndSend).not.toHaveBeenCalled() + }) + + test('should use obfuscateAndSend', async () => { + jest.mocked(harvestInstance.obfuscator.shouldObfuscate).mockReturnValue(true) + + const endpoint = faker.datatype.uuid() + harvestInstance.sendX({ endpoint }) + + expect(harvestInstance.obfuscateAndSend).toHaveBeenCalledWith({ + endpoint, + payload: {}, + submitMethod: expect.any(Function) + }) + expect(harvestInstance._send).not.toHaveBeenCalled() + }) + + test.each([undefined, {}])('should still call _send when spec is %s', async (spec) => { + harvestInstance.sendX(spec) + + expect(harvestInstance._send).toHaveBeenCalledWith({ + payload: {}, + submitMethod: expect.any(Function) + }) + }) +}) + +describe('send', () => { + beforeEach(() => { + jest.spyOn(harvestInstance, '_send').mockImplementation(jest.fn()) + jest.spyOn(harvestInstance, 'obfuscateAndSend').mockImplementation(jest.fn()) + }) + + test('should pass spec settings on to _send method', async () => { + const endpoint = faker.datatype.uuid() + const spec = { + endpoint, + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + harvestInstance.send(spec) + + expect(harvestInstance._send).toHaveBeenCalledWith(expect.objectContaining(spec)) + }) + + test('should not use obfuscateAndSend', async () => { + jest.mocked(harvestInstance.obfuscator.shouldObfuscate).mockReturnValue(false) + + const endpoint = faker.datatype.uuid() + harvestInstance.send({ endpoint }) + + expect(harvestInstance._send).toHaveBeenCalledWith({ + endpoint, + payload: { + body: {}, + qs: {} + } + }) + expect(harvestInstance.obfuscateAndSend).not.toHaveBeenCalled() + }) + + test('should use obfuscateAndSend', async () => { + jest.mocked(harvestInstance.obfuscator.shouldObfuscate).mockReturnValue(true) + + const endpoint = faker.datatype.uuid() + harvestInstance.send({ endpoint }) + + expect(harvestInstance.obfuscateAndSend).toHaveBeenCalledWith({ + endpoint, + payload: { + body: {}, + qs: {} + } + }) + expect(harvestInstance._send).not.toHaveBeenCalled() + }) + + test.each([undefined, {}])('should still call _send when spec is %s', async (spec) => { + harvestInstance.send(spec) + + expect(harvestInstance._send).toHaveBeenCalledWith({ + payload: { + body: {}, + qs: {} + } + }) + }) +}) + +describe('_send', () => { + let errorBeacon + let submitMethod + let spec + let licenseKey + + beforeEach(() => { + errorBeacon = faker.internet.domainName() + licenseKey = faker.datatype.uuid() + jest.mocked(configModule.getInfo).mockReturnValue({ + errorBeacon, + licenseKey + }) + jest.mocked(configModule.getRuntime).mockReturnValue({ + bytesSent: {}, + queryBytesSent: {}, + maxBytes: Infinity + }) + + spec = { + endpoint: faker.datatype.uuid(), + payload: { + body: { + [faker.datatype.uuid()]: faker.lorem.sentence() + }, + qs: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + }, + opts: {} + } + submitMethod = jest.fn().mockReturnValue(true) + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitMethod) + }) + + test('should return false when info.errorBeacon is not defined', () => { + jest.mocked(configModule.getInfo).mockReturnValue({}) + + const result = harvestInstance._send(spec) + + expect(result).toEqual(false) + expect(submitMethod).not.toHaveBeenCalled() + }) + + test('should return false when body is empty and sendEmptyBody is false', () => { + jest.spyOn(harvestInstance, 'cleanPayload').mockReturnValue({ body: {}, qs: {} }) + spec.opts.sendEmptyBody = false + spec.cbFinished = jest.fn() + + const result = harvestInstance._send(spec) + + expect(result).toEqual(false) + expect(submitMethod).not.toHaveBeenCalled() + expect(spec.cbFinished).toHaveBeenCalledWith({ sent: false }) + }) + + test('should construct the rum url', () => { + spec.endpoint = 'rum' + + const result = harvestInstance._send(spec) + + expect(result).toEqual(true) + expect(submitMethod).toHaveBeenCalledWith({ + body: JSON.stringify(spec.payload.body), + headers: [{ key: 'content-type', value: 'text/plain' }], + sync: undefined, + url: expect.stringContaining(`https://${errorBeacon}/1/${licenseKey}?`) + }) + }) + + test('should construct the non-rum url', () => { + const result = harvestInstance._send(spec) + + expect(result).toEqual(true) + expect(submitMethod).toHaveBeenCalledWith({ + body: JSON.stringify(spec.payload.body), + headers: [{ key: 'content-type', value: 'text/plain' }], + sync: undefined, + url: expect.stringContaining(`https://${errorBeacon}/${spec.endpoint}/1/${licenseKey}?`) + }) + }) + + test('should use the custom defined url', () => { + spec.customUrl = faker.internet.url() + + const result = harvestInstance._send(spec) + + expect(result).toEqual(true) + expect(submitMethod).toHaveBeenCalledWith({ + body: JSON.stringify(spec.payload.body), + headers: [{ key: 'content-type', value: 'text/plain' }], + sync: undefined, + url: expect.stringContaining(`${spec.customUrl}?`) + }) + }) + + test('should not include the license key or base params in a raw url', () => { + spec.raw = true + + const result = harvestInstance._send(spec) + const queryString = Object.entries(spec.payload.qs) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&') + + expect(result).toEqual(true) + expect(submitMethod).toHaveBeenCalledWith({ + body: JSON.stringify(spec.payload.body), + headers: [{ key: 'content-type', value: 'text/plain' }], + sync: undefined, + url: `https://${errorBeacon}/${spec.endpoint}?${queryString}` + }) + }) + + test('should remove leading ampersand from encoded payload params', () => { + const queryString = Object.entries(spec.payload.qs) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&') + + jest.mocked(encodeModule.obj).mockReturnValue(`&${queryString}`) + spec.raw = true + + const result = harvestInstance._send(spec) + + expect(result).toEqual(true) + expect(submitMethod).toHaveBeenCalledWith({ + body: JSON.stringify(spec.payload.body), + headers: [{ key: 'content-type', value: 'text/plain' }], + sync: undefined, + url: `https://${errorBeacon}/${spec.endpoint}?${queryString}` + }) + }) + + test('should not alter body when gzip qs is present', () => { + spec.payload.qs.content_encoding = 'gzip' + + const result = harvestInstance._send(spec) + + expect(result).toEqual(true) + expect(submitMethod).toHaveBeenCalledWith({ + body: spec.payload.body, + headers: [{ key: 'content-type', value: 'text/plain' }], + sync: undefined, + url: expect.stringContaining(`https://${errorBeacon}/${spec.endpoint}/1/${licenseKey}?`) + }) + }) + + test('should set body to events when endpoint is events', () => { + spec.endpoint = 'events' + spec.payload.body.e = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + + const result = harvestInstance._send(spec) + + expect(result).toEqual(true) + expect(submitMethod).toHaveBeenCalledWith({ + body: spec.payload.body.e, + headers: [{ key: 'content-type', value: 'text/plain' }], + sync: undefined, + url: expect.stringContaining(`https://${errorBeacon}/${spec.endpoint}/1/${licenseKey}?`) + }) + }) + + test.each([ + null, + undefined, + {}, + [] + ])('should set body to empty string when %s', (inputBody) => { + spec.opts.sendEmptyBody = true + spec.payload.body = inputBody + + const result = harvestInstance._send(spec) + + expect(result).toEqual(true) + expect(submitMethod).toHaveBeenCalledWith({ + body: '', + headers: [{ key: 'content-type', value: 'text/plain' }], + sync: undefined, + url: expect.stringContaining(`https://${errorBeacon}/${spec.endpoint}/1/${licenseKey}?`) + }) + }) + + test('should add a callback to XHR and call cbCallback when not a final harvest', () => { + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.xhr) + spec.cbFinished = jest.fn() + + const result = harvestInstance._send(spec) + const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener + const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1] + + const xhrState = { + status: faker.datatype.uuid() + } + xhrLoadHandler.call(xhrState) + + expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object)) + expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value) + expect(submitMethod).not.toHaveBeenCalled() + expect(spec.cbFinished).toHaveBeenCalledWith({ ...xhrState, sent: true }) + }) + + test('should set cbFinished state retry to true with delay when xhr has 429 status', () => { + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.xhr) + spec.cbFinished = jest.fn() + harvestInstance.tooManyRequestsDelay = faker.datatype.number({ min: 100, max: 1000 }) + + const result = harvestInstance._send(spec) + const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener + const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1] + + const xhrState = { + status: 429 + } + xhrLoadHandler.call(xhrState) + + expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object)) + expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value) + expect(submitMethod).not.toHaveBeenCalled() + expect(spec.cbFinished).toHaveBeenCalledWith({ + ...xhrState, + sent: true, + retry: true, + delay: harvestInstance.tooManyRequestsDelay + }) + }) + + test.each([ + 408, 500, 503 + ])('should set cbFinished state retry to true without delay when xhr has %s status', (statusCode) => { + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.xhr) + spec.cbFinished = jest.fn() + + const result = harvestInstance._send(spec) + const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener + const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1] + + const xhrState = { + status: statusCode + } + xhrLoadHandler.call(xhrState) + + expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object)) + expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value) + expect(submitMethod).not.toHaveBeenCalled() + expect(spec.cbFinished).toHaveBeenCalledWith({ + ...xhrState, + sent: true, + retry: true + }) + }) + + test('should include response in call to cbFinished when needResponse is true', () => { + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.xhr) + spec.cbFinished = jest.fn() + spec.opts.needResponse = true + + const result = harvestInstance._send(spec) + const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener + const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1] + + const xhrState = { + status: faker.datatype.uuid(), + responseText: faker.lorem.sentence() + } + xhrLoadHandler.call(xhrState) + + expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object)) + expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value) + expect(submitMethod).not.toHaveBeenCalled() + expect(spec.cbFinished).toHaveBeenCalledWith({ + ...xhrState, + sent: true + }) + }) + + test('should not include response in call to cbFinished when needResponse is false', () => { + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.xhr) + spec.cbFinished = jest.fn() + spec.opts.needResponse = false + + const result = harvestInstance._send(spec) + const xhrAddEventListener = jest.mocked(submitDataModule.xhr).mock.results[0].value.addEventListener + const xhrLoadHandler = jest.mocked(xhrAddEventListener).mock.calls[0][1] + + const xhrState = { + status: faker.datatype.uuid(), + responseText: faker.lorem.sentence() + } + xhrLoadHandler.call(xhrState) + + expect(xhrAddEventListener).toHaveBeenCalledWith('load', expect.any(Function), expect.any(Object)) + expect(result).toEqual(jest.mocked(submitDataModule.xhr).mock.results[0].value) + expect(submitMethod).not.toHaveBeenCalled() + expect(spec.cbFinished).toHaveBeenCalledWith({ + ...xhrState, + responseText: undefined, + sent: true + }) + }) + + test('should fallback to fetchKeepAlive when beacon returns false', async () => { + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.beacon) + jest.mocked(submitDataModule.beacon).mockReturnValue(false) + spec.opts.unload = true + + const results = harvestInstance._send(spec) + await new Promise(process.nextTick) + + expect(results).toEqual(true) + expect(submitDataModule.fetchKeepAlive).toHaveBeenCalledWith({ + body: JSON.stringify(spec.payload.body), + headers: [{ key: 'content-type', value: 'text/plain' }], + url: expect.stringContaining(`https://${errorBeacon}/${spec.endpoint}/1/${licenseKey}?`) + }) + }) + + test('should not throw an exception if fetchKeepAlive throws error', async () => { + jest.mocked(submitDataModule.getSubmitMethod).mockReturnValue(submitDataModule.beacon) + jest.mocked(submitDataModule.beacon).mockReturnValue(false) + jest.mocked(submitDataModule.fetchKeepAlive).mockImplementation(() => { + throw new Error(faker.lorem.sentence()) + }) + spec.opts.unload = true + + const results = harvestInstance._send(spec) + await new Promise(process.nextTick) + + expect(results).toEqual(true) + expect(submitDataModule.fetchKeepAlive).toHaveBeenCalledWith({ + body: JSON.stringify(spec.payload.body), + headers: [{ key: 'content-type', value: 'text/plain' }], + url: expect.stringContaining(`https://${errorBeacon}/${spec.endpoint}/1/${licenseKey}?`) + }) + }) +}) + +describe('obfuscateAndSend', () => { + beforeEach(() => { + jest.spyOn(harvestInstance, '_send').mockImplementation(jest.fn()) + }) + + test('should apply obfuscation to payload', () => { + const payload = { + body: { + foo: faker.lorem.sentence() + }, + qs: { + foo: faker.lorem.sentence() + } + } + + harvestInstance.obfuscateAndSend({ payload }) + + expect(applyFnToProps).toHaveBeenCalledWith(payload, expect.any(Function), 'string', ['e']) + expect(harvestInstance.obfuscator.obfuscateString).toHaveBeenCalledWith(payload) + }) + + test('should still call _send when spec is undefined', () => { + harvestInstance.obfuscateAndSend() + + expect(harvestInstance._send).toHaveBeenCalled() + }) + + test.each([ + null, + undefined + ])('should still call _send when payload is %s', (payload) => { + harvestInstance.obfuscateAndSend({ payload }) + + expect(harvestInstance._send).toHaveBeenCalled() + }) +}) + +describe('baseQueryString', () => { + beforeEach(() => { + jest.mocked(configModule.getInfo).mockReturnValue({}) + jest.mocked(configModule.getRuntime).mockReturnValue({}) + }) + + test('should construct a string of base query parameters', () => { + const applicationID = faker.datatype.uuid() + const sa = faker.datatype.uuid() + jest.mocked(configModule.getInfo).mockReturnValue({ + applicationID, + sa + }) + const customTransaction = faker.datatype.uuid() + const ptid = faker.datatype.uuid() + jest.mocked(configModule.getRuntime).mockReturnValue({ + customTransaction, + ptid + }) + + const results = harvestInstance.baseQueryString() + + expect(results).toContain(`a=${applicationID}`) + expect(encodeModule.param).toHaveBeenCalledWith('sa', sa) + expect(results).toContain(`&sa=${sa}`) + expect(encodeModule.param).toHaveBeenCalledWith('v', expect.stringMatching(/\d{1,3}\.\d{1,3}\.\d{1,3}/)) + expect(results).toMatch(/&v=\d{1,3}\.\d{1,3}\.\d{1,3}/) + expect(encodeModule.param).toHaveBeenCalledWith('t', 'Unnamed Transaction') + expect(results).toContain('&t=Unnamed%20Transaction') + expect(encodeModule.param).toHaveBeenCalledWith('ct', customTransaction) + expect(results).toContain(`&ct=${customTransaction}`) + expect(results).toMatch(/&rst=\d{1,9}/) + expect(results).toContain('&ck=0') + expect(results).toContain('&s=0') + expect(encodeModule.param).toHaveBeenCalledWith('ref', location) + expect(results).toContain(`&ref=${encodeURIComponent(location)}`) + expect(encodeModule.param).toHaveBeenCalledWith('ptid', ptid) + expect(results).toContain(`&ptid=${ptid}`) + }) + + test('should set t param to info.tNamePlain', () => { + const tNamePlain = faker.datatype.uuid() + jest.mocked(configModule.getInfo).mockReturnValue({ + tNamePlain + }) + + const results = harvestInstance.baseQueryString() + + expect(encodeModule.param).toHaveBeenCalledWith('t', tNamePlain) + expect(results).toContain(`&t=${tNamePlain}`) + }) + + test('should set to param to info.transactionName and exclude t param', () => { + const transactionName = faker.datatype.uuid() + jest.mocked(configModule.getInfo).mockReturnValue({ + transactionName + }) + + const results = harvestInstance.baseQueryString() + + expect(encodeModule.param).not.toHaveBeenCalledWith('t', expect.any(String)) + expect(results).not.toContain('&t=') + expect(encodeModule.param).toHaveBeenCalledWith('to', transactionName) + expect(results).toContain(`&to=${transactionName}`) + }) + + test('should default sa to empty string', () => { + const results = harvestInstance.baseQueryString() + + expect(encodeModule.param).toHaveBeenCalledWith('sa', '') + expect(results).toContain('&sa=') + }) + + test('should default s to 0', () => { + const results = harvestInstance.baseQueryString() + + expect(results).toContain('&s=0') + }) + + test('should obfuscate ref', () => { + const obfuscatedLocation = faker.datatype.uuid() + jest.mocked(harvestInstance.obfuscator.shouldObfuscate).mockReturnValue(true) + jest.mocked(harvestInstance.obfuscator.obfuscateString).mockReturnValue(obfuscatedLocation) + + const results = harvestInstance.baseQueryString() + + expect(harvestInstance.obfuscator.obfuscateString).toHaveBeenCalledWith(location) + expect(results).toContain(`&ref=${obfuscatedLocation}`) + }) + + test('should default ptid to empty string', () => { + const results = harvestInstance.baseQueryString() + + expect(encodeModule.param).toHaveBeenCalledWith('ptid', '') + expect(results).toContain('&ptid=') + }) +}) + +describe('createPayload', () => { + test('should return empty body and qs values when no listeners exist', () => { + const feature = faker.datatype.uuid() + const results = harvestInstance.createPayload(feature) + + expect(results).toEqual({ + body: {}, + qs: {} + }) + }) + + test('should pass options to callback', () => { + const feature = faker.datatype.uuid() + const options = { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + const harvestCallback = jest.fn() + + harvestInstance.on(feature, harvestCallback) + const results = harvestInstance.createPayload(feature, options) + + expect(results).toEqual({ + body: {}, + qs: {} + }) + }) + + test('should aggregate the body properties of the payload', () => { + const feature = faker.datatype.uuid() + const payloadA = { + body: { + [faker.datatype.uuid()]: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + } + const payloadB = { + body: { + [faker.datatype.uuid()]: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + } + const harvestCallbackA = jest.fn().mockReturnValue(payloadA) + const harvestCallbackB = jest.fn().mockReturnValue(payloadB) + + harvestInstance.on(feature, harvestCallbackA) + harvestInstance.on(feature, harvestCallbackB) + const results = harvestInstance.createPayload(feature) + + expect(results).toEqual({ + body: { + ...payloadA.body, + ...payloadB.body + }, + qs: {} + }) + }) + + test('should aggregate the qs properties of the payload', () => { + const feature = faker.datatype.uuid() + const payloadA = { + qs: { + [faker.datatype.uuid()]: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + } + const payloadB = { + qs: { + [faker.datatype.uuid()]: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + } + const harvestCallbackA = jest.fn().mockReturnValue(payloadA) + const harvestCallbackB = jest.fn().mockReturnValue(payloadB) + + harvestInstance.on(feature, harvestCallbackA) + harvestInstance.on(feature, harvestCallbackB) + const results = harvestInstance.createPayload(feature) + + expect(results).toEqual({ + body: {}, + qs: { + ...payloadA.qs, + ...payloadB.qs + } + }) + }) + + test('should not deep merge the body and qs properties', () => { + const feature = faker.datatype.uuid() + const bodyProp = faker.datatype.uuid() + const qsProp = faker.datatype.uuid() + const payloadA = { + body: { + [bodyProp]: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + }, + qs: { + [qsProp]: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + } + const payloadB = { + body: { + [bodyProp]: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + }, + qs: { + [qsProp]: { + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + } + const harvestCallbackA = jest.fn().mockReturnValue(payloadA) + const harvestCallbackB = jest.fn().mockReturnValue(payloadB) + + harvestInstance.on(feature, harvestCallbackA) + harvestInstance.on(feature, harvestCallbackB) + const results = harvestInstance.createPayload(feature) + + expect(results).toEqual({ + body: payloadB.body, + qs: payloadB.qs + }) + }) +}) + +describe('cleanPayload', () => { + test('should remove undefined properties from body and qs', () => { + const payload = { + body: { + foo: undefined, + [faker.datatype.uuid()]: faker.lorem.sentence() + }, + qs: { + foo: undefined, + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + + const results = harvestInstance.cleanPayload(payload) + + expect(Object.keys(results.body)).not.toContain('foo') + expect(Object.keys(results.qs)).not.toContain('foo') + }) + + test('should remove null properties from body and qs', () => { + const payload = { + body: { + foo: null, + [faker.datatype.uuid()]: faker.lorem.sentence() + }, + qs: { + foo: null, + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + + const results = harvestInstance.cleanPayload(payload) + + expect(Object.keys(results.body)).not.toContain('foo') + expect(Object.keys(results.qs)).not.toContain('foo') + }) + + test('should remove empty string properties from body and qs', () => { + const payload = { + body: { + foo: '', + [faker.datatype.uuid()]: faker.lorem.sentence() + }, + qs: { + foo: '', + [faker.datatype.uuid()]: faker.lorem.sentence() + } + } + + const results = harvestInstance.cleanPayload(payload) + + expect(Object.keys(results.body)).not.toContain('foo') + expect(Object.keys(results.qs)).not.toContain('foo') + }) +}) diff --git a/src/common/harvest/types.js b/src/common/harvest/types.js new file mode 100644 index 000000000..76c6d99b2 --- /dev/null +++ b/src/common/harvest/types.js @@ -0,0 +1,47 @@ +/** + * @file Contains types related to harvesting data. + * @copyright 2023 New Relic Corporation. All rights reserved. + * @license Apache-2.0 + */ + +/** + * @typedef {'rum'|'jserrors'|'events'|'ins'|'resources'|'blob'} HarvestEndpointIdentifier + */ + +/** + * @typedef {object} HarvestPayload + * @property {object} qs Map of values that should be sent as part of the request query string. + * @property {object} body Map of values that should be sent as the body of the request. + * @property {string} body.e Special case of body used for browser interactions. + */ + +/** + * @typedef {object} FeatureHarvestCallbackOptions Options for aggregating data for harvesting. + * @property {boolean} options.retry Indicates if the feature should store the aggregated + * data in anticipation of a possible need to retry the transmission. + */ + +/** + * @callback FeatureHarvestCallback + * @param {FeatureHarvestCallbackOptions} options Options for aggregating data for harvesting. + * @returns {HarvestPayload} Payload of data to transmit to bam endpoint. + */ + +/** + * @typedef {object} NetworkSendSpec + * @property {HarvestEndpointIdentifier} endpoint The endpoint to use (jserrors, events, resources etc.) + * @property {HarvestPayload} payload Object representing payload. + * @property {object} opts Additional options for sending data + * @property {boolean} opts.needResponse Specify whether the caller expects a response data. + * @property {boolean} opts.unload Specify whether the call is a final harvest during page unload. + * @property {boolean} opts.sendEmptyBody Specify whether the call should be made even if the body is empty. Useful for rum calls. + * @property {boolean} opts.retry Indicates if the feature should store the aggregated data in anticipation of a possible need to + * retry the transmission. + * @property {import('../util/submit-data.js').NetworkMethods} submitMethod The network method to use {@link ../util/submit-data.js} + * @property {string} customUrl Override the beacon url the data is sent to; must include protocol if defined + * @property {boolean} raw If true, disables adding the license key to the url + * @property {boolean} includeBaseParams Enables the use of base query parameters in the beacon url + */ + +/* istanbul ignore next */ +export const unused = {} diff --git a/src/common/ids/id.js b/src/common/ids/id.js index 628490d7e..2a0fed49f 100644 --- a/src/common/ids/id.js +++ b/src/common/ids/id.js @@ -4,7 +4,7 @@ */ import { getOrSet } from '../util/get-or-set' -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' // Start assigning ids at 1 so 0 can always be used for Window or WorkerGlobalScope, without // actually setting it (which would create a global variable). diff --git a/src/common/ids/unique-id.js b/src/common/ids/unique-id.js index 5e0ff999a..81f9317a3 100644 --- a/src/common/ids/unique-id.js +++ b/src/common/ids/unique-id.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' const uuidv4Template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' diff --git a/src/common/session/__mocks__/session-entity.js b/src/common/session/__mocks__/session-entity.js new file mode 100644 index 000000000..08c2cc7f6 --- /dev/null +++ b/src/common/session/__mocks__/session-entity.js @@ -0,0 +1,25 @@ +export const SESSION_EVENTS = { + PAUSE: 'session-pause', + RESET: 'session-reset', + RESUME: 'session-resume' +} + +export const SessionEntity = jest.fn(function () { + this.setup = jest.fn() + this.sync = jest.fn() + this.read = jest.fn() + this.write = jest.fn() + this.reset = jest.fn() + this.refresh = jest.fn() + this.isExpired = jest.fn(() => false) + this.isInvalid = jest.fn(() => false) + this.collectSM = jest.fn() + this.getFutureTimestamp = jest.fn() + this.syncCustomAttribute = jest.fn() + + Object.defineProperty(this, 'lookupKey', { + configurable: true, + enumerable: true, + get: jest.fn() + }) +}) diff --git a/src/common/session/session-entity.component-test.js b/src/common/session/session-entity.component-test.js index 83f741b1e..769ef6c55 100644 --- a/src/common/session/session-entity.component-test.js +++ b/src/common/session/session-entity.component-test.js @@ -40,7 +40,7 @@ jest.mock('../timer/interaction-timer') jest.useFakeTimers() const mockBrowserScope = jest.fn().mockImplementation(() => true) -jest.mock('../util/global-scope', () => ({ +jest.mock('../constants/runtime', () => ({ __esModule: true, get isBrowserScope () { return mockBrowserScope() diff --git a/src/common/session/session-entity.js b/src/common/session/session-entity.js index 20cc71a11..ce4f70de4 100644 --- a/src/common/session/session-entity.js +++ b/src/common/session/session-entity.js @@ -3,7 +3,7 @@ import { warn } from '../util/console' import { stringify } from '../util/stringify' import { ee } from '../event-emitter/contextual-ee' import { Timer } from '../timer/timer' -import { isBrowserScope } from '../util/global-scope' +import { isBrowserScope } from '../constants/runtime' import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, PREFIX } from './constants' import { InteractionTimer } from '../timer/interaction-timer' import { wrapEvents } from '../wrap' diff --git a/src/common/timer/interaction-timer.js b/src/common/timer/interaction-timer.js index 60dac8b9e..5a40072b4 100644 --- a/src/common/timer/interaction-timer.js +++ b/src/common/timer/interaction-timer.js @@ -1,7 +1,7 @@ import { Timer } from './timer' import { subscribeToVisibilityChange } from '../window/page-visibility' import { debounce } from '../util/invoke' -import { isBrowserScope } from '../util/global-scope' +import { isBrowserScope } from '../constants/runtime' export class InteractionTimer extends Timer { constructor (opts, ms) { diff --git a/src/common/timing/__mocks__/now.js b/src/common/timing/__mocks__/now.js new file mode 100644 index 000000000..df67dd927 --- /dev/null +++ b/src/common/timing/__mocks__/now.js @@ -0,0 +1 @@ +export const now = jest.fn(() => performance.now()) diff --git a/src/common/unload/__mocks__/eol.js b/src/common/unload/__mocks__/eol.js new file mode 100644 index 000000000..5037c8d50 --- /dev/null +++ b/src/common/unload/__mocks__/eol.js @@ -0,0 +1 @@ +export const subscribeToEOL = jest.fn() diff --git a/src/common/unload/eol.js b/src/common/unload/eol.js index 88271f893..e681d85e9 100644 --- a/src/common/unload/eol.js +++ b/src/common/unload/eol.js @@ -2,10 +2,9 @@ * Copyright 2020 New Relic Corporation. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { ffVersion } from '../browser-version/firefox-version' import { windowAddEventListener } from '../event-listener/event-listener-opts' import { single } from '../util/invoke' -import { globalScope, isWorkerScope, isBrowserScope } from '../util/global-scope' +import { ffVersion, globalScope, isWorkerScope, isBrowserScope } from '../constants/runtime' import { subscribeToVisibilityChange } from '../window/page-visibility' if (isWorkerScope) { diff --git a/src/common/url/__mocks__/clean-url.js b/src/common/url/__mocks__/clean-url.js new file mode 100644 index 000000000..a66cca4ee --- /dev/null +++ b/src/common/url/__mocks__/clean-url.js @@ -0,0 +1 @@ +export const cleanURL = jest.fn((input) => input) diff --git a/src/common/url/__mocks__/encode.js b/src/common/url/__mocks__/encode.js new file mode 100644 index 000000000..4c0f3456f --- /dev/null +++ b/src/common/url/__mocks__/encode.js @@ -0,0 +1,7 @@ +export const qs = jest.fn((input) => encodeURIComponent(input)) +export const fromArray = jest.fn((input) => input.join('')) +export const obj = jest.fn((input) => Object.entries(input) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&') +) +export const param = jest.fn((name, value) => `&${name}=${encodeURIComponent(value)}`) diff --git a/src/common/url/__mocks__/location.js b/src/common/url/__mocks__/location.js new file mode 100644 index 000000000..6cd8dfd69 --- /dev/null +++ b/src/common/url/__mocks__/location.js @@ -0,0 +1 @@ +export const getLocation = jest.fn(() => location) diff --git a/src/common/url/canonicalize-url.js b/src/common/url/canonicalize-url.js index 0cac13638..6c666d442 100644 --- a/src/common/url/canonicalize-url.js +++ b/src/common/url/canonicalize-url.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { initialLocation } from '../util/global-scope' +import { initialLocation } from '../constants/runtime' import { cleanURL } from './clean-url' /** diff --git a/src/common/url/canonicalize-url.test.js b/src/common/url/canonicalize-url.test.js index 4b6a5da3b..35e67f5ba 100644 --- a/src/common/url/canonicalize-url.test.js +++ b/src/common/url/canonicalize-url.test.js @@ -1,9 +1,9 @@ import { faker } from '@faker-js/faker' -import * as globalScopeModule from '../util/global-scope' +import * as globalScopeModule from '../constants/runtime' import * as cleanUrlModule from './clean-url' import { canonicalizeUrl } from './canonicalize-url' -jest.mock('../util/global-scope') +jest.mock('../constants/runtime') jest.mock('./clean-url') beforeEach(() => { diff --git a/src/common/url/parse-url.js b/src/common/url/parse-url.js index 40dca17d4..d1b3afb7a 100644 --- a/src/common/url/parse-url.js +++ b/src/common/url/parse-url.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { globalScope, isBrowserScope } from '../util/global-scope' +import { globalScope, isBrowserScope } from '../constants/runtime' var stringsToParsedUrls = {} diff --git a/src/common/url/parse-url.test.js b/src/common/url/parse-url.test.js index 24be189b2..d2bb2c3d2 100644 --- a/src/common/url/parse-url.test.js +++ b/src/common/url/parse-url.test.js @@ -63,7 +63,7 @@ const urlTests = [ ] test.each(urlTests)('verify url parsing inside browser scope', async ({ input, expected }) => { - jest.doMock('../util/global-scope', () => ({ + jest.doMock('../constants/runtime', () => ({ __esModule: true, isBrowserScope: true, globalScope: global @@ -74,7 +74,7 @@ test.each(urlTests)('verify url parsing inside browser scope', async ({ input, e }) test.each(urlTests)('verify url parsing outside browser scope', async ({ input, expected }) => { - jest.doMock('../util/global-scope', () => ({ + jest.doMock('../constants/runtime', () => ({ __esModule: true, isBrowserScope: false, globalScope: global @@ -85,7 +85,7 @@ test.each(urlTests)('verify url parsing outside browser scope', async ({ input, }) test('should cache parsed urls', async () => { - jest.doMock('../util/global-scope', () => ({ + jest.doMock('../constants/runtime', () => ({ __esModule: true, isBrowserScope: true, globalScope: global diff --git a/src/common/url/protocol.js b/src/common/url/protocol.js index 16764c8b6..7d989a3b1 100644 --- a/src/common/url/protocol.js +++ b/src/common/url/protocol.js @@ -2,7 +2,7 @@ * Copyright 2020 New Relic Corporation. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' export function isFileProtocol () { return Boolean(globalScope?.location?.protocol === 'file:') diff --git a/src/common/util/__mocks__/obfuscate.js b/src/common/util/__mocks__/obfuscate.js new file mode 100644 index 000000000..d71930fbf --- /dev/null +++ b/src/common/util/__mocks__/obfuscate.js @@ -0,0 +1,10 @@ +export const Obfuscator = jest.fn(function () { + this.sharedContext = { + agentIdentifier: 'abcd' + } + + this.shouldObfuscate = jest.fn(() => false) + this.obfuscateString = jest.fn((input) => input) +}) +export const getRules = jest.fn(() => ([])) +export const validateRules = jest.fn(() => true) diff --git a/src/common/util/__mocks__/stringify.js b/src/common/util/__mocks__/stringify.js new file mode 100644 index 000000000..d705f129c --- /dev/null +++ b/src/common/util/__mocks__/stringify.js @@ -0,0 +1 @@ +export const stringify = jest.fn((input) => JSON.stringify(input)) diff --git a/src/common/util/__mocks__/submit-data.js b/src/common/util/__mocks__/submit-data.js new file mode 100644 index 000000000..baf593515 --- /dev/null +++ b/src/common/util/__mocks__/submit-data.js @@ -0,0 +1,6 @@ +export const getSubmitMethod = jest.fn(() => jest.fn()) +export const xhr = jest.fn(() => ({ + addEventListener: jest.fn() +})) +export const fetchKeepAlive = jest.fn().mockResolvedValue({}) +export const beacon = jest.fn(() => true) diff --git a/src/common/util/__mocks__/traverse.js b/src/common/util/__mocks__/traverse.js new file mode 100644 index 000000000..bf6f7ab9a --- /dev/null +++ b/src/common/util/__mocks__/traverse.js @@ -0,0 +1 @@ +export const applyFnToProps = jest.fn((input, fn) => fn(input)) diff --git a/src/common/util/global-scope.js b/src/common/util/global-scope.js deleted file mode 100644 index e10b21d0f..000000000 --- a/src/common/util/global-scope.js +++ /dev/null @@ -1,23 +0,0 @@ -/* global globalThis, WorkerGlobalScope, WorkerNavigator */ - -export const isBrowserScope = - Boolean(typeof window !== 'undefined' && window.document) - -export const isWorkerScope = - Boolean(typeof WorkerGlobalScope !== 'undefined' && self.navigator instanceof WorkerNavigator) - -export let globalScope = (() => { - if (isBrowserScope) { - return window - } else if (isWorkerScope) { - if (typeof globalThis !== 'undefined' && globalThis instanceof WorkerGlobalScope) { - return globalThis - } else if (self instanceof WorkerGlobalScope) { - return self - } - } - - throw new Error('New Relic browser agent shutting down due to error: Unable to locate global scope. This is possibly due to code redefining browser global variables like "self" and "window".') -})() - -export const initialLocation = '' + globalScope.location diff --git a/src/common/util/global-scope.test.js b/src/common/util/global-scope.test.js deleted file mode 100644 index afbbb7d1b..000000000 --- a/src/common/util/global-scope.test.js +++ /dev/null @@ -1,87 +0,0 @@ -/* -The global-scope module contains exports that are defined once at the time -of importing the module. For this reason, the module must be dynamically -imported in each test case. - -A scope must always exist or importing the module will throw an error. Use -`enableWorkerScope` to enable the worker scope. Be sure to call `disableWorkerScope` -before any calls to `expect` or the test will fail with an error from Jest. -*/ - -import { faker } from '@faker-js/faker' - -afterEach(() => { - jest.restoreAllMocks() - jest.resetModules() -}) - -test('should indicate a browser scope', async () => { - jest.spyOn(global, 'window', 'get').mockReturnValue({ document: {} }) - - const globalScopeModule = await import('./global-scope') - - expect(globalScopeModule.isBrowserScope).toBe(true) - expect(globalScopeModule.isWorkerScope).toBe(false) - expect(globalScopeModule.globalScope).toBe(global.window) -}) - -test('should indicate a worker scope', async () => { - enableWorkerScope() - const globalScopeModule = await import('./global-scope') - const mockedGlobalThis = global.globalThis - disableWorkerScope() - - expect(globalScopeModule.isBrowserScope).toBe(false) - expect(globalScopeModule.isWorkerScope).toBe(true) - expect(globalScopeModule.globalScope).toBe(mockedGlobalThis) -}) - -test('should return the self global', async () => { - enableWorkerScope() - jest.replaceProperty(global, 'globalThis', null) - jest.spyOn(global, 'self', 'get').mockReturnValue(new global.WorkerGlobalScope()) - - const globalScopeModule = await import('./global-scope') - const mockedGlobalSelf = global.self - disableWorkerScope() - - expect(globalScopeModule.isBrowserScope).toBe(false) - expect(globalScopeModule.isWorkerScope).toBe(true) - expect(globalScopeModule.globalScope).toBe(mockedGlobalSelf) -}) - -test('should throw an error when a scope cannot be defined', async () => { - jest.spyOn(global, 'window', 'get').mockReturnValue(undefined) - - await expect(() => import('./global-scope')).rejects.toThrow() -}) - -test('should immediately store the current location', async () => { - const url = faker.internet.url() - jest.spyOn(window, 'location', 'get').mockReturnValue(url) - - const globalScopeModule = await import('./global-scope') - - expect(globalScopeModule.initialLocation).toBe(url) -}) - -function enableWorkerScope () { - jest.spyOn(global, 'window', 'get').mockReturnValue(undefined) - - class WorkerNavigator {} - class WorkerGlobalScope { - navigator = new WorkerNavigator() - } - global.WorkerGlobalScope = WorkerGlobalScope - global.WorkerNavigator = WorkerNavigator - - jest.spyOn(global, 'navigator', 'get').mockReturnValue(new global.WorkerNavigator()) - jest.replaceProperty(global, 'globalThis', new WorkerGlobalScope()) -} - -function disableWorkerScope () { - delete global.WorkerGlobalScope - delete global.WorkerNavigator - - jest.restoreAllMocks() -} diff --git a/src/common/util/submit-data.js b/src/common/util/submit-data.js index e59e2163e..97bd733e8 100644 --- a/src/common/util/submit-data.js +++ b/src/common/util/submit-data.js @@ -1,51 +1,28 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 +/** + * @file Contains common methods used to transmit harvested data. + * @copyright 2023 New Relic Corporation. All rights reserved. + * @license Apache-2.0 */ -import { isWorkerScope } from './global-scope' -export const submitData = {} + +import { isBrowserScope, supportsSendBeacon } from '../constants/runtime' /** - * Send via JSONP. Do NOT call this function outside of a guaranteed web window environment. - * @param {Object} args - The args - * @param {string} args.url - The URL to send to - * @param {string} args.jsonp - The string name of the jsonp cb method - * @returns {XMLHttpRequest} - * @returns {Element} + * @typedef {xhr|fetchKeepAlive|beacon} NetworkMethods */ -submitData.jsonp = function jsonp ({ url, jsonp }) { - try { - if (isWorkerScope) { - try { - return importScripts(url + '&jsonp=' + jsonp) - } catch (e) { - // for now theres no other way to execute the callback from ingest without jsonp, or unsafe eval / new Function calls - // future work needs to be conducted to allow ingest to return a more traditional JSON API-like experience for the entitlement flags - submitData.xhrGet({ url: url + '&jsonp=' + jsonp }) - return false - } - } else { - var element = document.createElement('script') - element.type = 'text/javascript' - element.src = url + '&jsonp=' + jsonp - var firstScript = document.getElementsByTagName('script')[0] - firstScript.parentNode.insertBefore(element, firstScript) - return element - } - } catch (err) { - // do nothing - } -} /** - * Performs an asynchronous GET request using XMLHttpRequest. - * - * @param {Object} args - An object containing a `url` property. - * @param {string} args.url - The URL to send the GET request to. - * @returns {XMLHttpRequest} - An XMLHttpRequest object. + * Determines the submit method to use based on options. + * @param {object} opts Options used to determine submit method. + * @param {boolean} opts.isFinalHarvest Indicates if the data submission is due to + * a final harvest within the agent. */ -submitData.xhrGet = function xhrGet ({ url }) { - return submitData.xhr({ url, sync: false, method: 'GET' }) +export function getSubmitMethod ({ isFinalHarvest = false } = {}) { + return isFinalHarvest && isBrowserScope && supportsSendBeacon + // Use sendBeacon for final harvest + ? beacon + // Only IE does not support sendBeacon for final harvest + // If not final harvest, or not browserScope, always use xhr post + : xhr } /** @@ -58,8 +35,8 @@ submitData.xhrGet = function xhrGet ({ url }) { * @param {{key: string, value: string}[]} [args.headers] - The headers to attach. * @returns {XMLHttpRequest} */ -submitData.xhr = function xhr ({ url, body = null, sync, method = 'POST', headers = [{ key: 'content-type', value: 'text/plain' }] }) { - var request = new XMLHttpRequest() +export function xhr ({ url, body = null, sync, method = 'POST', headers = [{ key: 'content-type', value: 'text/plain' }] }) { + const request = new XMLHttpRequest() request.open(method, url, !sync) try { @@ -78,15 +55,24 @@ submitData.xhr = function xhr ({ url, body = null, sync, method = 'POST', header } /** - * Send by appending an element to the page. Do NOT call this function outside of a guaranteed web window environment. - * @param {Object} args - The args - * @param {string} args.url - The URL to send to - * @returns {HTMLImageElement} + * Send via fetch with keepalive true + * @param {Object} args - The args. + * @param {string} args.url - The URL to send to. + * @param {string=} args.body - The Stringified body. + * @param {string=} [args.method=POST] - The XHR method to use. + * @param {{key: string, value: string}[]} [args.headers] - The headers to attach. + * @returns {XMLHttpRequest} */ -submitData.img = function img ({ url }) { - var element = new Image() - element.src = url - return element +export function fetchKeepAlive ({ url, body = null, method = 'POST', headers = [{ key: 'content-type', value: 'text/plain' }] }) { + return fetch(url, { + method, + headers: headers.reduce((aggregator, header) => { + aggregator.push([header.key, header.value]) + return aggregator + }, []), + body, + keepalive: true + }) } /** @@ -96,7 +82,7 @@ submitData.img = function img ({ url }) { * @param {string=} args.body - The Stringified body * @returns {boolean} */ -submitData.beacon = function ({ url, body }) { +export function beacon ({ url, body }) { try { // Navigator has to be bound to ensure it does not error in some browsers // https://xgwang.me/posts/you-may-not-know-beacon/#it-may-throw-error%2C-be-sure-to-catch @@ -105,7 +91,7 @@ submitData.beacon = function ({ url, body }) { } catch (err) { // if sendBeacon still trys to throw an illegal invocation error, // we can catch here and return. The harvest module will fallback to use - // .img to try to send + // fetchKeepAlive to try to send return false } } diff --git a/src/common/util/submit-data.test.js b/src/common/util/submit-data.test.js index 057e41580..e28eef4d3 100644 --- a/src/common/util/submit-data.test.js +++ b/src/common/util/submit-data.test.js @@ -4,90 +4,58 @@ */ import { faker } from '@faker-js/faker' -import { submitData } from './submit-data' -import * as globalScope from './global-scope' +import * as runtimeModule from '../constants/runtime' +import * as submitData from './submit-data' -jest.mock('./global-scope') +jest.enableAutomock() +jest.unmock('./submit-data') const url = 'https://example.com/api' afterEach(() => { - jest.restoreAllMocks() + jest.clearAllMocks() }) -describe('submitData.jsonp', () => { - beforeEach(() => { - jest.replaceProperty(globalScope, 'isWorkerScope', false) - }) - - afterEach(() => { - delete global.importScripts - }) - - // This test requires a script tag to exist in the html set by this file's jest-environment-options header block. - test('should return an HTMLScriptElement when called from a web window environment', () => { - const jsonp = faker.datatype.uuid() - const result = submitData.jsonp({ url, jsonp }) - - expect(result).toBeInstanceOf(HTMLScriptElement) - expect(result.type).toBe('text/javascript') - expect(result.src).toBe(url + '&jsonp=' + jsonp) - }) - - test('should try to use importScripts when called from a worker scope', () => { - jest.replaceProperty(globalScope, 'isWorkerScope', true) - global.importScripts = jest.fn() - - const jsonp = faker.datatype.uuid() - submitData.jsonp({ url, jsonp }) +describe('getSubmitMethod', () => { + test('should use xhr for final harvest when isBrowserScope is false', () => { + jest.replaceProperty(runtimeModule, 'isBrowserScope', false) + jest.replaceProperty(runtimeModule, 'supportsSendBeacon', true) - expect(global.importScripts).toHaveBeenCalledWith(url + '&jsonp=' + jsonp) + expect(submitData.getSubmitMethod({ isFinalHarvest: true })).toEqual(submitData.xhr) }) - test('should fall back to an xhrGet call and return false when importScripts throws an error', () => { - jest.replaceProperty(globalScope, 'isWorkerScope', true) - jest.spyOn(submitData, 'xhrGet').mockImplementation(jest.fn()) - global.importScripts = jest.fn().mockImplementation(() => { throw new Error(faker.lorem.sentence()) }) + test('should use xhr for final harvest when supportsSendBeacon is false', () => { + jest.replaceProperty(runtimeModule, 'isBrowserScope', true) + jest.replaceProperty(runtimeModule, 'supportsSendBeacon', false) - const jsonp = faker.datatype.uuid() - const result = submitData.jsonp({ url, jsonp }) - - expect(result).toBe(false) - expect(global.importScripts).toHaveBeenCalledWith(url + '&jsonp=' + jsonp) - expect(submitData.xhrGet).toHaveBeenCalledWith({ url: url + '&jsonp=' + jsonp }) + expect(submitData.getSubmitMethod({ isFinalHarvest: true })).toEqual(submitData.xhr) }) - test('should not throw an error when xhrGet throws an error', () => { - jest.replaceProperty(globalScope, 'isWorkerScope', true) - jest.spyOn(submitData, 'xhrGet').mockImplementation(() => { throw new Error(faker.lorem.sentence()) }) - global.importScripts = jest.fn().mockImplementation(() => { throw new Error(faker.lorem.sentence()) }) - - const jsonp = faker.datatype.uuid() + test('should use beacon for final harvest when isBrowserScope and supportsSendBeacon is true', () => { + jest.replaceProperty(runtimeModule, 'isBrowserScope', true) + jest.replaceProperty(runtimeModule, 'supportsSendBeacon', true) - expect(() => submitData.jsonp({ url, jsonp })).not.toThrow() + expect(submitData.getSubmitMethod({ isFinalHarvest: true })).toEqual(submitData.beacon) }) - test('should not throw an error when element insertion fails', () => { - jest.spyOn(document, 'createElement').mockImplementation(() => { throw new Error(faker.lorem.sentence()) }) + test.each([ + null, undefined, false + ])('should use xhr when final harvest is %s', (isFinalHarvest) => { + jest.replaceProperty(runtimeModule, 'isBrowserScope', true) + jest.replaceProperty(runtimeModule, 'supportsSendBeacon', true) - const jsonp = faker.datatype.uuid() - - expect(() => submitData.jsonp({ url, jsonp })).not.toThrow() + expect(submitData.getSubmitMethod({ isFinalHarvest })).toEqual(submitData.xhr) }) -}) -describe('submitData.xhrGet', () => { - test('xhrGet should call xhr with GET as the method', () => { - jest.spyOn(submitData, 'xhr').mockReturnValue(new XMLHttpRequest()) + test('should use xhr when opts is undefined', () => { + jest.replaceProperty(runtimeModule, 'isBrowserScope', true) + jest.replaceProperty(runtimeModule, 'supportsSendBeacon', true) - const result = submitData.xhrGet({ url }) - - expect(result).toBeInstanceOf(XMLHttpRequest) - expect(submitData.xhr).toHaveBeenCalledWith({ url, sync: false, method: 'GET' }) + expect(submitData.getSubmitMethod()).toEqual(submitData.xhr) }) }) -describe('submitData.xhr', () => { +describe('xhr', () => { beforeEach(() => { jest.spyOn(global, 'XMLHttpRequest').mockImplementation(function () { this.prototype = XMLHttpRequest.prototype @@ -173,18 +141,63 @@ describe('submitData.xhr', () => { }) }) -describe('submitData.img', () => { - test('should return an HTMLImageElement', () => { - const imageUrl = 'https://example.com/image.png' +describe('fetchKeepAlive', () => { + beforeEach(() => { + global.fetch = jest.fn().mockReturnValue(Promise.resolve()) + }) + + afterEach(() => { + delete global.fetch + }) + + test('should make a fetch with default values', () => { + submitData.fetchKeepAlive({ url }) + + expect(global.fetch).toHaveBeenCalledWith(url, { + method: 'POST', + body: null, + keepalive: true, + headers: [['content-type', 'text/plain']] + }) + }) + + test('should send the body when provided', () => { + const body = faker.lorem.paragraph() + submitData.fetchKeepAlive({ url, body }) + + expect(global.fetch).toHaveBeenCalledWith(url, { + method: 'POST', + body, + keepalive: true, + headers: [['content-type', 'text/plain']] + }) + }) + + test('should use the provided method', () => { + submitData.fetchKeepAlive({ url, method: 'HEAD' }) + + expect(global.fetch).toHaveBeenCalledWith(url, { + method: 'HEAD', + body: null, + keepalive: true, + headers: [['content-type', 'text/plain']] + }) + }) - const result = submitData.img({ url: imageUrl }) + test('should use the provided headers', () => { + const headers = [{ key: faker.lorem.word(), value: faker.datatype.uuid() }] + submitData.fetchKeepAlive({ url, headers }) - expect(result).toBeInstanceOf(HTMLImageElement) - expect(result.src).toBe(imageUrl) + expect(global.fetch).toHaveBeenCalledWith(url, { + method: 'POST', + body: null, + keepalive: true, + headers: [[headers[0].key, headers[0].value]] + }) }) }) -describe('submitData.beacon', () => { +describe('beacon', () => { afterEach(() => { delete window.navigator.sendBeacon }) diff --git a/src/common/window/nreum.js b/src/common/window/nreum.js index 5684d7888..0699ce23e 100644 --- a/src/common/window/nreum.js +++ b/src/common/window/nreum.js @@ -1,5 +1,5 @@ import { now } from '../timing/now' -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' export const defaults = { beacon: 'bam.nr-data.net', diff --git a/src/common/wrap/wrap-events.js b/src/common/wrap/wrap-events.js index f9eff7db6..2c23cfae5 100644 --- a/src/common/wrap/wrap-events.js +++ b/src/common/wrap/wrap-events.js @@ -10,7 +10,7 @@ import { ee as baseEE } from '../event-emitter/contextual-ee' import { createWrapperWithEmitter as wfn } from './wrap-function' import { getOrSet } from '../util/get-or-set' -import { globalScope, isBrowserScope } from '../util/global-scope' +import { globalScope, isBrowserScope } from '../constants/runtime' const wrapped = {} const XHR = XMLHttpRequest diff --git a/src/common/wrap/wrap-fetch.js b/src/common/wrap/wrap-fetch.js index 96ed94d66..06c754a4f 100644 --- a/src/common/wrap/wrap-fetch.js +++ b/src/common/wrap/wrap-fetch.js @@ -7,7 +7,7 @@ * This module is used by: ajax, spa. */ import { ee as baseEE } from '../event-emitter/contextual-ee' -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' import { flag } from './wrap-function' var prefix = 'fetch-' diff --git a/src/common/wrap/wrap-history.js b/src/common/wrap/wrap-history.js index f14419e30..ddac681a6 100644 --- a/src/common/wrap/wrap-history.js +++ b/src/common/wrap/wrap-history.js @@ -8,7 +8,7 @@ */ import { ee as globalEE } from '../event-emitter/contextual-ee' import { createWrapperWithEmitter as wfn } from './wrap-function' -import { isBrowserScope } from '../util/global-scope' +import { isBrowserScope } from '../constants/runtime' const wrapped = {} const HISTORY_FNS = ['pushState', 'replaceState'] diff --git a/src/common/wrap/wrap-jsonp.js b/src/common/wrap/wrap-jsonp.js index c198235d4..b95f02a14 100644 --- a/src/common/wrap/wrap-jsonp.js +++ b/src/common/wrap/wrap-jsonp.js @@ -10,7 +10,7 @@ import { eventListenerOpts } from '../event-listener/event-listener-opts' import { ee as baseEE } from '../event-emitter/contextual-ee' import { createWrapperWithEmitter as wfn } from './wrap-function' -import { isBrowserScope } from '../util/global-scope' +import { isBrowserScope } from '../constants/runtime' const wrapped = {} const domInsertMethods = ['appendChild', 'insertBefore', 'replaceChild'] diff --git a/src/common/wrap/wrap-mutation.js b/src/common/wrap/wrap-mutation.js index 33b847130..e23703941 100644 --- a/src/common/wrap/wrap-mutation.js +++ b/src/common/wrap/wrap-mutation.js @@ -10,7 +10,7 @@ import { ee as baseEE } from '../event-emitter/contextual-ee' import { createWrapperWithEmitter as wfn } from './wrap-function' import { originals } from '../config/config' -import { isBrowserScope } from '../util/global-scope' +import { isBrowserScope } from '../constants/runtime' const wrapped = {} diff --git a/src/common/wrap/wrap-promise.js b/src/common/wrap/wrap-promise.js index b7fcd77bc..cddaee865 100644 --- a/src/common/wrap/wrap-promise.js +++ b/src/common/wrap/wrap-promise.js @@ -10,7 +10,7 @@ import { createWrapperWithEmitter as wrapFn, flag } from './wrap-function' import { ee as baseEE, getOrSetContext } from '../event-emitter/contextual-ee' import { originals } from '../config/config' -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' const wrapped = {} diff --git a/src/common/wrap/wrap-promise.test.js b/src/common/wrap/wrap-promise.test.js index 96dc88adb..c328dc069 100644 --- a/src/common/wrap/wrap-promise.test.js +++ b/src/common/wrap/wrap-promise.test.js @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker' -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' import { originals } from '../config/config' jest.mock('./wrap-function', () => ({ @@ -24,7 +24,7 @@ jest.mock('../config/config', () => ({ __esModule: true, originals: {} })) -jest.mock('../util/global-scope', () => ({ +jest.mock('../constants/runtime', () => ({ __esModule: true, globalScope: { NREUM: {} diff --git a/src/common/wrap/wrap-raf.js b/src/common/wrap/wrap-raf.js index e4dfb8abd..2e3204d3b 100644 --- a/src/common/wrap/wrap-raf.js +++ b/src/common/wrap/wrap-raf.js @@ -9,7 +9,7 @@ import { ee as baseEE } from '../event-emitter/contextual-ee' import { createWrapperWithEmitter as wfn } from './wrap-function' -import { isBrowserScope } from '../util/global-scope' +import { isBrowserScope } from '../constants/runtime' const wrapped = {} const RAF_NAME = 'requestAnimationFrame' diff --git a/src/common/wrap/wrap-timer.js b/src/common/wrap/wrap-timer.js index 7190440ed..b4b705ea0 100644 --- a/src/common/wrap/wrap-timer.js +++ b/src/common/wrap/wrap-timer.js @@ -9,7 +9,7 @@ import { ee as baseEE } from '../event-emitter/contextual-ee' import { createWrapperWithEmitter as wfn } from './wrap-function' -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' const wrapped = {} const SET_TIMEOUT = 'setTimeout' diff --git a/src/common/wrap/wrap-xhr.js b/src/common/wrap/wrap-xhr.js index 15e3de2b6..14b125933 100644 --- a/src/common/wrap/wrap-xhr.js +++ b/src/common/wrap/wrap-xhr.js @@ -12,7 +12,7 @@ import { ee as contextualEE } from '../event-emitter/contextual-ee' import { eventListenerOpts } from '../event-listener/event-listener-opts' import { createWrapperWithEmitter as wfn } from './wrap-function' import { originals } from '../config/config' -import { globalScope } from '../util/global-scope' +import { globalScope } from '../constants/runtime' import { warn } from '../util/console' const wrapped = {} diff --git a/src/features/ajax/aggregate/index.js b/src/features/ajax/aggregate/index.js index fb464b019..1b546f5e9 100644 --- a/src/features/ajax/aggregate/index.js +++ b/src/features/ajax/aggregate/index.js @@ -173,7 +173,7 @@ export class Aggregate extends AggregateBase { function onEventsHarvestFinished (result) { if (result.retry && sentAjaxEvents.length > 0 && allAjaxIsEnabled()) { - ajaxEvents = ajaxEvents.concat(sentAjaxEvents) + ajaxEvents.unshift(...sentAjaxEvents) sentAjaxEvents = [] } } diff --git a/src/features/ajax/instrument/distributed-tracing.js b/src/features/ajax/instrument/distributed-tracing.js index 4fc266ee0..7c9a8c0fc 100644 --- a/src/features/ajax/instrument/distributed-tracing.js +++ b/src/features/ajax/instrument/distributed-tracing.js @@ -5,7 +5,7 @@ import { getConfiguration, getConfigurationValue, getLoaderConfig } from '../../../common/config/config' import { generateSpanId, generateTraceId } from '../../../common/ids/unique-id' import { parseUrl } from '../../../common/url/parse-url' -import { globalScope } from '../../../common/util/global-scope' +import { globalScope } from '../../../common/constants/runtime' import { stringify } from '../../../common/util/stringify' export class DT { diff --git a/src/features/ajax/instrument/index.js b/src/features/ajax/instrument/index.js index ba46e044b..9755ff043 100644 --- a/src/features/ajax/instrument/index.js +++ b/src/features/ajax/instrument/index.js @@ -5,7 +5,7 @@ import { originals, getLoaderConfig, getRuntime } from '../../../common/config/config' import { handle } from '../../../common/event-emitter/handle' import { id } from '../../../common/ids/id' -import { ffVersion } from '../../../common/browser-version/firefox-version' +import { ffVersion, globalScope } from '../../../common/constants/runtime' import { dataSize } from '../../../common/util/data-size' import { eventListenerOpts } from '../../../common/event-listener/event-listener-opts' import { now } from '../../../common/timing/now' @@ -16,7 +16,6 @@ import { responseSizeFromXhr } from './response-size' import { InstrumentBase } from '../../utils/instrument-base' import { FEATURE_NAME } from '../constants' import { FEATURE_NAMES } from '../../../loaders/features/features' -import { globalScope } from '../../../common/util/global-scope' var handlers = ['load', 'error', 'abort', 'timeout'] var handlersLen = handlers.length diff --git a/src/features/jserrors/aggregate/compute-stack-trace.test.js b/src/features/jserrors/aggregate/compute-stack-trace.test.js index 839834b05..9b2ba8773 100644 --- a/src/features/jserrors/aggregate/compute-stack-trace.test.js +++ b/src/features/jserrors/aggregate/compute-stack-trace.test.js @@ -4,7 +4,7 @@ import { browserErrorUtils } from '../../../../tools/testing-utils' const globalScopeLocation = 'https://example.com/' const mockGlobalScopeLocation = (url) => { - jest.doMock('../../../common/util/global-scope', () => ({ + jest.doMock('../../../common/constants/runtime', () => ({ initialLocation: url || globalScopeLocation })) } diff --git a/src/features/jserrors/aggregate/index.js b/src/features/jserrors/aggregate/index.js index bb42641ea..dbb6cc9f8 100644 --- a/src/features/jserrors/aggregate/index.js +++ b/src/features/jserrors/aggregate/index.js @@ -15,7 +15,7 @@ import { handle } from '../../../common/event-emitter/handle' import { mapOwn } from '../../../common/util/map-own' import { getInfo, getConfigurationValue, getRuntime } from '../../../common/config/config' import { now } from '../../../common/timing/now' -import { globalScope } from '../../../common/util/global-scope' +import { globalScope } from '../../../common/constants/runtime' import { FEATURE_NAME } from '../constants' import { drain } from '../../../common/drain/drain' @@ -93,7 +93,7 @@ export class Aggregate extends AggregateBase { mapOwn(this.currentBody, (key, value) => { for (var i = 0; i < value.length; i++) { var bucket = value[i] - var name = this.getBucketName(bucket.params, bucket.custom) + var name = this.getBucketName(key, bucket.params, bucket.custom) this.aggregator.merge(key, name, bucket.metrics, bucket.params, bucket.custom) } }) @@ -105,7 +105,11 @@ export class Aggregate extends AggregateBase { return stringHashCode(`${params.exceptionClass}_${params.message}_${params.stack_trace || params.browser_stack_hash}`) } - getBucketName (params, customParams) { + getBucketName (objType, params, customParams) { + if (objType === 'xhr') { + return stringHashCode(stringify(params)) + ':' + stringHashCode(stringify(customParams)) + } + return this.nameHash(params) + ':' + stringHashCode(stringify(customParams)) } diff --git a/src/features/jserrors/instrument/index.js b/src/features/jserrors/instrument/index.js index ff0e64635..05a104209 100644 --- a/src/features/jserrors/instrument/index.js +++ b/src/features/jserrors/instrument/index.js @@ -11,7 +11,7 @@ import './debug' import { InstrumentBase } from '../../utils/instrument-base' import { FEATURE_NAME, NR_ERR_PROP } from '../constants' import { FEATURE_NAMES } from '../../../loaders/features/features' -import { globalScope } from '../../../common/util/global-scope' +import { globalScope } from '../../../common/constants/runtime' import { eventListenerOpts } from '../../../common/event-listener/event-listener-opts' import { getRuntime } from '../../../common/config/config' import { stringify } from '../../../common/util/stringify' diff --git a/src/features/metrics/aggregate/framework-detection.js b/src/features/metrics/aggregate/framework-detection.js index cdabf5da2..e2e969c2f 100644 --- a/src/features/metrics/aggregate/framework-detection.js +++ b/src/features/metrics/aggregate/framework-detection.js @@ -1,4 +1,4 @@ -import { isBrowserScope } from '../../../common/util/global-scope' +import { isBrowserScope } from '../../../common/constants/runtime' const FRAMEWORKS = { REACT: 'React', diff --git a/src/features/metrics/aggregate/framework-detection.test.js b/src/features/metrics/aggregate/framework-detection.test.js index 6941b6a2f..58007deb8 100644 --- a/src/features/metrics/aggregate/framework-detection.test.js +++ b/src/features/metrics/aggregate/framework-detection.test.js @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker' import { getFrameworks } from './framework-detection' -jest.mock('../../../common/util/global-scope', () => ({ +jest.mock('../../../common/constants/runtime', () => ({ isBrowserScope: true })) @@ -13,7 +13,7 @@ test('framework detection should not happen in non-browser scope', async () => { global.React = {} jest.resetModules() - jest.doMock('../../../common/util/global-scope', () => ({ + jest.doMock('../../../common/constants/runtime', () => ({ isBrowserScope: false })) const frameworkDetector = await import('./framework-detection') diff --git a/src/features/metrics/aggregate/index.js b/src/features/metrics/aggregate/index.js index f5b49959d..eaf9da8e9 100644 --- a/src/features/metrics/aggregate/index.js +++ b/src/features/metrics/aggregate/index.js @@ -10,7 +10,7 @@ import { getRules, validateRules } from '../../../common/util/obfuscate' import { VERSION } from '../../../common/constants/env' import { onDOMContentLoaded } from '../../../common/window/load' import { windowAddEventListener } from '../../../common/event-listener/event-listener-opts' -import { isBrowserScope } from '../../../common/util/global-scope' +import { isBrowserScope } from '../../../common/constants/runtime' import { AggregateBase } from '../../utils/aggregate-base' import { stringify } from '../../../common/util/stringify' import { endpointMap } from './endpoint-map' diff --git a/src/features/page_action/aggregate/index.js b/src/features/page_action/aggregate/index.js index c07195592..e60e2304f 100644 --- a/src/features/page_action/aggregate/index.js +++ b/src/features/page_action/aggregate/index.js @@ -11,7 +11,7 @@ import { cleanURL } from '../../../common/url/clean-url' import { getConfigurationValue, getInfo, getRuntime } from '../../../common/config/config' import { FEATURE_NAME } from '../constants' import { drain } from '../../../common/drain/drain' -import { isBrowserScope } from '../../../common/util/global-scope' +import { isBrowserScope } from '../../../common/constants/runtime' import { AggregateBase } from '../../utils/aggregate-base' export class Aggregate extends AggregateBase { diff --git a/src/features/page_view_event/aggregate/index.js b/src/features/page_view_event/aggregate/index.js index 7a4d2e364..fa479ed0f 100644 --- a/src/features/page_view_event/aggregate/index.js +++ b/src/features/page_view_event/aggregate/index.js @@ -1,6 +1,6 @@ import { handle } from '../../../common/event-emitter/handle' import { FEATURE_NAMES } from '../../../loaders/features/features' -import { isiOS } from '../../../common/browser-version/ios-version' +import { isiOS, globalScope, isBrowserScope } from '../../../common/constants/runtime' import { onTTFB } from 'web-vitals' import { addPT, addPN } from '../../../common/timing/nav-timing' import { stringify } from '../../../common/util/stringify' @@ -9,7 +9,6 @@ import { getConfigurationValue, getInfo, getRuntime } from '../../../common/conf import { Harvest } from '../../../common/harvest/harvest' import * as CONSTANTS from '../constants' import { getActivatedFeaturesFlags } from './initialized-features' -import { globalScope, isBrowserScope } from '../../../common/util/global-scope' import { drain } from '../../../common/drain/drain' import { activateFeatures } from '../../../common/util/feature-flags' import { warn } from '../../../common/util/console' diff --git a/src/features/page_view_event/instrument/index.js b/src/features/page_view_event/instrument/index.js index ff3179ae6..618a8f1a8 100644 --- a/src/features/page_view_event/instrument/index.js +++ b/src/features/page_view_event/instrument/index.js @@ -1,5 +1,5 @@ import { handle } from '../../../common/event-emitter/handle' -import { isiOS } from '../../../common/browser-version/ios-version' +import { isiOS } from '../../../common/constants/runtime' import { InstrumentBase } from '../../utils/instrument-base' import * as CONSTANTS from '../constants' import { FEATURE_NAMES } from '../../../loaders/features/features' diff --git a/src/features/page_view_timing/aggregate/index.js b/src/features/page_view_timing/aggregate/index.js index a30585064..238b25155 100644 --- a/src/features/page_view_timing/aggregate/index.js +++ b/src/features/page_view_timing/aggregate/index.js @@ -6,7 +6,7 @@ import { onFCP, onFID, onLCP, onCLS, onINP } from 'web-vitals' import { onFirstPaint } from '../first-paint' import { onLongTask } from '../long-tasks' -import { iOS_below16 } from '../../../common/browser-version/ios-version' +import { iOS_below16 } from '../../../common/constants/runtime' import { nullable, numeric, getAddStringContext, addCustomAttributes } from '../../../common/serialize/bel-serializer' import { mapOwn } from '../../../common/util/map-own' import { HarvestScheduler } from '../../../common/harvest/harvest-scheduler' @@ -195,9 +195,7 @@ export class Aggregate extends AggregateBase { onHarvestFinished (result) { if (result.retry && this.timingsSent.length > 0) { - for (var i = 0; i < this.timingsSent.length; i++) { - this.timings.push(this.timingsSent[i]) - } + this.timings.unshift(...this.timingsSent) this.timingsSent = [] } } diff --git a/src/features/page_view_timing/instrument/index.js b/src/features/page_view_timing/instrument/index.js index 94e6ed4d1..1231f6bda 100644 --- a/src/features/page_view_timing/instrument/index.js +++ b/src/features/page_view_timing/instrument/index.js @@ -9,7 +9,7 @@ import { windowAddEventListener } from '../../../common/event-listener/event-lis import { now } from '../../../common/timing/now' import { InstrumentBase } from '../../utils/instrument-base' import { FEATURE_NAME } from '../constants' -import { isBrowserScope } from '../../../common/util/global-scope' +import { isBrowserScope } from '../../../common/constants/runtime' export class Instrument extends InstrumentBase { static featureName = FEATURE_NAME diff --git a/src/features/session_trace/instrument/index.js b/src/features/session_trace/instrument/index.js index 501ca62b2..3f72852df 100644 --- a/src/features/session_trace/instrument/index.js +++ b/src/features/session_trace/instrument/index.js @@ -8,7 +8,7 @@ import { now } from '../../../common/timing/now' import { InstrumentBase } from '../../utils/instrument-base' import * as CONSTANTS from '../constants' import { FEATURE_NAMES } from '../../../loaders/features/features' -import { isBrowserScope } from '../../../common/util/global-scope' +import { isBrowserScope } from '../../../common/constants/runtime' const { BST_RESOURCE, RESOURCE, START, END, FEATURE_NAME, FN_END, FN_START, PUSH_STATE diff --git a/src/features/spa/aggregate/index.js b/src/features/spa/aggregate/index.js index f59421355..21084f903 100644 --- a/src/features/spa/aggregate/index.js +++ b/src/features/spa/aggregate/index.js @@ -674,7 +674,7 @@ export class Aggregate extends AggregateBase { function onHarvestFinished (result) { if (result.sent && result.retry && state.interactionsSent.length > 0) { state.interactionsSent.forEach(function (interaction) { - state.interactionsToHarvest.push(interaction) + state.interactionsToHarvest.unshift(interaction) }) state.interactionsSent = [] } diff --git a/src/features/spa/instrument/index.js b/src/features/spa/instrument/index.js index b7500a518..4594fef2a 100644 --- a/src/features/spa/instrument/index.js +++ b/src/features/spa/instrument/index.js @@ -10,7 +10,7 @@ import { InstrumentBase } from '../../utils/instrument-base' import { getRuntime } from '../../../common/config/config' import { now } from '../../../common/timing/now' import * as CONSTANTS from '../constants' -import { isBrowserScope } from '../../../common/util/global-scope' +import { isBrowserScope } from '../../../common/constants/runtime' const { FEATURE_NAME, START, END, BODY, CB_END, JS_TIME, FETCH, FN_START, CB_START, FN_END diff --git a/src/features/utils/agent-session.test.js b/src/features/utils/agent-session.test.js index 3188965e3..a74c95520 100644 --- a/src/features/utils/agent-session.test.js +++ b/src/features/utils/agent-session.test.js @@ -35,7 +35,7 @@ beforeEach(() => { __esModule: true, registerHandler: jest.fn() })) - jest.doMock('../../common/util/global-scope', () => ({ + jest.doMock('../../common/constants/runtime', () => ({ __esModule: true, isBrowserScope: true })) @@ -161,7 +161,7 @@ test('should set custom session data', async () => { }) test('should not set custom session data in worker scope', async () => { - const globalScope = await import('../../common/util/global-scope') + const globalScope = await import('../../common/constants/runtime') jest.replaceProperty(globalScope, 'isBrowserScope', false) const { setInfo } = await import('../../common/config/config') diff --git a/src/features/utils/instrument-base.js b/src/features/utils/instrument-base.js index a8f9962df..938f344fc 100644 --- a/src/features/utils/instrument-base.js +++ b/src/features/utils/instrument-base.js @@ -7,7 +7,7 @@ import { drain, registerDrain } from '../../common/drain/drain' import { FeatureBase } from './feature-base' import { onWindowLoad } from '../../common/window/load' -import { isBrowserScope } from '../../common/util/global-scope' +import { isBrowserScope } from '../../common/constants/runtime' import { warn } from '../../common/util/console' import { FEATURE_NAMES } from '../../loaders/features/features' import { getConfigurationValue } from '../../common/config/config' diff --git a/src/features/utils/instrument-base.test.js b/src/features/utils/instrument-base.test.js index d0607e8b0..40e0c6cd7 100644 --- a/src/features/utils/instrument-base.test.js +++ b/src/features/utils/instrument-base.test.js @@ -7,7 +7,7 @@ import { lazyFeatureLoader } from './lazy-feature-loader' import { getConfigurationValue } from '../../common/config/config' import { setupAgentSession } from './agent-session' import { warn } from '../../common/util/console' -import * as globalScopeModule from '../../common/util/global-scope' +import * as globalScopeModule from '../../common/constants/runtime' import { FEATURE_NAMES } from '../../loaders/features/features' jest.enableAutomock() @@ -22,7 +22,7 @@ jest.mock('../../common/window/load', () => ({ __esModule: true, onWindowLoad: jest.fn() })) -jest.mock('../../common/util/global-scope', () => ({ +jest.mock('../../common/constants/runtime', () => ({ __esModule: true, isBrowserScope: undefined, isWorkerScope: undefined diff --git a/src/loaders/agent.js b/src/loaders/agent.js index d834a9132..586dbca70 100644 --- a/src/loaders/agent.js +++ b/src/loaders/agent.js @@ -12,6 +12,7 @@ import { generateRandomHexString } from '../common/ids/unique-id' import { getConfiguration, getInfo, getLoaderConfig, getRuntime } from '../common/config/config' import { warn } from '../common/util/console' import { stringify } from '../common/util/stringify' +import { globalScope } from '../common/constants/runtime' /** * A flexible class that may be used to compose an agent from a select subset of feature modules. In applications @@ -19,6 +20,13 @@ import { stringify } from '../common/util/stringify' */ export class Agent { constructor (options, agentIdentifier = generateRandomHexString(16)) { + if (!globalScope) { + // We could not determine the runtime environment. Short-circuite the agent here + // to avoid possible exceptions later that may cause issues with customer's application. + warn('Failed to initial the agent. Could not determine the runtime environment.') + return + } + this.agentIdentifier = agentIdentifier this.sharedAggregator = new Aggregator({ agentIdentifier: this.agentIdentifier }) this.features = {} diff --git a/src/loaders/api/api.js b/src/loaders/api/api.js index 6e4b66bb2..b62744410 100644 --- a/src/loaders/api/api.js +++ b/src/loaders/api/api.js @@ -9,7 +9,7 @@ import { ee } from '../../common/event-emitter/contextual-ee' import { now } from '../../common/timing/now' import { drain, registerDrain } from '../../common/drain/drain' import { onWindowLoad } from '../../common/window/load' -import { isBrowserScope } from '../../common/util/global-scope' +import { isBrowserScope } from '../../common/constants/runtime' import { warn } from '../../common/util/console' import { SUPPORTABILITY_METRIC_CHANNEL } from '../../features/metrics/constants' import { gosCDN } from '../../common/window/nreum' diff --git a/src/loaders/api/apiAsync.js b/src/loaders/api/apiAsync.js index cffa6f13b..9fc842d9e 100644 --- a/src/loaders/api/apiAsync.js +++ b/src/loaders/api/apiAsync.js @@ -4,8 +4,8 @@ import { ee } from '../../common/event-emitter/contextual-ee' import { handle } from '../../common/event-emitter/handle' import { registerHandler } from '../../common/event-emitter/register-handler' import { single } from '../../common/util/invoke' -import { submitData } from '../../common/util/submit-data' -import { isBrowserScope } from '../../common/util/global-scope' +import * as submitData from '../../common/util/submit-data' +import { isBrowserScope } from '../../common/constants/runtime' import { CUSTOM_METRIC_CHANNEL } from '../../features/metrics/constants' export function setAPI (agentIdentifier) { @@ -78,7 +78,7 @@ export function setAPI (agentIdentifier) { url += 'fe=' + ~~fe_time + '&' url += 'c=' + cycle - submitData.img({ url }) + submitData.xhr({ url }) } function setErrorHandler (t, handler) { diff --git a/src/loaders/configure/configure.js b/src/loaders/configure/configure.js index 9f435f402..7625f4e46 100644 --- a/src/loaders/configure/configure.js +++ b/src/loaders/configure/configure.js @@ -2,7 +2,7 @@ import { setAPI, setTopLevelCallers } from '../api/api' import { addToNREUM, gosCDN, gosNREUMInitializedAgents } from '../../common/window/nreum' import { setConfiguration, setInfo, setLoaderConfig, setRuntime } from '../../common/config/config' import { activateFeatures, activatedFeatures } from '../../common/util/feature-flags' -import { isWorkerScope } from '../../common/util/global-scope' +import { isWorkerScope } from '../../common/constants/runtime' export function configure (agentIdentifier, opts = {}, loaderType, forceDrain) { let { init, info, loader_config, runtime = { loaderType }, exposed = true } = opts diff --git a/tests/browser/err/zz-error.browser.js b/tests/browser/err/zz-error.browser.js index 3b30bcd8d..138290509 100644 --- a/tests/browser/err/zz-error.browser.js +++ b/tests/browser/err/zz-error.browser.js @@ -7,7 +7,7 @@ // Name prefixed with zz- to be the last file // included in the unit test bundle. import test from '../../../tools/jil/browser-test' -import { ffVersion } from '../../../src/common/browser-version/firefox-version' +import { ffVersion } from '../../../src/common/constants/runtime' import { windowAddEventListener } from '../../../src/common/event-listener/event-listener-opts' import { setup } from '../utils/setup' // Should be loaded first diff --git a/tests/browser/harvest-scheduler.browser.js b/tests/browser/harvest-scheduler.browser.js deleted file mode 100644 index 23b4af93e..000000000 --- a/tests/browser/harvest-scheduler.browser.js +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import test from '../../tools/jil/browser-test' -import { setup } from './utils/setup' -import { setRuntime, setInfo } from '../../src/common/config/config' -import * as sinon from 'sinon' -import * as harv from '../../src/common/harvest/harvest' -import { submitData } from '../../src/common/util/submit-data' -import { HarvestScheduler } from '../../src/common/harvest/harvest-scheduler' - -const { agentIdentifier, aggregator } = setup() -const nrInfo = { errorBeacon: 'foo', licenseKey: 'bar' } -const nrOrigin = 'http://foo.com?bar=crunchy#bacon'; -(() => { - setInfo(agentIdentifier, nrInfo) - setRuntime(agentIdentifier, { origin: nrOrigin }) -})() - -function resetSpies (options) { - options = options || {} - - if (harv.Harvest.prototype.send.isSinonProxy) { - harv.Harvest.prototype.send.restore() - } - if (harv.Harvest.prototype.sendX.isSinonProxy) { - harv.Harvest.prototype.sendX.restore() - } - if (harv.getSubmitMethod.isSinonProxy) { - harv.getSubmitMethod.restore() - } - if (HarvestScheduler.prototype.scheduleHarvest.isSinonProxy) { - HarvestScheduler.prototype.scheduleHarvest.restore() - } - - sinon.stub(harv.Harvest.prototype, 'send', fakeSend) - sinon.stub(harv.Harvest.prototype, 'sendX', fakeSendX) - sinon.stub(harv, 'getSubmitMethod').callsFake(fakeGetSubmitMethod) - - function fakeSend ({endpoint, payload, opts, submitMethod, cbFinished}) { - setTimeout(function () { - var response = options.response || { sent: true } - cbFinished(response) - }, 0) - } - - function fakeSendX ({endpoint, opts, cbFinished}) { - setTimeout(function () { - var response = options.response || { sent: true } - cbFinished(response) - }, 0) - } - - function fakeGetSubmitMethod () { - return { - method: options.submitMethod || submitData.beacon - } - } -} - -test('after calling startTimer, periodically invokes harvest', function (t) { - resetSpies() - var calls = 0 - - var scheduler = new HarvestScheduler('endpoint', { onFinished: onFinished, getPayload: getPayload }, aggregator.sharedContext) - scheduler.startTimer(0.1) - - function getPayload () { - return { body: {} } - } - - function onFinished () { - calls++ - if (calls > 1) { - scheduler.stopTimer() - validate() - } - } - - function validate () { - t.equal(harv.Harvest.prototype.send.callCount, 2, 'harvest was initiated more than once') - t.end() - } -}) - -test('scheduleHarvest invokes harvest once', function (t) { - resetSpies() - - var scheduler = new HarvestScheduler('endpoint', { getPayload: getPayload }, aggregator.sharedContext) - scheduler.scheduleHarvest(0.1) - - function getPayload () { - return { body: {} } - } - - setTimeout(validate, 1000) - - function validate () { - t.equal(harv.Harvest.prototype.send.callCount, 1, 'harvest was initiated once') - t.end() - } -}) - -test('when getPayload is provided, calls harvest.send', function (t) { - resetSpies() - var scheduler = new HarvestScheduler('endpoint', { onFinished: onFinished, getPayload: getPayload }, aggregator.sharedContext) - scheduler.startTimer(0.1) - - function getPayload () { - return { body: {} } - } - - function onFinished () { - scheduler.stopTimer() - t.ok(harv.Harvest.prototype.send.called, 'harvest.send was called') - t.notOk(harv.Harvest.prototype.sendX.called, 'harvest.sendX was not called') - t.end() - } -}) - -test('when getPayload is not provided, calls harvest.sendX', function (t) { - resetSpies() - var scheduler = new HarvestScheduler('endpoint', { onFinished: onFinished }, aggregator.sharedContext) - scheduler.startTimer(0.1) - - function onFinished () { - scheduler.stopTimer() - t.notOk(harv.Harvest.prototype.send.called, 'harvest.send was not called') - t.ok(harv.Harvest.prototype.sendX.called, 'harvest.sendX was called') - t.end() - } -}) - -test('does not call harvest.send when payload is null', function (t) { - resetSpies() - var scheduler = new HarvestScheduler('endpoint', { getPayload: getPayload }, aggregator.sharedContext) - scheduler.startTimer(0.1) - - function getPayload () { - setTimeout(validate, 0) - return null - } - - function validate () { - scheduler.stopTimer() - t.notOk(harv.Harvest.prototype.send.called, 'harvest.send was not called') - t.notOk(harv.Harvest.prototype.sendX.called, 'harvest.sendX was not called') - t.end() - } -}) - -test('provides retry to getPayload when submit method is xhr', function (t) { - resetSpies({ submitMethod: submitData.xhr }) - - var scheduler = new HarvestScheduler('endpoint', { getPayload: getPayload }, aggregator.sharedContext) - scheduler.startTimer(0.1) - - function getPayload (opts) { - scheduler.stopTimer() - setTimeout(function () { - var call = harv.Harvest.prototype.send.getCall(0) - t.equal(call.args[0].submitMethod.method, submitData.xhr, 'method was xhr') - t.ok(opts.retry, 'retry was set to true') - t.end() - }, 0) - return { body: {} } - } -}) - -test('when retrying, uses delay provided by harvest response', function (t) { - resetSpies({ - response: { sent: true, retry: true, delay: 0.2 } - }) - sinon.spy(HarvestScheduler.prototype, 'scheduleHarvest') - - var scheduler = new HarvestScheduler('endpoint', { onFinished: onFinished, getPayload: getPayload }, aggregator.sharedContext) - scheduler.scheduleHarvest(0.1) - - var count = 0 - function getPayload () { - return { body: {} } - } - - function onFinished (result) { - count++ - if (count > 1) { - scheduler.stopTimer() - validate() - } - } - - function validate () { - t.equal(HarvestScheduler.prototype.scheduleHarvest.callCount, 2) - var call = HarvestScheduler.prototype.scheduleHarvest.getCall(0) - t.equal(call.args[0], 0.1) - call = HarvestScheduler.prototype.scheduleHarvest.getCall(1) - t.equal(call.args[0], 0.2) - t.end() - } -}) diff --git a/tests/browser/harvest.browser.js b/tests/browser/harvest.browser.js deleted file mode 100644 index 4047e5333..000000000 --- a/tests/browser/harvest.browser.js +++ /dev/null @@ -1,713 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import test from '../../tools/jil/browser-test' -import { setup } from './utils/setup' -import { getRuntime, setRuntime, setInfo } from '../../src/common/config/config' -import { submitData } from '../../src/common/util/submit-data' -import * as harvest from '../../src/common/harvest/harvest' -import * as sinon from 'sinon' -import * as encode from '../../src/common/url/encode' -import * as locationUtil from '../../src/common/url/location' -import { stringify } from '../../src/common/util/stringify' -import { VERSION } from '../../src/common/constants/env' - -const { agentIdentifier } = setup() -const harvesterInst = new harvest.Harvest({ agentIdentifier }) -const scheme = harvesterInst.getScheme() -const agentRuntime = getRuntime(agentIdentifier) -const nrInfo = { errorBeacon: 'foo', licenseKey: 'bar' } -const nrFeatures = { err: true, xhr: true } -const nrOrigin = scheme + '://foo.com?bar=crunchy#bacon' // default origin -const setupFakeNr = (tArgs) => { - setInfo(agentIdentifier, nrInfo) - setRuntime(agentIdentifier, { features: nrFeatures, origin: tArgs?.origin || nrOrigin, ptid: tArgs?.ptid || undefined }) -} - -const hasSendBeacon = !!navigator.sendBeacon -const xhrWrappable = agentRuntime.xhrWrappable - -function once (cb) { - var done = false - return function () { - if (done) return {} - done = true - return cb() - } -} - -function createMockedXhr (responseCode) { - var loadListeners = [] - return { - status: parseInt(responseCode), - addEventListener: function (event, fn) { - loadListeners.push(fn) - }, - send: function () { - var xhr = this - setTimeout(function () { - loadListeners.forEach(function (fn) { - fn.call(xhr) - }) - }, 0) - } - } -} - -function resetSpies (origin, options) { - harvesterInst.resetListeners() - options = options || {} - submitData.img = sinon.stub().returns(true) - submitData.beacon = sinon.stub().returns(true) - - if (submitData.xhr.isSinonProxy) { - submitData.xhr.restore() - } - sinon.stub(submitData, 'xhr', function () { - var mockedXhr = createMockedXhr(options.xhrResponseCode) - if (options.xhrWithLoadEvent) { - mockedXhr.send() - } - return mockedXhr - }) - - origin = origin || scheme + '://foo.com?bar=crunchy#bacon' - locationUtil.getLocation = sinon.stub().returns(origin) -} - -function dummyPayload (key) { - return function () { - var body = {} - body[key] = ['one', 'two', 'three'] - return { - qs: { q1: 'v1', q2: 'v2' }, - body: body - } - } -} - -function validateUrl (t, args, expectedUrlTemplate, message) { - // Extract the timestamp from the actual URL - // Can't use url.parse because this test has to run in old IE, which chokes - // when trying to use the browserified version of url.parse. - // Also get the ck parameter value from the actual URL - const actualUrl = args.url - let queryString = actualUrl.split('?')[1] - let pairs = queryString.split('&') - let submissionTimestamp, sessionId - let ckValue = '1' - for (var i = 0; i < pairs.length; i++) { - let pair = pairs[i].split('=') - if (pair[0] === 'rst') { - submissionTimestamp = pair[1] - } else if (pair[0] === 'ck') { - ckValue = pair[1] - } else if (pair[0] === 's') { - sessionId = pair[1] - } - } - - // In addition to replacing timestamp, add in the ck parameter which goes after timestamp - let expectedUrl = expectedUrlTemplate.replace('{TIMESTAMP}', `${submissionTimestamp}&ck=${ckValue}&s=${sessionId}`) - - t.equal(actualUrl, expectedUrl, message) -} - -test('returns false if nothing is sent', function (t) { - resetSpies() - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'ins'}) - t.notOk(result, 'sendX returns a falsy value when nothing was sent') - t.end() -}) - -test('encodes only the origin of the referrer url, not the fragment ', function (t) { - resetSpies() - harvesterInst.on('ins', once(dummyPayload('ins'))) - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'ins'}) - let baseUrl = scheme + '://foo/ins/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com&q1=v1&q2=v2' - - if (xhrWrappable) { - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to sendBeacon') - } else { - t.notOk(result, 'result falsy when attempting to submit ins in unsupported browser') - } - - t.end() -}) - -test('encodes referrer urls that include spaces', function (t) { - let testOriginWithSpace = scheme + '://foo.com%20crunchy%20bacon' - resetSpies(testOriginWithSpace) - - harvesterInst.on('ins', once(dummyPayload('ins'))) - setupFakeNr({ origin: testOriginWithSpace }) - let result = harvesterInst.sendX({endpoint: 'ins'}) - let baseUrl = scheme + '://foo/ins/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com%2520crunchy%2520bacon&q1=v1&q2=v2' - - if (xhrWrappable) { - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to sendBeacon') - } else { - t.notOk(result, 'result falsy when attempting to submit ins in unsupported browser') - } - - t.end() -}) - -test('encodes referrer urls that include ampersands', function (t) { - let testOriginWithSpace = scheme + '://foo.com&crunchy&bacon' - resetSpies(testOriginWithSpace) - - harvesterInst.on('ins', once(dummyPayload('ins'))) - setupFakeNr({ origin: testOriginWithSpace }) - let result = harvesterInst.sendX({endpoint: 'ins'}) - let baseUrl = scheme + '://foo/ins/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com%26crunchy%26bacon&q1=v1&q2=v2' - - if (xhrWrappable) { - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to sendBeacon') - } else { - t.notOk(result, 'result falsy when attempting to submit ins in unsupported browser') - } - - t.end() -}) - -test('uses correct submission mechanism for ins', function (t) { - if (xhrWrappable) { - t.plan(4) - } else { - t.plan(2) - } - - resetSpies(null, { xhrWithLoadEvent: true }) - harvesterInst.on('ins', once(dummyPayload('ins'))) - - function harvestFinished () { - t.pass('harvest finished callback has been called') - } - - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'ins', cbFinished: harvestFinished}) - let baseUrl = scheme + '://foo/ins/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com&q1=v1&q2=v2' - let expectedPayload = { ins: ['one', 'two', 'three'] } - - if (xhrWrappable) { - t.ok(result, 'result truthy when ins submitted via XHR') - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to sendBeacon') - t.equal(call.args[0].body, stringify(expectedPayload), 'correct body given to XHR') /// <== - } else { - t.notOk(result, 'result falsy when attempting to submit ins in unsupported browser') - t.equal(submitData.img.callCount, 0, 'should not try to submit ins via img tag') - } -}) - -test('does not send ins call when there is no body', function (t) { - resetSpies() - harvesterInst.on('ins', once(testPayload)) - - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'ins'}) - - t.notOk(result, 'result should be falsy') - t.equal(submitData.xhr.callCount, 0, 'no xhr call should have been made') - t.equal(submitData.img.callCount, 0, 'no call via img tag should have been made') - t.equal(submitData.beacon.callCount, 0, 'no beacon call should have been made') - t.end() - - function testPayload () { - return { - qs: { q1: 'v1', q2: 'v2' }, - body: { - ins: [] - } - } - } -}) - -test('uses correct submission mechanism for resources', function (t) { - if (xhrWrappable) { - t.plan(4) - } else { - t.plan(2) - } - - resetSpies(null, { xhrWithLoadEvent: true }) - harvesterInst.on('resources', once(dummyPayload('resources'))) - - function harvestFinished () { - t.pass('harvest finished callback has been called') - } - - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'resources', cbFinished: harvestFinished}) - - let baseUrl = scheme + '://foo/resources/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com&q1=v1&q2=v2' - let expectedPayload = { resources: ['one', 'two', 'three'] } - - if (xhrWrappable) { - t.ok(result, 'result truthy when resources submitted via XHR') - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to XHR') - t.equal(call.args[0].body, stringify(expectedPayload), 'correct body given to XHR') - } else { - t.notOk(result, 'result falsy when attempting to submit resources in unsupported browser') - t.equal(submitData.img.callCount, 0, 'should not try to submit via img') - } -}) - -test('does not send resources when there is no body', function (t) { - resetSpies() - harvesterInst.on('resources', once(testPayload)) - - setupFakeNr() - let result = harvesterInst.sendX({endpoint:'resources'}) - - t.notOk(result, 'result should be falsy') - t.equal(submitData.xhr.callCount, 0, 'no xhr call should have been made') - t.equal(submitData.img.callCount, 0, 'no call via img tag should have been made') - t.equal(submitData.beacon.callCount, 0, 'no beacon call should have been made') - t.end() - - function testPayload () { - return { - qs: { st: '1234', ptid: 123 }, - body: { res: [] } - } - } -}) - -test('uses an XHR and returns it for first resources POST', function (t) { - resetSpies() - harvesterInst.on('resources', once(dummyPayload('resources'))) - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'resources', opts: { needResponse: true }}) - - let baseUrl = scheme + '://foo/resources/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com&q1=v1&q2=v2' - let expectedPayload = { resources: ['one', 'two', 'three'] } - - if (xhrWrappable) { - t.ok(result, 'result truthy when resources submitted via XHR with needResponse') - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to XHR') - t.equal(call.args[0].body, stringify(expectedPayload), 'correct body given to XHR') - - t.equal(submitData.img.callCount, 0, 'did not use img to submit first resources POST') - t.equal(submitData.beacon.callCount, 0, 'did not use beacon to submit first resources POST') - } else { - t.notOk(result, 'result false when resources submitted via XHR with needResponse') - } - - t.end() -}) - -test('uses correct submission mechanism for jserrors', function (t) { - if (xhrWrappable) { - t.plan(4) - } else { - t.plan(3) - } - - resetSpies(null, { xhrWithLoadEvent: true }) - harvesterInst.on('jserrors', once(dummyPayload('jserrors'))) - - function harvestFinished () { - t.pass('harvest finished callback has been called') - } - - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'jserrors', cbFinished: harvestFinished}) - - let baseUrl = scheme + '://foo/jserrors/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com&q1=v1&q2=v2' - let expectedPayload = { jserrors: ['one', 'two', 'three'] } - - if (xhrWrappable) { - t.ok(result, 'result truthy when jserrors submitted via xhr') - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to xhr') - t.equal(call.args[0].body, JSON.stringify(expectedPayload), 'body arg given to xhr is correct') - } else { - t.ok(result, 'result truthy when jserrors submitted via img') - let call = submitData.img.getCall(0) - let expectedUrl = baseUrl + encode.obj(expectedPayload) - validateUrl(t, call.args[0], expectedUrl, 'correct URL given to img') - t.notOk(call.args[0].body, 'no body arg given to img') - } -}) - -test('adds ptid to harvest when ptid is present', function (t) { - if (xhrWrappable) { - t.plan(2) - } else { - t.plan(2) - } - - resetSpies(null, { xhrWithLoadEvent: true }) - harvesterInst.on('jserrors', once(dummyPayload('jserrors'))) - - // simulate ptid present (session trace in progress) after initial session trace (/resources) call - setupFakeNr({ ptid: '54321' }) - let result = harvesterInst.sendX({endpoint: 'jserrors', cbFinished: function () {}}) - - let baseUrl = scheme + '://foo/jserrors/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com&ptid=54321&q1=v1&q2=v2' - let expectedPayload = { jserrors: ['one', 'two', 'three'] } - - if (xhrWrappable) { - t.ok(result, 'result truthy when jserrors submitted via xhr') - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to xhr') - } else { - t.ok(result, 'result truthy when jserrors submitted via img') - let call = submitData.img.getCall(0) - let expectedUrl = baseUrl + encode.obj(expectedPayload) - validateUrl(t, call.args[0], expectedUrl, 'correct URL given to img') - } - - //delete fakeNr.ptid - setupFakeNr() // this'll reset ptid to 'undefined' since we currently lack a delete API in runtime.js -}) - -test('does not add ptid to harvest when ptid is not present', function (t) { - if (xhrWrappable) { - t.plan(2) - } else { - t.plan(2) - } - - resetSpies(null, { xhrWithLoadEvent: true }) - harvesterInst.on('jserrors', once(dummyPayload('jserrors'))) - - // simulate session trace not started on page - setupFakeNr({ ptid: null }) // === ptid: undefined - let result = harvesterInst.sendX({endpoint: 'jserrors', cbFinished: function () {}}) - - if (xhrWrappable) { - t.ok(result, 'result truthy when jserrors submitted via xhr') - let call = submitData.xhr.getCall(0) - t.ok(call.args[0].url.indexOf('ptid') === -1, 'ptid not included in querystring') - } else { - t.ok(result, 'result truthy when jserrors submitted via img') - let call = submitData.img.getCall(0) - t.ok(call.args[0].url.indexOf('ptid') === -1, 'ptid not included in querystring') - } -}) - -test('does not send jserrors when there is nothing to send', function (t) { - resetSpies() - harvesterInst.on('jserrors', once(testPayload)) - - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'jserrors'}) - - t.notOk(result, 'result should be falsy') - t.equal(submitData.xhr.callCount, 0, 'no xhr call should have been made') - t.equal(submitData.img.callCount, 0, 'no call via img tag should have been made') - t.equal(submitData.beacon.callCount, 0, 'no beacon call should have been made') - t.end() - - function testPayload () { - return { - qs: { pve: '1', ri: '1234' }, - body: null - } - } -}) - -test('uses correct submission mechanism for events', function (t) { - if (xhrWrappable) { - t.plan(4) - } else { - t.plan(3) - } - - resetSpies(null, { xhrWithLoadEvent: true }) - harvesterInst.on('events', once(function () { - return { - body: { - e: 'bel.1;1;' - } - } - })) - - function harvestFinished () { - t.pass('harvest finished callback has been called') - } - - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'events', cbFinished: harvestFinished}) - - let baseUrl = scheme + '://foo/events/1/bar?a=undefined&v=' + VERSION + '&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=' + scheme + '://foo.com' - let expectedPayload = { e: 'bel.1;1;' } - - if (xhrWrappable) { - t.ok(result, 'result truthy when events submitted via xhr') - let call = submitData.xhr.getCall(0) - validateUrl(t, call.args[0], baseUrl, 'correct URL given to xhr') - t.equal(call.args[0].body, 'bel.1;1;', 'body arg given to xhr is correct') - } else { - t.ok(result, 'result truthy when events submitted via img') - let call = submitData.img.getCall(0) - let expectedUrl = baseUrl + encode.obj(expectedPayload) - validateUrl(t, call.args[0], expectedUrl, 'correct URL given to img') - t.notOk(call.args[0].body, 'no body arg given to img') - } -}) - -test('does not send eents when there is nothing to send', function (t) { - resetSpies() - harvesterInst.on('events', once(testPayload)) - - setupFakeNr() - let result = harvesterInst.sendX({endpoint: 'events'}) - - t.notOk(result, 'result should be falsy') - t.equal(submitData.xhr.callCount, 0, 'no xhr call should have been made') - t.equal(submitData.img.callCount, 0, 'no call via img tag should have been made') - t.equal(submitData.beacon.callCount, 0, 'no beacon call should have been made') - t.end() - - function testPayload () { - return { - body: null - } - } -}) - -test('uses correct submission mechanisms on unload', function (t) { - resetSpies() - - harvesterInst.on('ins', once(dummyPayload('ins'))) - harvesterInst.on('resources', once(dummyPayload('resources'))) - harvesterInst.on('jserrors', once(dummyPayload('jserrors'))) - harvesterInst.on('events', once(function () { - return { - body: { - e: 'bel.1;1;' - } - } - })) - - setupFakeNr(); - ['ins', 'resources', 'jserrors', 'events'].forEach((evt) => { - harvesterInst.sendX({endpoint: evt, opts: { unload: true }}) - }) - - t.equal(submitData.xhr.callCount, 0, 'did not send any final submissions via XHR') - - let calls - if (hasSendBeacon) { - calls = submitData.beacon.getCalls() - t.equal(submitData.beacon.callCount, 4, 'sent all final submissions via sendBeacon') - } else { - calls = submitData.img.getCalls() - t.equal(submitData.img.callCount, 4, 'sent all final submissions via img') - } - - let insCall = findCallForEndpoint(calls, 'ins') - let resourcesCall = findCallForEndpoint(calls, 'resources') - let jserrorsCall = findCallForEndpoint(calls, 'jserrors') - let eventsCall = findCallForEndpoint(calls, 'events') - - t.ok(insCall, 'got unload submission for ins') - t.ok(resourcesCall, 'got unload submission for resources') - t.ok(jserrorsCall, 'got unload submission for jserrors') - t.ok(eventsCall, 'got unload submission for events') - - let expectedInsPayload = { ins: ['one', 'two', 'three'] } - let expectedResourcesPayload = { resources: ['one', 'two', 'three'] } - let expectedJserrorsPayload = { jserrors: ['one', 'two', 'three'] } - let expectedEventsPayload = { e: 'bel.1;1;' } - - if (hasSendBeacon) { - validateUrl(t, insCall.args[0], baseUrlFor('ins'), 'correct URL for ins unload submission') - t.equal(insCall.args[0].body, stringify(expectedInsPayload), 'correct body for ins unload submission') - validateUrl(t, resourcesCall.args[0], baseUrlFor('resources'), 'correct URL for resources unload submission') - t.equal(resourcesCall.args[0].body, stringify(expectedResourcesPayload), 'correct body for resources unload submission') - validateUrl(t, eventsCall.args[0], baseUrlFor('events', ''), 'correct URL for events unload submission') - t.equal(eventsCall.args[0].body, expectedEventsPayload.e, 'send correct body on final events submission') - validateUrl(t, jserrorsCall.args[0], baseUrlFor('jserrors'), 'correct URL for jserrors unload submission') - t.equal(jserrorsCall.args[0].body, stringify(expectedJserrorsPayload), 'correct body for jserrors unload submission') - } else { - validateUrl(t, insCall.args[0], baseUrlFor('ins') + encode.obj(expectedInsPayload), 'correct URL for ins unload submission') - t.notOk(insCall.args[0].body, 'did not send body on final submission of ins data') - validateUrl(t, resourcesCall.args[0], baseUrlFor('resources') + encode.obj(expectedResourcesPayload), 'correct URL for resources unload submission') - t.notOk(resourcesCall.args[0].body, 'did not send body on final submission of resources data') - validateUrl(t, eventsCall.args[0], baseUrlFor('events', encode.obj(expectedEventsPayload)), 'correct URL for events unload submission') - t.notOk(eventsCall.args[0].body, 'did not send body on final events submission') - validateUrl(t, jserrorsCall.args[0], baseUrlFor('jserrors') + encode.obj(expectedJserrorsPayload), 'correct URL for jserrors unload submission') - t.notOk(jserrorsCall.args[0].body, 'did not send body on final jserrors submission') - } - - t.end() - - function baseUrlFor (endpoint, qs) { - return `${scheme}://foo/${endpoint}/1/bar?a=undefined&v=${VERSION}&t=Unnamed%20Transaction&rst={TIMESTAMP}&ref=${scheme}://foo.com${qs !== undefined ? qs : '&q1=v1&q2=v2'}` - } - - function findCallForEndpoint (calls, desiredEndpoint) { - const matches = calls.filter(call => { - let url = call.args[0].url - let endpoint = url.split(/\/+/)[2] - if (endpoint === desiredEndpoint) return call - }) - return matches[0] - } -}) - -test('when sendBeacon returns false', function (t) { - t.test('uses img for jserrors', function (t) { - let baseUrl = scheme + '://foo/jserrors/1/bar?' - const payload = dummyPayload('jserrors') - const expectedUrl = baseUrl + encode.obj(payload().qs) + encode.obj(payload().body) - - resetSpies() - submitData.beacon = sinon.stub().returns(false) - - harvesterInst.on('jserrors', once(payload)) - - setupFakeNr() - harvesterInst.sendX({endpoint: 'jserrors', includeBaseParams: false, opts: { unload: true }}) - - t.equal(submitData.img.callCount, 1, 'sent one final submissions via IMG (jserrors)') - let call = submitData.img.getCall(0) - validateUrl(t, call.args[0], expectedUrl, 'correct URL given to img') - t.notOk(call.args[0].body, 'no body arg given to img') - - t.end() - }) - - t.test('uses img for ins', function (t) { - let baseUrl = scheme + '://foo/ins/1/bar?' - const payload = dummyPayload('ins') - const expectedUrl = baseUrl + encode.obj(payload().qs) + encode.obj(payload().body) - - resetSpies() - submitData.beacon = sinon.stub().returns(false) - - harvesterInst.on('ins', once(payload)) - - setupFakeNr() - harvesterInst.sendX({endpoint: 'ins', includeBaseParams: false, opts: { unload: true }}) - - t.equal(submitData.img.callCount, 1, 'sent one final submissions via IMG (ins)') - let call = submitData.img.getCall(0) - validateUrl(t, call.args[0], expectedUrl, 'correct URL given to img') - t.notOk(call.args[0].body, 'no body arg given to img') - - t.end() - }) - - t.test('uses img for resources', function (t) { - let baseUrl = scheme + '://foo/resources/1/bar?' - const payload = dummyPayload('resources') - const expectedUrl = baseUrl + encode.obj(payload().qs) + encode.obj(payload().body) - - resetSpies() - submitData.beacon = sinon.stub().returns(false) - - harvesterInst.on('resources', once(payload)) - - setupFakeNr() - harvesterInst.sendX({endpoint: 'resources', includeBaseParams: false, opts: { unload: true }}) - - t.equal(submitData.img.callCount, 1, 'sent one final submissions via IMG (resources)') - let call = submitData.img.getCall(0) - validateUrl(t, call.args[0], expectedUrl, 'correct URL given to img') - t.notOk(call.args[0].body, 'no body arg given to img') - - t.end() - }) - - t.test('uses img for events', function (t) { - let baseUrl = scheme + '://foo/events/1/bar?' - const payload = dummyPayload('events') - const expectedUrl = baseUrl + encode.obj(payload().qs) + encode.obj(payload().body) - - resetSpies() - submitData.beacon = sinon.stub().returns(false) - - harvesterInst.on('events', once(payload)) - - setupFakeNr() - harvesterInst.sendX({endpoint:'events', includeBaseParams: false, opts:{ unload: true }}) - - t.equal(submitData.img.callCount, 1, 'sent one final submissions via IMG (events)') - let call = submitData.img.getCall(0) - validateUrl(t, call.args[0], expectedUrl, 'correct URL given to img') - t.notOk(call.args[0].body, 'no body arg given to img') - - t.end() - }) -}) - -test('response codes', function (t) { - if (!xhrWrappable) { - t.pass('no handling for response codes for browser without CORS support') - t.end() - return - } - - var cases = { - 200: { - retry: undefined - }, - 202: { - retry: undefined - }, - 429: { - retry: true - }, - 408: { - retry: true - }, - 400: { - retry: undefined - }, - 404: { - retry: undefined - }, - 500: { - retry: true - }, - 503: { - retry: true - }, - 413: { - retry: undefined - }, - 414: { - retry: undefined - }, - 431: { - retry: undefined - } - } - - var harvestTypes = ['ins', 'events', 'jserrors', 'resources'] - - harvestTypes.forEach(function (type) { - for (var key in cases) { - runTest(type, key, cases[key]) - } - }) - - function runTest (type, responseCode, testCase) { - t.test('returns correct result with ' + responseCode, function (t) { - t.plan(1) - - resetSpies(null, { xhrWithLoadEvent: true, xhrResponseCode: responseCode }) - harvesterInst.on(type, once(dummyPayload(type))) - - setupFakeNr() - harvesterInst.sendX({endpoint: type, cbFinished: function (result) { - t.equal(result.retry, testCase.retry) - }}) - }) - } -}) diff --git a/tests/browser/xhr/index.browser.js b/tests/browser/xhr/index.browser.js index ac9bfca96..50bae0cec 100644 --- a/tests/browser/xhr/index.browser.js +++ b/tests/browser/xhr/index.browser.js @@ -19,7 +19,7 @@ const ajaxTestInstr = new AjaxInstrum(agentIdentifier, aggregator, false) const jserrTestInstr = new JsErrInstrum(agentIdentifier, aggregator, false) const jserrTestAgg = new JsErrAggreg(agentIdentifier, aggregator) -import {ffVersion} from '../../../src/common/browser-version/firefox-version' +import {ffVersion} from '../../../src/common/constants/runtime' const hasXhr = window.XMLHttpRequest && XMLHttpRequest.prototype && XMLHttpRequest.prototype.addEventListener let onloadtime = 2 diff --git a/tests/browser/xhr/onreadystatechange.browser.js b/tests/browser/xhr/onreadystatechange.browser.js index c85082128..f4d9feced 100644 --- a/tests/browser/xhr/onreadystatechange.browser.js +++ b/tests/browser/xhr/onreadystatechange.browser.js @@ -9,7 +9,7 @@ import { setup } from '../utils/setup' const { agentIdentifier, aggregator } = setup() jil.browserTest('xhr with onreadystatechange assigned after send', async function (t) { - const ffVersion = await import('../../../src/common/browser-version/firefox-version') + const ffVersion = await import('../../../src/common/constants/runtime') const { Instrument: AjaxInstrum } = await import('../../../src/features/ajax/instrument/index') const ajaxTestInstr = new AjaxInstrum(agentIdentifier, aggregator, false) @@ -43,7 +43,7 @@ jil.browserTest('xhr with onreadystatechange assigned after send', async functio }) jil.browserTest('multiple XHRs with onreadystatechange assigned after send', async function (t) { - const ffVersion = await import('../../../src/common/browser-version/firefox-version') + const ffVersion = await import('../../../src/common/constants/runtime') const { Instrument: AjaxInstrum } = await import('../../../src/features/ajax/instrument/index') const ajaxTestInstr = new AjaxInstrum(agentIdentifier, aggregator, false) diff --git a/tests/functional/disable-harvest.test.js b/tests/functional/disable-harvest.test.js deleted file mode 100644 index 76306cbe5..000000000 --- a/tests/functional/disable-harvest.test.js +++ /dev/null @@ -1,115 +0,0 @@ -const testDriver = require('../../tools/jil/index') -const { testRumRequest } = require('../../tools/testing-server/utils/expect-tests') - -let supported = testDriver.Matcher.withFeature('notInternetExplorer') - -testDriver.test('METRICS, ERRORS - Kills feature if entitlements flag is 0', supported, function (t, browser, router) { - const init = { - metrics: { enabled: true }, - jserrors: { enabled: true } - } - - router.scheduleReply('bamServer', { - test: testRumRequest, - body: `${JSON.stringify({ - stn: 1, - err: 0, - ins: 1, - cap: 1, - spa: 1, - loaded: 1 - }) - }` - }) - - const assetURL = router.assetURL('obfuscate-pii.html', { loader: 'full', init }) - const rumPromise = router.expectRum() - const loadPromise = browser.get(assetURL) - const metricsPromise = router.expectMetrics(5000) - const errorsPromise = router.expectErrors(5000) - - Promise.all([rumPromise, loadPromise]) - .then(() => browser.get(router.assetURL('/'))) // metrics only harvest on EoL - .then(() => Promise.any([metricsPromise, errorsPromise])) // if EITHER of these resolve, then that's BAD - .then(() => { - t.fail('should not have received metrics or errors') - }) - .catch(() => { - t.pass('did not receive metrics or errors :)') - }) - .finally(() => t.end()) -}) - -testDriver.test('SPA - Kills feature if entitlements flag is 0', supported, function (t, browser, router) { - const init = { - ajax: { enabled: false }, - spa: { enabled: true, harvestTimeSeconds: 5 }, - page_view_timing: { enabled: false } - } - - router.scheduleReply('bamServer', { - test: testRumRequest, - body: `${JSON.stringify({ - stn: 1, - err: 1, - ins: 1, - cap: 1, - spa: 0, - loaded: 1 - }) - }` - }) - - const assetURL = router.assetURL('obfuscate-pii.html', { loader: 'spa', init }) - const rumPromise = router.expectRum() - const loadPromise = browser.get(assetURL) - const spaPromise = router.expectEvents(7000) - - Promise.all([rumPromise, loadPromise]) - .then(() => spaPromise) - .then(() => t.fail('should not have received spa data')) - .catch((e) => { - if (e.toString().indexOf('for bamServer timed out') > -1) { - t.pass('did not received spa data :)') - } else { - t.fail('unknown error', e) - } - }) - .finally(() => t.end()) -}) - -testDriver.test('PAGE ACTIONS - Kills feature if entitlements flag is 0', supported, function (t, browser, router) { - const init = { - page_action: { enabled: true, harvestTimeSeconds: 5 } - } - - router.scheduleReply('bamServer', { - test: testRumRequest, - body: `${JSON.stringify({ - stn: 1, - err: 1, - ins: 0, - cap: 1, - spa: 1, - loaded: 1 - }) - }` - }) - - const assetURL = router.assetURL('obfuscate-pii.html', { loader: 'full', init }) - const rumPromise = router.expectRum() - const loadPromise = browser.get(assetURL) - const insPromise = router.expectIns(7000) - - Promise.all([rumPromise, loadPromise]) - .then(() => insPromise) - .then(() => t.fail('should not have received spa data')) - .catch((e) => { - if (e.toString().indexOf('for bamServer timed out') > -1) { - t.pass('did not received ins data :)') - } else { - t.fail('unknown error', e) - } - }) - .finally(() => t.end()) -}) diff --git a/tests/functional/err/harvest.test.js b/tests/functional/err/harvest.test.js deleted file mode 100644 index f099fce99..000000000 --- a/tests/functional/err/harvest.test.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -const testDriver = require('../../../tools/jil/index') -const { testErrorsRequest } = require('../../../tools/testing-server/utils/expect-tests') - -let corsSupported = testDriver.Matcher.withFeature('cors') - -testDriver.test('jserrors are retried when collector returns 429', corsSupported, function (t, browser, router) { - let assetURL = router.assetURL('external-uncaught-error.html', { - init: { - jserrors: { - harvestTimeSeconds: 5 - }, - harvest: { - tooManyRequestsDelay: 10 - }, - metrics: { - enabled: false - } - } - }) - - // simulate 429 response for the first jserrors request - router.scheduleReply('bamServer', { - test: testErrorsRequest, - statusCode: 429 - }) - - let loadPromise = browser.get(assetURL) - let rumPromise = router.expectRum() - let errPromise = router.expectErrors() - - let firstBody - - Promise.all([errPromise, loadPromise, rumPromise]).then(([errResult]) => { - t.equal(errResult.reply.statusCode, 429, 'server responded with 429') - firstBody = errResult.request.body.err - return router.expectErrors() - }).then(result => { - let secondBody = result.request.body.err - - t.equal(result.reply.statusCode, 200, 'server responded with 200') - t.deepEqual(secondBody, firstBody, 'post body in retry harvest should be the same as in the first harvest') - t.equal(router.requestCounts.bamServer.jserrors, 2, 'got two jserrors harvest requests') - - t.end() - }).catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -// NOTE: we do not test 408 response in a functional test because some browsers automatically retry -// 408 responses, which makes it difficult to distinguish browser retries from the agent retries diff --git a/tests/functional/final-harvest.test.js b/tests/functional/final-harvest.test.js deleted file mode 100644 index 8a78e0b0d..000000000 --- a/tests/functional/final-harvest.test.js +++ /dev/null @@ -1,411 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -const testDriver = require('../../tools/jil/index') -const querypack = require('@newrelic/nr-querypack') - -const BrowserMatcher = testDriver.Matcher -let stnSupported = testDriver.Matcher.withFeature('stn') - -let notSafariWithSeleniumBug = testDriver.Matcher.withFeature('notSafariWithSeleniumBug') -let workingSendBeacon = testDriver.Matcher.withFeature('workingSendBeacon') - .and(notSafariWithSeleniumBug) - -// final harvest for resources intermittently fails on additional browsers, probably due -// to the amount of data -// these excluded browsers fail to send final harvest for resources only -// used to create a composite matcher below -let excludeUnreliableResourcesHarvest = new BrowserMatcher() - .exclude('ie') - -let doNotSupportWaitForConditionInBrowser = new BrowserMatcher() - .exclude('safari', '<=10.0') -let reliableResourcesHarvest = workingSendBeacon.and(excludeUnreliableResourcesHarvest) -let reliablePageUnload = testDriver.Matcher.withFeature('reliableUnloadEvent') - -/** iOS is still shaky while on 'pagehide' callback. "reliablePageUnload" may be needed if it fails this test file too often. - * In the future, removing 'pagehide' listener and relying on visibilitychange alone may yield higher success. - */ -testDriver.test('final harvest happens on page unload -- new unload BFC work', function (t, browser, router) { - let url = router.assetURL('final-harvest.html', { - init: { - allow_bfcache: true, - page_view_timing: { - enabled: false - }, - metrics: { - enabled: false - } - } - }) - - let loadPromise = browser.safeGet(url).catch(fail(t)) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]) - .then(() => { - t.equal(router.requestCounts.bamServer.ins, undefined, 'no ins harvest yet') - t.equal(router.requestCounts.bamServer.jserrors, undefined, 'no err harvest yet') - - let insPromise = router.expectIns() - let errPromise = router.expectErrors() - let loadPromise2 = browser - .safeEval('newrelic.addPageAction("hello", { a: 1 })') - .safeEval('newrelic.noticeError("test")') - .get(router.assetURL('/')) // test that navigation away aka redirect triggers final harvest - - return Promise.all([insPromise, errPromise, loadPromise2]).then((respArr) => { - return respArr - }) - }) - .then((results) => { - t.equal(router.requestCounts.bamServer.ins, 1, 'received one ins harvest') - t.equal(router.requestCounts.bamServer.jserrors, 1, 'received one err harvest') - - if (results[0].request.body) { - t.ok(results[0].request.body.ins, 'received ins harvest') - } else { - t.ok(JSON.parse(results[0].request.query.ins), 'received ins harvest') - } - if (results[0].request.body) { - t.ok(results[1].request.body.err, 'received err harvest') - } else { - t.ok(JSON.parse(results[1].request.query.err), 'received err harvest') - } - t.end() - }) - .catch(fail(t)) -}) -/** iOS or mobile doesn't like the way the (wd) new tab is driven, so this test almost always timeout for iOS. - * WD's .newWindow causes later tests to fail. JWP mapping is complicated to figure out how to solve this. Can reassess this test with webdriveio later. */ -/*testDriver.test('final harvest happens on doc hide -- new unload BFC work', reliablePageUnload, function (t, browser, router) { - let url = router.assetURL('final-harvest.html', { - init: { - allow_bfcache: true, - page_view_timing: { - enabled: false - }, - metrics: { - enabled: false - } - } - }) - - let loadPromise = browser.safeGet(url).catch(fail(t)) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]) - .then(() => { - t.equal(router.seenRequests.ins, 0, 'no ins harvest yet') - t.equal(router.seenRequests.errors, 0, 'no err harvest yet') - - let insPromise = router.expectIns() - let errPromise = router.expectErrors() - let loadPromise2 = browser - .safeEval('newrelic.addPageAction("hello", { a: 1 })') - .safeEval('newrelic.noticeError("test")') - .newWindow(router.assetURL('/'), 'newTab'); // test that opening new tab aka page becoming hidden triggers final harvest too - - return Promise.all([insPromise, errPromise, loadPromise2]).then((respArr) => { - return respArr - }) - }) - .then(() => { - t.equal(router.seenRequests.ins, 1, 'received one ins harvest') - t.equal(router.seenRequests.errors, 1, 'received one err harvest') - t.end(); - }) - .catch(fail(t)) -})*/ - -function fail (t, err) { - return (err) => { - t.error(err) - t.end() - } -} - -testDriver.test('final harvest sends page action', workingSendBeacon, function (t, browser, router) { - let url = router.assetURL('final-harvest.html', { - init: { - page_view_timing: { - enabled: false - } - } - }) - - let loadPromise = browser.safeGet(url).catch(fail(t)) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]) - .then(() => { - t.equal(router.requestCounts.bamServer.ins, undefined, 'no ins harvest yet') - - let insPromise = router.expectIns() - - let loadPromise = browser - .safeEval('newrelic.addPageAction("hello", { a: 1 })') - .get(router.assetURL('/')) - - return Promise.all([insPromise, loadPromise]) - }).then((results) => { - t.equal(router.requestCounts.bamServer.ins, 1, 'received one ins harvest') - - t.ok(results[0].request.body, 'received ins harvest') - t.end() - }) - .catch(fail(t)) -}) - -testDriver.test('final harvest sends pageHide if not already recorded', workingSendBeacon, function (t, browser, router) { - let url = router.assetURL('final-harvest-timings.html', { loader: 'rum' }) - let loadPromise = browser.safeGet(url).catch(fail) - let rumPromise = router.expectRum() - const start = Date.now() - - Promise.all([rumPromise, loadPromise]) - .then(() => { - t.equal(router.requestCounts.bamServer.events, undefined, 'no events harvest yet') - - let timingsPromise = router.expectTimings() - - let domPromise = browser - .setAsyncScriptTimeout(10000) // the default is too low for IE - .elementById('standardBtn') - .click() - .get(router.assetURL('/')) - - return Promise.all([timingsPromise, domPromise]).then(([data]) => { - return data - }) - }) - .then(({ request: { body, query } }) => { - t.equal(router.requestCounts.bamServer.events, 1, 'received one events harvest') - - const timings = body && body.length ? body : querypack.decode(query.e) - const pageHide = timings.find(x => x.type === 'timing' && x.name === 'pageHide') - const duration = Date.now() - start - t.ok(timings.length > 0, 'there should be at least one timing metric') - t.ok(!!pageHide, 'Final harvest should have a pageHide timing') - t.ok(pageHide.value > 0, 'pageHide should have a value') - t.ok(pageHide.value <= duration, 'pageHide value should be valid') - t.end() - }) - .catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -testDriver.test('final harvest doesnt append pageHide if already previously recorded', workingSendBeacon, function (t, browser, router) { - let url = router.assetURL('pagehide.html', { loader: 'rum' }) - let loadPromise = browser.safeGet(url).catch(fail) - let start = Date.now() - - Promise.all([loadPromise, router.expectRum()]) - .then(() => { - const clickPromise = browser - .elementById('btn1').click() - .get(router.assetURL('/')) - const timingsPromise = router.expectTimings() - return Promise.all([timingsPromise, clickPromise]) - }) - .then(([{ request: { body, query } }]) => { - const timings = body && body.length ? body : querypack.decode(query.e) - let duration = Date.now() - start - t.ok(timings.length > 0, 'there should be at least one timing metric') - const pageHide = timings.filter(t => t.name === 'pageHide') - t.ok(timings && pageHide.length === 1, 'there should be ONLY ONE pageHide timing') - t.ok(pageHide[0].value > 0, 'value should be a positive number') - t.ok(pageHide[0].value <= duration, 'value should not be larger than time since start of the test') - t.end() - }) - .catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -testDriver.test('final harvest sends js errors', workingSendBeacon, function (t, browser, router) { - let url = router.assetURL('final-harvest.html', { init: { metrics: { enabled: false } } }) - let loadPromise = browser.safeGet(url).catch(fail) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]) - .then(() => { - t.equal(router.requestCounts.bamServer.jserrors, undefined, 'no errors harvest yet') - - let errorsPromise = router.expectErrors() - - let domPromise = browser - .elementById('errorBtn') - .click() - .get(router.assetURL('/')) - - return Promise.all([errorsPromise, domPromise]) - }) - .then((results) => { - t.equal(router.requestCounts.bamServer.jserrors, 1, 'received one errors harvest') - t.ok(results[0].request.body, 'received err harvest') - t.end() - }) - .catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -testDriver.test('final harvest sends resources', reliableResourcesHarvest.and(stnSupported), function (t, browser, router) { - let url = router.assetURL('final-harvest.html') - let loadPromise = browser.safeGet(url).catch(fail) - let rumPromise = router.expectRum() - let resourcesPromise = router.expectResources() - - Promise.all([resourcesPromise, rumPromise, loadPromise]) - .then(([{ request: { body } }]) => { - t.ok(body, 'received first resources harvest on startup') - - resourcesPromise = router.expectResources() - let domPromise = browser - .setAsyncScriptTimeout(10000) // the default is too low for IE - .elementById('resourcesBtn') - .click() - .waitForConditionInBrowser('window.timerLoopDone == true') - .get(router.assetURL('/')) - - return Promise.all([resourcesPromise, domPromise]) - }) - .then(([{ request: { body } }]) => { - t.ok(body, 'received second res harvest on interval') - t.end() - }) - .catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -testDriver.test('final harvest sends timings data', workingSendBeacon, function (t, browser, router) { - let url = router.assetURL('final-harvest-timings.html', { loader: 'rum' }) - let loadPromise = browser.safeGet(url).catch(fail) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]) - .then(() => { - t.equal(router.requestCounts.bamServer.events, undefined, 'no events harvest yet') - - let timingsPromise = router.expectTimings() - - let domPromise = browser - .setAsyncScriptTimeout(10000) // the default is too low for IE - .elementById('standardBtn') - .click() - .get(router.assetURL('/')) - - return Promise.all([timingsPromise, domPromise]).then(([data, clicked]) => { - return data - }) - }) - .then(({ request: { body, query } }) => { - t.equal(router.requestCounts.bamServer.events, 1, 'received first events harvest') - - const timings = body && body.length ? body : querypack.decode(query.e) - t.ok(timings.length > 0, 'there should be at least one timing metric') - t.equal(timings[0].type, 'timing', 'first node is a timing node') - t.end() - }) - .catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -// // This test checks that the agent sends multiple types of data types on unload -// // It does not check all of them, just errors and resources. This is sufficient for the -// // test. Sending more than that makes the test very fragile on some platforms. -testDriver.test('final harvest sends multiple', reliableResourcesHarvest.and(stnSupported), function (t, browser, router) { - let url = router.assetURL('final-harvest-timings.html', { init: { metrics: { enabled: false } } }) - let loadPromise = browser.safeGet(url).catch(fail) - let rumPromise = router.expectRum() - let resourcesPromise = router.expectResources() - - Promise.all([resourcesPromise, rumPromise, loadPromise]) - .then(([{ request: { body }}]) => { - t.ok(body, 'resources harvest is sent on startup') - t.equal(router.requestCounts.bamServer.jserrors, undefined, 'no errors harvest yet') - - resourcesPromise = router.expectResources() - let errorsPromise = router.expectErrors() - - let domPromise = browser - .setAsyncScriptTimeout(10000) // the default is too low for IE - .elementById('errorBtn') - .click() - .elementById('resourcesBtn') - .click() - .waitForConditionInBrowser('window.timerLoopDone == true') - .get(router.assetURL('/')) - - return Promise.all([resourcesPromise, errorsPromise, domPromise]) - }) - .then((results) => { - t.equal(router.requestCounts.bamServer.resources, 2, 'received second resources harvest') - t.equal(router.requestCounts.bamServer.jserrors, 1, 'received one errors harvest') - - t.ok(results[0].request.body, 'received res harvest') - t.ok(results[1].request.body, 'received err harvest') - t.end() - }) - .catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -testDriver.test('final harvest sends ajax events', workingSendBeacon.and(doNotSupportWaitForConditionInBrowser), function (t, browser, router) { - let url = router.assetURL('final-harvest-ajax.html', { loader: 'spa', init: { ajax: { enabled: true } } }) - let loadPromise = browser.safeGet(url).catch(fail) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]) - .then(() => { - let eventsPromise = router.expectAjaxEvents() - - let domPromise = browser - .setAsyncScriptTimeout(10000) // the default is too low for IE - .elementById('btnGenerate') - .click() - .waitForConditionInBrowser('window.ajaxCallsDone == true') - .get(router.assetURL('/')) - - return Promise.all([eventsPromise, domPromise]).then(([data, clicked]) => { - return data - }) - }) - .then(({ request: { body, query } }) => { - const events = body && body.length ? body : querypack.decode(query.e) - t.ok(events.length > 0, 'there should be at least one ajax call') - t.equal(events[0].type, 'ajax', 'first node is a ajax node') - t.end() - }) - .catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) diff --git a/tests/functional/harvest.test.js b/tests/functional/harvest.test.js deleted file mode 100644 index 4e8de594f..000000000 --- a/tests/functional/harvest.test.js +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -const testDriver = require('../../tools/jil/index') -const { fail, url, cleanURL } = require('./uncat-internal-help.cjs') - -let notSafariWithSeleniumBug = testDriver.Matcher.withFeature('notSafariWithSeleniumBug') -let originOnlyReferer = testDriver.Matcher.withFeature('originOnlyReferer') -const FAIL_MSG = 'unexpected error' - -testDriver.test('referrer attribute is sent in the query string', notSafariWithSeleniumBug, function (t, browser, router) { - t.plan(1) - let loadPromise = browser.safeGet(router.assetURL('instrumented.html')) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { query } }]) => { - t.ok(query.ref, 'The query string should include the ref attribute.') - }).catch(fail(t, FAIL_MSG)) -}) - -testDriver.test('referrer sent in query does not include query parameters', notSafariWithSeleniumBug, function (t, browser, router) { - t.plan(1) - let loadPromise = browser.safeGet(router.assetURL('instrumented.html')) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { query } }]) => { - var queryRefUrl = url.parse(query.ref) - t.ok(queryRefUrl.query == null, 'url in ref query param does not contain query parameters') - }).catch(fail(t, FAIL_MSG)) -}) - -testDriver.test('referrer sent in referer header includes path', originOnlyReferer.inverse().and(notSafariWithSeleniumBug), function (t, browser, router) { - t.plan(1) - let loadPromise = browser.safeGet(router.assetURL('instrumented.html')) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { headers } }]) => { - var headerUrl = url.parse(headers.referer) - t.ok(headerUrl.query != null, 'url in referer header contains query parameters') - }).catch(fail(t, FAIL_MSG)) -}) - -testDriver.test('when url is changed using pushState during load', notSafariWithSeleniumBug, function (t, browser, router) { - var originalUrl = router.assetURL('referrer-pushstate.html') - var originalPath = url.parse(originalUrl).pathname - var redirectedPath = url.parse(router.assetURL('instrumented.html')).pathname - - t.test('header', function (t) { - t.plan(1) - - if (originOnlyReferer.match(browser)) { - t.ok('browser does not send full referrer by default') - return - } - - let loadPromise = browser.get(originalUrl) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { headers } }]) => { - var headerUrl = url.parse(headers.referer) - t.equal(headerUrl.pathname, redirectedPath, 'referer header contains the redirected URL') - }).catch(fail(t, FAIL_MSG)) - }) - - t.test('query param', function (t) { - t.plan(1) - let loadPromise = browser.get(originalUrl) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { query } }]) => { - var queryRefUrl = url.parse(query.ref) - t.equal(queryRefUrl.pathname, redirectedPath, 'ref param contains the redirected URL') - }).catch(fail(t, FAIL_MSG)) - }) -}) - -testDriver.test('when url is changed using replaceState during load', notSafariWithSeleniumBug, function (t, browser, router) { - var originalUrl = router.assetURL('referrer-replacestate.html') - var originalPath = url.parse(originalUrl).pathname - var redirectedPath = url.parse(router.assetURL('instrumented.html')).pathname - - t.test('header', function (t) { - t.plan(1) - - if (originOnlyReferer.match(browser)) { - t.ok('browser does not send full referrer by default') - return - } - - let loadPromise = browser.get(originalUrl) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { headers } }]) => { - var headerUrl = url.parse(headers.referer) - t.equal(headerUrl.pathname, redirectedPath, 'referer header contains the redirected URL') - }).catch(fail(t, FAIL_MSG)) - }) - - t.test('query param', function (t) { - t.plan(1) - - let loadPromise = browser.get(originalUrl) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { query } }]) => { - var queryRefUrl = url.parse(query.ref) - t.equal(queryRefUrl.pathname, redirectedPath, 'ref param contains the redirected URL') - }).catch(fail(t, FAIL_MSG)) - }) -}) - -testDriver.test('browsers that do not decode the url when accessing window.location encode special characters in the referrer attribute', notSafariWithSeleniumBug, function (t, browser, router) { - t.plan(2) - let assetURL = router.assetURL('symbols%20in&referrer.html') - let loadPromise = browser.safeGet(assetURL).catch(fail(t, FAIL_MSG)) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { query } }]) => { - let cleanAssetURL = cleanURL(assetURL) - t.ok(query.ref, 'The query string should include the ref attribute.') - t.equal(query.ref, cleanAssetURL, 'The ref attribute should be the same as the assetURL') - }).catch(fail(t, FAIL_MSG)) -}) - -testDriver.test('cookie disabled: query string attributes', notSafariWithSeleniumBug, function (t, browser, router) { - let loadPromise = browser.safeGet(router.assetURL('instrumented.html', { - init: { privacy: { cookies_enabled: false } } - })) - let rumPromise = router.expectRum() - - let sId - Promise.all([rumPromise, loadPromise]).then(([{ request: { query } }]) => { - t.equal(query.ck, '0', "The cookie flag ('ck') should equal 0.") - t.ok(query.s, "The session id attr 's' should exist and be truthy.") - sId = query.s - return Promise.all([router.expectRum(), browser.refresh()]) - }).then(([{ request: { query } }]) => { - t.equal(query.ck, '0', "The cookie flag ('ck') should equal 0.") - t.equal(query.s, '0', 'the session id attr "s" should equal 0 when disabled') - t.end() - }).catch(fail(t, FAIL_MSG)) -}) - -testDriver.test('cookie enabled by default: query string attributes', notSafariWithSeleniumBug, function (t, browser, router) { - t.plan(2) - let loadPromise = browser.safeGet(router.assetURL('instrumented.html')) - let rumPromise = router.expectRum() - - Promise.all([rumPromise, loadPromise]).then(([{ request: { query } }]) => { - t.equal(query.ck, '0', "The cookie flag ('ck') should equal 0.") - t.notEqual(query.s, '0', "The session id ('s') should NOT be 0.") - }).catch(fail(t, FAIL_MSG)) -}) diff --git a/tests/functional/ins/page-action-submission.test.js b/tests/functional/ins/page-action-submission.test.js index 4f7e8c15c..3eb9863c6 100644 --- a/tests/functional/ins/page-action-submission.test.js +++ b/tests/functional/ins/page-action-submission.test.js @@ -43,62 +43,6 @@ testDriver.test('PageAction submission', function (t, browser, router) { .catch(fail(t)) }) -testDriver.test('PageActions are retried when collector returns 429', function (t, browser, router) { - let assetURL = router.assetURL('instrumented.html', { - init: { - ins: { - harvestTimeSeconds: 2 - }, - harvest: { - tooManyRequestsDelay: 10 - } - } - }) - - let loadPromise = browser.get(assetURL) - let rumPromise = router.expectRum() - let firstBody - - Promise.all([rumPromise, loadPromise]) - .then(() => { - router.scheduleReply('bamServer', { - test: testInsRequest, - statusCode: 429 - }) - browser.safeEval('newrelic.addPageAction("exampleEvent", {param: "value"})') - - return router.expectIns() - }) - .then(({ request, reply }) => { - t.equal(reply.statusCode, 429, 'server responded with 429') - - if (request.body) { - firstBody = request.body.ins - } else { - firstBody = JSON.parse(request.query.ins) - } - - return router.expectIns() - }) - .then(({ request, reply }) => { - t.equal(router.requestCounts.bamServer.ins, 2, 'got two ins harvest requests') - - let secondBody - - if (request.body) { - secondBody = request.body.ins - } else { - secondBody = JSON.parse(request.query.ins) - } - - t.equal(reply.statusCode, 200, 'server responded with 200') - t.deepEqual(secondBody, firstBody, 'post body in retry harvest should be the same as in the first harvest') - - t.end() - }) - .catch(fail(t)) -}) - testDriver.test('PageAction submission on final harvest', function (t, browser, router) { let assetURL = router.assetURL('instrumented.html', { init: { diff --git a/tests/functional/pvt/timings.test.js b/tests/functional/pvt/timings.test.js index 808900b55..ddc3d0265 100644 --- a/tests/functional/pvt/timings.test.js +++ b/tests/functional/pvt/timings.test.js @@ -536,11 +536,8 @@ function runLongTasksTest (loader) { const timings = body && body.length ? body : querypack.decode(query.e) const ltEvents = timings.filter(t => t.name === 'lt') - - /* Istanbul (for wdio code cov) adds a long task to the loader for webpack LOCAL testing, so we account for that here. It may be taken off in the future. - * TODO - This should be changed from 3 to 2 once Istanbul is no longer included by default in the test agent build. - */ - t.ok(ltEvents.length == 3, 'expected number of long tasks (2 -> temp 3) observed') + + t.ok(ltEvents.length == 2, 'expected number of long tasks') ltEvents.forEach((lt) => { t.ok(lt.value >= 59, 'task duration is roughly as expected') // defined in some-long-task.js -- duration should be at least that value +/- 1ms diff --git a/tests/functional/spa/harvest.test.js b/tests/functional/spa/harvest.test.js deleted file mode 100644 index 12bb362d5..000000000 --- a/tests/functional/spa/harvest.test.js +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -const testDriver = require('../../../tools/jil/index') -const querypack = require('@newrelic/nr-querypack') -const { testEventsRequest } = require('../../../tools/testing-server/utils/expect-tests') - -let corsSupported = testDriver.Matcher.withFeature('cors') - -testDriver.test('events are retried when collector returns 429', corsSupported, function (t, browser, router) { - let assetURL = router.assetURL('instrumented.html', { - loader: 'spa', - init: { - spa: { - harvestTimeSeconds: 10 - }, - harvest: { - tooManyRequestsDelay: 10 - }, - page_view_timing: { - enabled: false - }, - ajax: { - deny_list: ['bam-test-1.nr-local.net'] - } - } - }) - - router.scheduleReply('bamServer', { - test: testEventsRequest, - statusCode: 429 - }) - - let loadPromise = browser.safeGet(assetURL) - let rumPromise = router.expectRum() - let eventsPromise = router.expectEvents() - - let firstBody - - Promise.all([eventsPromise, loadPromise, rumPromise]).then(([eventsResult]) => { - t.equal(eventsResult.reply.statusCode, 429, 'server responded with 429') - firstBody = eventsResult.request.body - return router.expectEvents() - }).then(result => { - t.equal(router.requestCounts.bamServer.events, 2, 'got two events harvest requests') - - let secondBody = result.request.body - - t.equal(result.reply.statusCode, 200, 'server responded with 200') - t.deepEqual(secondBody, firstBody, 'post body in retry harvest should be the same as in the first harvest') - - t.end() - }).catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -// NOTE: we do not test 408 response in a functional test because some browsers automatically retry -// 408 responses, which makes it difficult to distinguish browser retries from the agent retries - -testDriver.test('multiple custom interactions have correct customEnd value', corsSupported, function (t, browser, router) { - let assetURL = router.assetURL('spa/multiple-custom-interactions.html', { - loader: 'spa', - init: { - spa: { - harvestTimeSeconds: 2 - }, - harvest: { - tooManyRequestsDelay: 10 - }, - page_view_timing: { - enabled: false - }, - ajax: { - deny_list: ['bam-test-1.nr-local.net'] - } - } - }) - - let loadPromise = browser.safeGet(assetURL) - let rumPromise = router.expectRum() - let eventsPromise = router.expectEvents() - - Promise.all([eventsPromise, loadPromise, rumPromise]).then(([eventsResult]) => { - const qpData = eventsResult.request.body - - t.ok(qpData.length === 3, 'three interactions should have been captured') - qpData.forEach(interaction => { - t.ok(['interaction1', 'interaction2', 'interaction4'].indexOf(interaction.customName) > -1, 'interaction has expected custom name') - const customEndTime = interaction.children.find(child => child.type === 'customEnd') - t.ok(customEndTime.time >= interaction.end, 'interaction custom end time is equal to or greater than interaction end time') - }) - - t.end() - }).catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) diff --git a/tests/functional/stn/harvest.test.js b/tests/functional/stn/harvest.test.js deleted file mode 100644 index 060de77a4..000000000 --- a/tests/functional/stn/harvest.test.js +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -const testDriver = require('../../../tools/jil/index') -const { testResourcesRequest } = require('../../../tools/testing-server/utils/expect-tests') - -let supported = testDriver.Matcher.withFeature('stn') - -testDriver.test('session traces are retried when collector returns 429 during first harvest', supported, function (t, browser, router) { - let assetURL = router.assetURL('instrumented.html', { - loader: 'spa', - init: { - session_trace: { - harvestTimeSeconds: 5 - }, - harvest: { - tooManyRequestsDelay: 5 - } - } - }) - - router.scheduleReply('bamServer', { - test: testResourcesRequest, - statusCode: 429 - }) - - let loadPromise = browser.safeGet(assetURL).waitForFeature('loaded') - let rumPromise = router.expectRum() - let resourcePromise = router.expectResources() - - let firstBody - - Promise.all([resourcePromise, loadPromise, rumPromise]).then(([result]) => { - t.equal(result.reply.statusCode, 429, 'server responded with 429') - firstBody = result.request.body - return router.expectResources() - }).then(result => { - t.equal(router.requestCounts.bamServer.resources, 2, 'got two harvest requests') - - let secondBody = result.request.body - - t.ok(secondBody.res.length > firstBody.res.length, 'second try has more nodes than first') - t.ok(containsAll(secondBody, firstBody), 'all nodes have been resent') - t.equal(result.reply.statusCode, 200, 'server responded with 200') - - t.end() - }).catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -testDriver.test('retried first harvest captures ptid', supported, function (t, browser, router) { - let assetURL = router.assetURL('lotsatimers.html', { - loader: 'spa', - init: { - session_trace: { - harvestTimeSeconds: 5 - }, - harvest: { - tooManyRequestsDelay: 5 - } - } - }) - - router.scheduleReply('bamServer', { - test: testResourcesRequest, - statusCode: 429 - }) - - let loadPromise = browser.safeGet(assetURL).waitForFeature('loaded') - let rumPromise = router.expectRum() - let resourcePromise = router.expectResources() - - Promise.all([resourcePromise, loadPromise, rumPromise]).then(([result]) => { - t.equal(result.reply.statusCode, 429, 'server responded with 429') - return router.expectResources() - }).then(result => { - t.equal(result.reply.statusCode, 200, 'server responded with 200') - const domPromise = browser - .elementByCssSelector('body') - .click() - return Promise.all([router.expectResources(), domPromise]) - }).then(([result]) => { - t.equal(result.reply.statusCode, 200, 'server responded with 200') - t.ok(result.request.query.ptid, 'ptid was included') - t.end() - }).catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -testDriver.test('session traces are retried when collector returns 429 during scheduled harvest', supported, function (t, browser, router) { - let assetURL = router.assetURL('lotsatimers.html', { - loader: 'spa', - init: { - session_trace: { - harvestTimeSeconds: 5 - }, - harvest: { - tooManyRequestsDelay: 5 - } - } - }) - - let loadPromise = browser.safeGet(assetURL).waitForFeature('loaded') - let rumPromise = router.expectRum() - let resourcePromise = router.expectResources() - - let firstBody, secondBody - - Promise.all([resourcePromise, loadPromise, rumPromise]).then(([result]) => { - firstBody = result.request.body - t.equal(result.reply.statusCode, 200, 'server responded with 200') - - router.scheduleReply('bamServer', { - test: testResourcesRequest, - statusCode: 429 - }) - return router.expectResources() - }).then(result => { - t.equal(result.reply.statusCode, 429, 'server responded with 429') - secondBody = result.request.body - return router.expectResources() - }).then(result => { - t.equal(router.requestCounts.bamServer.resources, 3, 'got three harvest requests') - - let thirdBody = result.request.body - - t.ok(containsAll(thirdBody, secondBody), 'all nodes have been resent') - - // this is really checking that no nodes have been resent - var resentNodes = intersectPayloads(secondBody, firstBody) - t.ok(resentNodes.length === 0, 'nodes from first successful harvest are not resent in second harvest') - - resentNodes = intersectPayloads(thirdBody, firstBody) - t.ok(resentNodes.length === 0, 'nodes from first successful harvest are not resent in third harvest') - - t.equal(result.reply.statusCode, 200, 'server responded with 200') - - t.end() - }).catch(fail) - - function fail (err) { - t.error(err) - t.end() - } -}) - -function containsAll (targetPayload, subsetPayload) { - let allFound = true - subsetPayload.res.forEach(node => { - const found = targetPayload.res.find(getFindCallback(node)) - allFound = allFound && !!found - }) - return allFound -} - -function intersectPayloads (target, subset) { - var nodes = [] - subset.res.forEach(node => { - var found = target.res.find(getFindCallback(node)) - if (found) { - nodes.push(found) - } - }) - return nodes -} - -function getFindCallback (node) { - return function (el) { - return el.n === node.n && - el.s === node.s && - el.e === node.e && - el.o === node.o && - el.t === node.t - } -} - -// NOTE: we do not test 408 response in a functional test because some browsers automatically retry -// 408 responses, which makes it difficult to distinguish browser retries from the agent retries diff --git a/tests/functional/xhr/harvest.test.js b/tests/functional/xhr/harvest.test.js deleted file mode 100644 index e6e483c24..000000000 --- a/tests/functional/xhr/harvest.test.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -const testDriver = require('../../../tools/jil/index') -const { fail, querypack } = require('./helpers') -const { testEventsRequest } = require('../../../tools/testing-server/utils/expect-tests') - -testDriver.test('ajax events harvests are retried when collector returns 429', function (t, browser, router) { - let assetURL = router.assetURL('xhr-outside-interaction.html', { - loader: 'full', - init: { - page_view_timing: { - enabled: false - }, - harvest: { - tooManyRequestsDelay: 10 - }, - spa: { - enabled: false - }, - ajax: { - harvestTimeSeconds: 4, - enabled: true - }, - metrics: { - enabled: false - } - } - }) - - router.scheduleReply('bamServer', { - test: testEventsRequest, - statusCode: 429 - }) - - let ajaxPromise = router.expectAjaxEvents() - let rumPromise = router.expectRum() - let loadPromise = browser.safeGet(assetURL).waitForFeature('loaded') - - let firstBody - - Promise.all([ajaxPromise, loadPromise, rumPromise]).then(([result]) => { - t.equal(result.reply.statusCode, 429, 'server responded with 429') - firstBody = result.request.body - return router.expectAjaxEvents() - }).then(result => { - t.equal(router.requestCounts.bamServer.events, 2, 'got two events harvest requests') - - const secondBody = result.request.body - - const secondContainsFirst = firstBody.every(firstElement => { - return secondBody.find(secondElement => { - return secondElement.path === firstElement.path && secondElement.start === firstElement.start - }) - }) - - t.equal(result.reply.statusCode, 200, 'server responded with 200') - t.ok(secondContainsFirst, 'second body should include the contents of the first retried harvest') - - t.end() - }).catch(fail(t)) -}) diff --git a/tests/specs/api.e2e.js b/tests/specs/api.e2e.js index a457831b6..387be4f43 100644 --- a/tests/specs/api.e2e.js +++ b/tests/specs/api.e2e.js @@ -1,4 +1,4 @@ -import { reliableUnload, notIE } from '../../tools/browser-matcher/common-matchers.mjs' +import { reliableUnload } from '../../tools/browser-matcher/common-matchers.mjs' describe('newrelic api', () => { afterEach(async () => { @@ -33,7 +33,7 @@ describe('newrelic api', () => { expect(ajaxResults.request.query.ct).toEqual('http://custom.transaction/foo') }) - withBrowsersMatching(reliableUnload)('includes the 1st argument (page name) in metrics call on unload', async () => { + it.withBrowsersMatching(reliableUnload)('includes the 1st argument (page name) in metrics call on unload', async () => { await browser.url(await browser.testHandle.assetURL('api.html')) .then(() => browser.waitForAgentLoad()) @@ -51,7 +51,7 @@ describe('newrelic api', () => { expect(time).toBeGreaterThan(0) }) - withBrowsersMatching(reliableUnload)('includes the optional 2nd argument for host in metrics call on unload', async () => { + it.withBrowsersMatching(reliableUnload)('includes the optional 2nd argument for host in metrics call on unload', async () => { await browser.url(await browser.testHandle.assetURL('api2.html')) .then(() => browser.waitForAgentLoad()) diff --git a/tests/specs/err/bucketing.e2e.js b/tests/specs/err/bucketing.e2e.js index 5eead396a..88c93b34e 100644 --- a/tests/specs/err/bucketing.e2e.js +++ b/tests/specs/err/bucketing.e2e.js @@ -3,8 +3,8 @@ import { testErrorsRequest } from '../../../tools/testing-server/utils/expect-te // IE11 actually does bucket these cases, so these tests will fail. Because the cases are niche, we exclude IE11. -describe('error bucketing', () => { - withBrowsersMatching(notIE)('NR-40043: Multiple errors with noticeError and unique messages should not bucket', async () => { +describe.withBrowsersMatching(notIE)('error bucketing', () => { + it('NR-40043: Multiple errors with noticeError and unique messages should not bucket', async () => { const [errorResult] = await Promise.all([ browser.testHandle.expectErrors(), browser.url(await browser.testHandle.assetURL('js-errors-noticeerror-bucketing.html')) @@ -17,7 +17,7 @@ describe('error bucketing', () => { }) }) - withBrowsersMatching(notIE)('NR-40043: Multiple errors with noticeError and unique messages should not bucket when retrying due to 429', async () => { + it('NR-40043: Multiple errors with noticeError and unique messages should not bucket when retrying due to 429', async () => { await browser.testHandle.scheduleReply('bamServer', { test: testErrorsRequest, statusCode: 429 @@ -37,7 +37,7 @@ describe('error bucketing', () => { expect(firstErrorResult.request.body.err).toEqual(secondErrorResult.request.body.err) // same because it's a retry }) - withBrowsersMatching(notIE)('NEWRELIC-3788: Multiple identical errors from the same line but different columns should not be bucketed', async () => { + it('NEWRELIC-3788: Multiple identical errors from the same line but different columns should not be bucketed', async () => { const [errorResult] = await Promise.all([ browser.testHandle.expectErrors(), browser.url(await browser.testHandle.assetURL('js-error-column-bucketing.html')) @@ -50,7 +50,7 @@ describe('error bucketing', () => { expect(typeof errorResult.request.body.err[1].params.stack_trace === 'string').toBeTruthy() // second error has a stack trace }) - withBrowsersMatching(notIE)('NEWRELIC-3788: Multiple identical errors from the same line but different columns should not be bucketed when retrying due to 429', async () => { + it('NEWRELIC-3788: Multiple identical errors from the same line but different columns should not be bucketed when retrying due to 429', async () => { await browser.testHandle.scheduleReply('bamServer', { test: testErrorsRequest, statusCode: 429 diff --git a/tests/specs/err/error-payload.e2e.js b/tests/specs/err/error-payload.e2e.js index 33e7651bf..914c08498 100644 --- a/tests/specs/err/error-payload.e2e.js +++ b/tests/specs/err/error-payload.e2e.js @@ -53,8 +53,8 @@ describe('error payloads', () => { const { request: { body: { err } } } = await browser.testHandle.expectErrors() - expect(Math.abs(err[0].params.firstOccurrenceTimestamp - before) <= 1).toEqual(true) - expect(Math.abs(err[0].params.firstOccurrenceTimestamp - after) <= 1).toEqual(false) + expect(Math.abs(err[0].params.firstOccurrenceTimestamp - before)).toBeWithin(0, 20) + expect(Math.abs(err[0].params.firstOccurrenceTimestamp - after)).toBeWithin(0, 20) }) it('simultaneous errors - should set a timestamp, tied to the FIRST error seen - thrown errors', async () => { @@ -71,8 +71,8 @@ describe('error payloads', () => { return [window['error-0'], window['error-1']] }) - expect(Math.abs(err[0].params.firstOccurrenceTimestamp - firstTime) <= 1).toEqual(true) - expect(Math.abs(err[0].params.firstOccurrenceTimestamp - secondTime) <= 1).toEqual(false) + expect(Math.abs(err[0].params.firstOccurrenceTimestamp - firstTime)).toBeWithin(0, 20) + expect(Math.abs(err[0].params.firstOccurrenceTimestamp - secondTime)).toBeWithin(0, 20) }) it('subsequent errors - should set a timestamp, tied to the FIRST error seen - noticeError', async () => { diff --git a/tests/specs/err/retry-harvesting.e2e.js b/tests/specs/err/retry-harvesting.e2e.js new file mode 100644 index 000000000..9aea59be9 --- /dev/null +++ b/tests/specs/err/retry-harvesting.e2e.js @@ -0,0 +1,69 @@ +import { testErrorsRequest } from '../../../tools/testing-server/utils/expect-tests' + +describe('err retry harvesting', () => { + [408, 429, 500, 503].forEach(statusCode => + it(`should send the error on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testErrorsRequest, + permanent: true, + statusCode + }) + + const [firstErrorsHarvest] = await Promise.all([ + browser.testHandle.expectErrors(), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + .then(() => browser.execute(function () { + newrelic.noticeError(new Error('hippo hangry')) + })) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const [secondErrorsHarvest] = await Promise.all([ + browser.testHandle.expectErrors(), + browser.execute(function () { + newrelic.noticeError(new Error('hippo hangry 2')) + }) + ]) + + expect(firstErrorsHarvest.reply.statusCode).toEqual(statusCode) + expect(secondErrorsHarvest.request.body.err).toEqual(expect.arrayContaining(firstErrorsHarvest.request.body.err)) + }) + ); + + [400, 404, 502, 504, 512].forEach(statusCode => + it(`should not send the error on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testErrorsRequest, + permanent: true, + statusCode + }) + + const [firstErrorsHarvest] = await Promise.all([ + browser.testHandle.expectErrors(), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + .then(() => browser.execute(function () { + newrelic.noticeError(new Error('hippo hangry')) + })) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const [secondErrorsHarvest] = await Promise.all([ + browser.testHandle.expectErrors(), + browser.execute(function () { + newrelic.noticeError(new Error('hippo hangry 2')) + }) + ]) + + expect(firstErrorsHarvest.reply.statusCode).toEqual(statusCode) + expect(secondErrorsHarvest.request.body.err).not.toEqual(expect.arrayContaining(firstErrorsHarvest.request.body.err)) + }) + ) +}) diff --git a/tests/specs/framework-detection.e2e.js b/tests/specs/framework-detection.e2e.js index e3be78da9..d50c84e51 100644 --- a/tests/specs/framework-detection.e2e.js +++ b/tests/specs/framework-detection.e2e.js @@ -8,8 +8,8 @@ const config = { } } -describe('framework detection', () => { - withBrowsersMatching(supportsFetchExtended)('detects a page built with REACT and sends a supportability metric', async () => { +describe.withBrowsersMatching(supportsFetchExtended)('framework detection', () => { + it('detects a page built with REACT and sends a supportability metric', async () => { await Promise.all([ browser.testHandle.expectRum(), browser.url(await browser.testHandle.assetURL('frameworks/react/simple-app/index.html', config)) @@ -26,7 +26,7 @@ describe('framework detection', () => { }])) }) - withBrowsersMatching(supportsFetchExtended)('detects a page built with ANGULAR and sends a supportability metric', async () => { + it('detects a page built with ANGULAR and sends a supportability metric', async () => { await Promise.all([ browser.testHandle.expectRum(), browser.url(await browser.testHandle.assetURL('frameworks/angular/simple-app/index.html', config)) @@ -43,7 +43,7 @@ describe('framework detection', () => { }])) }) - withBrowsersMatching(supportsFetchExtended)('detects a page built with NO FRAMEWORK and DOES NOT send a supportability metric', async () => { + it('detects a page built with NO FRAMEWORK and DOES NOT send a supportability metric', async () => { await Promise.all([ browser.testHandle.expectRum(), browser.url(await browser.testHandle.assetURL('frameworks/control.html', config)) diff --git a/tests/specs/harvesting/disable-harvesting.e2e.js b/tests/specs/harvesting/disable-harvesting.e2e.js new file mode 100644 index 000000000..eb4462242 --- /dev/null +++ b/tests/specs/harvesting/disable-harvesting.e2e.js @@ -0,0 +1,98 @@ +import { testRumRequest } from '../../../tools/testing-server/utils/expect-tests' + +describe('disable harvesting', () => { + it('should disable harvesting metrics and errors when err entitlement is 0', async () => { + browser.testHandle.scheduleReply('bamServer', { + test: testRumRequest, + body: `${JSON.stringify({ + stn: 1, + err: 0, + ins: 1, + cap: 1, + spa: 1, + loaded: 1 + }) + }` + }) + + const metricsPromise = browser.testHandle.expectMetrics(10000, true) + const errorsPromise = browser.testHandle.expectErrors(10000, true) + + await browser.url(await browser.testHandle.assetURL('obfuscate-pii.html')) + .then(() => browser.waitForAgentLoad()) + + await expect(metricsPromise).resolves.toEqual(undefined) + await expect(errorsPromise).resolves.toEqual(undefined) + }) + + it('should disable harvesting spa when spa entitlement is 0', async () => { + browser.testHandle.scheduleReply('bamServer', { + test: testRumRequest, + body: `${JSON.stringify({ + stn: 1, + err: 1, + ins: 1, + cap: 1, + spa: 0, + loaded: 1 + }) + }` + }) + + // Disable non-spa features that also use the events endpoint + const init = { + ajax: { enabled: false }, + page_view_timing: { enabled: false } + } + const eventsPromise = browser.testHandle.expectEvents(10000, true) + + await browser.url(await browser.testHandle.assetURL('obfuscate-pii.html', { init })) + .then(() => browser.waitForAgentLoad()) + + await expect(eventsPromise).resolves.toEqual(undefined) + }) + + it('should disable harvesting page actions when ins entitlement is 0', async () => { + browser.testHandle.scheduleReply('bamServer', { + test: testRumRequest, + body: `${JSON.stringify({ + stn: 1, + err: 1, + ins: 0, + cap: 1, + spa: 1, + loaded: 1 + }) + }` + }) + + const insPromise = browser.testHandle.expectIns(10000, true) + + await browser.url(await browser.testHandle.assetURL('obfuscate-pii.html')) + .then(() => browser.waitForAgentLoad()) + + await expect(insPromise).resolves.toEqual(undefined) + }) + + it('should disable harvesting session traces when stn entitlement is 0', async () => { + browser.testHandle.scheduleReply('bamServer', { + test: testRumRequest, + body: `${JSON.stringify({ + stn: 0, + err: 1, + ins: 1, + cap: 1, + spa: 1, + loaded: 1 + }) + }` + }) + + const stnPromise = browser.testHandle.expectResources(10000, true) + + await browser.url(await browser.testHandle.assetURL('obfuscate-pii.html')) + .then(() => browser.waitForAgentLoad()) + + await expect(stnPromise).resolves.toEqual(undefined) + }) +}) diff --git a/tests/specs/harvesting/final-harvesting.e2e.js b/tests/specs/harvesting/final-harvesting.e2e.js new file mode 100644 index 000000000..04be472b3 --- /dev/null +++ b/tests/specs/harvesting/final-harvesting.e2e.js @@ -0,0 +1,197 @@ +import { supportsFetch, reliableUnload } from '../../../tools/browser-matcher/common-matchers.mjs' + +describe('final harvesting', () => { + it.withBrowsersMatching(reliableUnload)('should send final harvest when navigating away from page', async () => { + await browser.url(await browser.testHandle.assetURL('final-harvest.html')) + .then(() => browser.waitForAgentLoad()) + + await browser.pause(500) + + const finalHarvest = Promise.all([ + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectMetrics(), + browser.testHandle.expectErrors(), + browser.testHandle.expectResources() + ]) + + await browser.execute(function () { + newrelic.noticeError(new Error('hippo hangry')) + newrelic.addPageAction('DummyEvent', { free: 'tacos' }) + }) + + await browser.url(await browser.testHandle.assetURL('/')) + + const [timingsResults, ajaxEventsResults, metricsResults, errorsResults, resourcesResults] = await finalHarvest + + expect(timingsResults.request.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'unload', + type: 'timing' + }) + ])) + expect(timingsResults.request.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'pageHide', + type: 'timing' + }) + ])) + expect(ajaxEventsResults.request.body.length).toBeGreaterThan(0) + expect(metricsResults.request.body.sm.length).toBeGreaterThan(0) + expect(errorsResults.request.body.err).toEqual(expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + message: 'hippo hangry' + }) + }) + ])) + expect(errorsResults.request.body.xhr.length).toBeGreaterThan(0) + expect(resourcesResults.request.body.res.length).toBeGreaterThan(0) + }) + + it.withBrowsersMatching(supportsFetch)('should use sendBeacon for unload harvests', async () => { + await browser.url(await browser.testHandle.assetURL('final-harvest.html')) + .then(() => browser.waitForAgentLoad()) + + await browser.pause(500) + + const finalHarvest = Promise.all([ + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectMetrics(), + browser.testHandle.expectErrors(), + browser.testHandle.expectResources() + ]) + + await browser.execute(function () { + newrelic.noticeError(new Error('hippo hangry')) + newrelic.addPageAction('DummyEvent', { free: 'tacos' }) + + const sendBeaconFn = navigator.sendBeacon.bind(navigator) + navigator.sendBeacon = function (url, body) { + sendBeaconFn.call(navigator, url + '&sendBeacon=true', body) + } + }) + + await browser.url(await browser.testHandle.assetURL('/')) + + const [timingsResults, ajaxEventsResults, metricsResults, errorsResults, resourcesResults] = await finalHarvest + + expect(timingsResults.request.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'unload', + type: 'timing' + }) + ])) + expect(timingsResults.request.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'pageHide', + type: 'timing' + }) + ])) + expect(ajaxEventsResults.request.body.length).toBeGreaterThan(0) + expect(metricsResults.request.body.sm.length).toBeGreaterThan(0) + expect(errorsResults.request.body.err).toEqual(expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + message: 'hippo hangry' + }) + }) + ])) + expect(errorsResults.request.body.xhr.length).toBeGreaterThan(0) + expect(resourcesResults.request.body.res.length).toBeGreaterThan(0) + + /* + sendBeacon can be flakey so we check to see if at least one of the network + calls used sendBeacon + */ + const sendBeaconUsage = [ + timingsResults.request.query.sendBeacon, + ajaxEventsResults.request.query.sendBeacon, + metricsResults.request.query.sendBeacon, + errorsResults.request.query.sendBeacon, + resourcesResults.request.query.sendBeacon + ] + expect(sendBeaconUsage).toContain('true') + }) + + it.withBrowsersMatching(supportsFetch)('should use fetch with keepalive when sendBeacon returns false', async () => { + await browser.url(await browser.testHandle.assetURL('final-harvest.html')) + .then(() => browser.waitForAgentLoad()) + + await browser.pause(500) + + const finalHarvest = Promise.all([ + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectMetrics(), + browser.testHandle.expectErrors(), + browser.testHandle.expectResources() + ]) + + await browser.execute(function () { + newrelic.noticeError(new Error('hippo hangry')) + newrelic.addPageAction('DummyEvent', { free: 'tacos' }) + + navigator.sendBeacon = function () { + return false + } + }) + + await browser.url(await browser.testHandle.assetURL('/')) + + const [timingsResults, ajaxEventsResults, metricsResults, errorsResults, resourcesResults] = await finalHarvest + + expect(timingsResults.request.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'unload', + type: 'timing' + }) + ])) + expect(timingsResults.request.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'pageHide', + type: 'timing' + }) + ])) + expect(ajaxEventsResults.request.body.length).toBeGreaterThan(0) + expect(metricsResults.request.body.sm.length).toBeGreaterThan(0) + expect(errorsResults.request.body.err).toEqual(expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + message: 'hippo hangry' + }) + }) + ])) + expect(errorsResults.request.body.xhr.length).toBeGreaterThan(0) + expect(resourcesResults.request.body.res.length).toBeGreaterThan(0) + }) + + it.withBrowsersMatching(reliableUnload)('should not send pageHide event twice', async () => { + await browser.url(await browser.testHandle.assetURL('pagehide.html')) + .then(() => browser.waitForAgentLoad()) + + await browser.pause(500) + + await $('#btn1').click() + + const timingsPromise = browser.testHandle.expectTimings() + + await browser.url(await browser.testHandle.assetURL('/')) + + const timingsResults = await timingsPromise + + expect(timingsResults.request.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'unload', + type: 'timing' + }) + ])) + expect(timingsResults.request.body).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'pageHide', + type: 'timing' + }) + ])) + }) +}) diff --git a/tests/specs/harvesting/index.e2e.js b/tests/specs/harvesting/index.e2e.js new file mode 100644 index 000000000..b8ab61c47 --- /dev/null +++ b/tests/specs/harvesting/index.e2e.js @@ -0,0 +1,329 @@ +import { faker } from '@faker-js/faker' +import { testResourcesRequest } from '../../../tools/testing-server/utils/expect-tests' + +describe('harvesting', () => { + it('should include the base query parameters', async () => { + const testURL = await browser.testHandle.assetURL('obfuscate-pii.html', { + config: { + sa: 1 + }, + init: { + privacy: { + cookies_enabled: true + } + } + }) + + const [ + rumResults, + resourcesResults, + interactionResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults, + insResults + ] = await Promise.all([ + browser.testHandle.expectRum(), + browser.testHandle.expectResources(), + browser.testHandle.expectInteractionEvents(), + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxTimeSlices(), + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectIns(), + browser.url(testURL) + .then(() => browser.waitForAgentLoad()) + .then(() => $('a').click()) + ]) + + const expectedURL = testURL.split('?')[0] + verifyBaseQueryParameters(rumResults.request.query, expectedURL) + verifyBaseQueryParameters(resourcesResults.request.query, expectedURL) + verifyBaseQueryParameters(interactionResults.request.query, expectedURL) + verifyBaseQueryParameters(timingsResults.request.query, expectedURL) + verifyBaseQueryParameters(ajaxSliceResults.request.query, expectedURL) + verifyBaseQueryParameters(ajaxEventsResults.request.query, expectedURL) + verifyBaseQueryParameters(insResults.request.query, expectedURL) + }) + + it('should include the ptid query parameter on requests after the first session trace harvest', async () => { + const ptid = faker.datatype.uuid() + browser.testHandle.scheduleReply('bamServer', { + test: testResourcesRequest, + body: ptid + }) + + await Promise.all([ + browser.testHandle.expectRum(), + browser.testHandle.expectResources(), + browser.url(await browser.testHandle.assetURL('obfuscate-pii.html')) + .then(() => browser.waitForAgentLoad()) + ]) + + const [ + resourcesResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults + ] = await Promise.all([ + browser.testHandle.expectResources(), + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxTimeSlices(), + browser.testHandle.expectAjaxEvents() + ]) + + expect(timingsResults.request.query.ptid).toEqual(ptid) + expect(ajaxSliceResults.request.query.ptid).toEqual(ptid) + expect(ajaxEventsResults.request.query.ptid).toEqual(ptid) + expect(resourcesResults.request.query.ptid).toEqual(ptid) + }) + + it('should include the transaction name (transactionName) passed in the info block in the query parameters', async () => { + const transactionName = faker.datatype.uuid() + const testURL = await browser.testHandle.assetURL('obfuscate-pii.html', { + config: { + transactionName + } + }) + + const [ + rumResults, + resourcesResults, + interactionResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults, + insResults + ] = await Promise.all([ + browser.testHandle.expectRum(), + browser.testHandle.expectResources(), + browser.testHandle.expectInteractionEvents(), + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxTimeSlices(), + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectIns(), + browser.url(testURL) + .then(() => browser.waitForAgentLoad()) + .then(() => $('a').click()) + ]) + + expect(rumResults.request.query.to).toEqual(transactionName) + expect(resourcesResults.request.query.to).toEqual(transactionName) + expect(interactionResults.request.query.to).toEqual(transactionName) + expect(timingsResults.request.query.to).toEqual(transactionName) + expect(ajaxSliceResults.request.query.to).toEqual(transactionName) + expect(ajaxEventsResults.request.query.to).toEqual(transactionName) + expect(insResults.request.query.to).toEqual(transactionName) + }) + + it('should include the transaction name (tNamePlain) passed in the info block in the query parameters', async () => { + const transactionName = faker.datatype.uuid() + const testURL = await browser.testHandle.assetURL('obfuscate-pii.html', { + config: { + tNamePlain: transactionName + } + }) + const [ + rumResults, + resourcesResults, + interactionResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults, + insResults + ] = await Promise.all([ + browser.testHandle.expectRum(), + browser.testHandle.expectResources(), + browser.testHandle.expectInteractionEvents(), + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxTimeSlices(), + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectIns(), + browser.url(testURL) + .then(() => browser.waitForAgentLoad()) + .then(() => $('a').click()) + ]) + + expect(rumResults.request.query.t).toEqual(transactionName) + expect(rumResults.request.query.to).toBeUndefined() + expect(resourcesResults.request.query.t).toEqual(transactionName) + expect(resourcesResults.request.query.to).toBeUndefined() + expect(interactionResults.request.query.t).toEqual(transactionName) + expect(interactionResults.request.query.to).toBeUndefined() + expect(timingsResults.request.query.t).toEqual(transactionName) + expect(timingsResults.request.query.to).toBeUndefined() + expect(ajaxSliceResults.request.query.t).toEqual(transactionName) + expect(ajaxSliceResults.request.query.to).toBeUndefined() + expect(ajaxEventsResults.request.query.t).toEqual(transactionName) + expect(ajaxEventsResults.request.query.to).toBeUndefined() + expect(insResults.request.query.t).toEqual(transactionName) + expect(insResults.request.query.to).toBeUndefined() + }) + + it('should always take the transactionName info parameter over the tNamePlan info parameter for the transaction name query parameter', async () => { + const transactionName = faker.datatype.uuid() + const testURL = await browser.testHandle.assetURL('obfuscate-pii.html', { + config: { + tNamePlain: faker.datatype.uuid(), + transactionName + } + }) + + const [ + rumResults, + resourcesResults, + interactionResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults, + insResults + ] = await Promise.all([ + browser.testHandle.expectRum(), + browser.testHandle.expectResources(), + browser.testHandle.expectInteractionEvents(), + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxTimeSlices(), + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectIns(), + browser.url(testURL) + .then(() => browser.waitForAgentLoad()) + .then(() => $('a').click()) + ]) + + expect(rumResults.request.query.to).toEqual(transactionName) + expect(rumResults.request.query.t).toBeUndefined() + expect(resourcesResults.request.query.to).toEqual(transactionName) + expect(resourcesResults.request.query.t).toBeUndefined() + expect(interactionResults.request.query.to).toEqual(transactionName) + expect(interactionResults.request.query.t).toBeUndefined() + expect(timingsResults.request.query.to).toEqual(transactionName) + expect(timingsResults.request.query.t).toBeUndefined() + expect(ajaxSliceResults.request.query.to).toEqual(transactionName) + expect(ajaxSliceResults.request.query.t).toBeUndefined() + expect(ajaxEventsResults.request.query.to).toEqual(transactionName) + expect(ajaxEventsResults.request.query.t).toBeUndefined() + expect(insResults.request.query.to).toEqual(transactionName) + expect(insResults.request.query.t).toBeUndefined() + }) + + it('should update the ref query parameter when url is changes using pushState during load', async () => { + const originalURL = await browser.testHandle.assetURL('referrer-pushstate.html') + const redirectURL = await browser.testHandle.assetURL('instrumented.html') + + const [ + rumResults, + resourcesResults, + interactionResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults + ] = await Promise.all([ + browser.testHandle.expectRum(), + browser.testHandle.expectResources(), + browser.testHandle.expectInteractionEvents(), + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxTimeSlices(), + browser.testHandle.expectAjaxEvents(), + browser.url(originalURL) + .then(() => browser.waitForAgentLoad()) + ]) + + const expectedURL = redirectURL.split('?')[0] + expect(rumResults.request.query.ref).toEqual(expectedURL) + expect(resourcesResults.request.query.ref).toEqual(expectedURL) + expect(interactionResults.request.query.ref).toEqual(expectedURL) + expect(timingsResults.request.query.ref).toEqual(expectedURL) + expect(ajaxSliceResults.request.query.ref).toEqual(expectedURL) + expect(ajaxEventsResults.request.query.ref).toEqual(expectedURL) + }) + + it('should update the ref query parameter when url is changes using replaceState during load', async () => { + const originalURL = await browser.testHandle.assetURL('referrer-replacestate.html') + const redirectURL = await browser.testHandle.assetURL('instrumented.html') + + const [ + rumResults, + resourcesResults, + interactionResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults + ] = await Promise.all([ + browser.testHandle.expectRum(), + browser.testHandle.expectResources(), + browser.testHandle.expectInteractionEvents(), + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxTimeSlices(), + browser.testHandle.expectAjaxEvents(), + browser.url(originalURL) + .then(() => browser.waitForAgentLoad()) + ]) + + const expectedURL = redirectURL.split('?')[0] + expect(rumResults.request.query.ref).toEqual(expectedURL) + expect(resourcesResults.request.query.ref).toEqual(expectedURL) + expect(interactionResults.request.query.ref).toEqual(expectedURL) + expect(timingsResults.request.query.ref).toEqual(expectedURL) + expect(ajaxSliceResults.request.query.ref).toEqual(expectedURL) + expect(ajaxEventsResults.request.query.ref).toEqual(expectedURL) + }) + + it('should set session query parameter to 0 when cookies_enabled is false', async () => { + const testURL = await browser.testHandle.assetURL('obfuscate-pii.html', { + init: { + privacy: { + cookies_enabled: false + } + } + }) + + const [ + rumResults, + resourcesResults, + interactionResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults + ] = await Promise.all([ + browser.testHandle.expectRum(), + browser.testHandle.expectResources(), + browser.testHandle.expectInteractionEvents(), + browser.testHandle.expectTimings(), + browser.testHandle.expectAjaxTimeSlices(), + browser.testHandle.expectAjaxEvents(), + browser.url(testURL) + .then(() => browser.waitForAgentLoad()) + ]) + + expect(rumResults.request.query.s).toEqual('0') + expect(resourcesResults.request.query.s).toEqual('0') + expect(interactionResults.request.query.s).toEqual('0') + expect(timingsResults.request.query.s).toEqual('0') + expect(ajaxSliceResults.request.query.s).toEqual('0') + expect(ajaxEventsResults.request.query.s).toEqual('0') + }) + + it('should not harvest features when there is no data', async () => { + const [ + errorsResults, + insResults + ] = await Promise.all([ + browser.testHandle.expectErrors(10000, true), + browser.testHandle.expectIns(10000, true), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + ]) + + expect(errorsResults).toBeUndefined() + expect(insResults).toBeUndefined() + }) +}) + +function verifyBaseQueryParameters (queryParams, expectedURL) { + expect(queryParams.a).toEqual('42') + expect(queryParams.sa).toEqual('1') + expect(queryParams.v).toMatch(/^\d{1,3}\.\d{1,3}\.\d{1,3}$/) + expect(queryParams.t).toEqual('Unnamed Transaction') + expect(queryParams.rst).toMatch(/^\d{1,5}$/) + expect(queryParams.s).toMatch(/^[A-F\d]{16}$/i) + expect(queryParams.ref).toEqual(expectedURL) +} diff --git a/tests/specs/ins/retry-harvesting.e2e.js b/tests/specs/ins/retry-harvesting.e2e.js new file mode 100644 index 000000000..527e8147b --- /dev/null +++ b/tests/specs/ins/retry-harvesting.e2e.js @@ -0,0 +1,69 @@ +import { testInsRequest } from '../../../tools/testing-server/utils/expect-tests' + +describe('ins retry harvesting', () => { + [408, 429, 500, 503].forEach(statusCode => + it(`should send the page action on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testInsRequest, + permanent: true, + statusCode + }) + + const [firstPageActionsHarvest] = await Promise.all([ + browser.testHandle.expectIns(), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + .then(() => browser.execute(function () { + newrelic.addPageAction('DummyEvent', { free: 'tacos' }) + })) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const [secondPageActionsHarvest] = await Promise.all([ + browser.testHandle.expectIns(), + browser.execute(function () { + newrelic.addPageAction('DummyEvent2', { free: 'more tacos' }) + }) + ]) + + expect(firstPageActionsHarvest.reply.statusCode).toEqual(statusCode) + expect(secondPageActionsHarvest.request.body.ins).toEqual(expect.arrayContaining(firstPageActionsHarvest.request.body.ins)) + }) + ); + + [400, 404, 502, 504, 512].forEach(statusCode => + it(`should not send the page action on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testInsRequest, + permanent: true, + statusCode + }) + + const [firstPageActionsHarvest] = await Promise.all([ + browser.testHandle.expectIns(), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + .then(() => browser.execute(function () { + newrelic.addPageAction('DummyEvent', { free: 'tacos' }) + })) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const [secondPageActionsHarvest] = await Promise.all([ + browser.testHandle.expectIns(), + browser.execute(function () { + newrelic.addPageAction('DummyEvent2', { free: 'more tacos' }) + }) + ]) + + expect(firstPageActionsHarvest.reply.statusCode).toEqual(statusCode) + expect(secondPageActionsHarvest.request.body.ins).not.toEqual(expect.arrayContaining(firstPageActionsHarvest.request.body.ins)) + }) + ) +}) diff --git a/tests/specs/metrics.e2e.js b/tests/specs/metrics.e2e.js index aee1d7b9a..6992e900f 100644 --- a/tests/specs/metrics.e2e.js +++ b/tests/specs/metrics.e2e.js @@ -3,10 +3,10 @@ import { reliableUnload, supportsFetch } from '../../tools/browser-matcher/commo const loaderTypes = ['rum', 'full', 'spa'] const loaderTypesMapped = { rum: 'lite', full: 'pro', spa: 'spa' } -describe('metrics', () => { +describe.withBrowsersMatching(reliableUnload)('metrics', () => { loaderTypes.forEach(lt => loaderTypeSupportabilityMetric(lt)) - withBrowsersMatching(reliableUnload)('should send SMs for endpoint bytes', async () => { + it('should send SMs for endpoint bytes', async () => { await Promise.all([ browser.testHandle.expectEvents(), browser.testHandle.expectResources(), @@ -75,7 +75,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should send SMs for resources seen', async () => { + it('should send SMs for resources seen', async () => { await browser.url(await browser.testHandle.assetURL('resources.html')) .then(() => browser.waitForAgentLoad()) @@ -103,7 +103,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should send CMs and SMs when calling agent api methods', async () => { + it('should send CMs and SMs when calling agent api methods', async () => { await browser.url(await browser.testHandle.assetURL('api/customMetrics.html')) .then(() => browser.waitForAgentLoad()) @@ -160,7 +160,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should create SMs for valid obfuscation rules', async () => { + it('should create SMs for valid obfuscation rules', async () => { await browser.url(await browser.testHandle.assetURL('obfuscate-pii-valid.html')) .then(() => browser.waitForAgentLoad()) @@ -180,7 +180,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should create SMs for obfuscation rule containing invalid regex type', async () => { + it('should create SMs for obfuscation rule containing invalid regex type', async () => { await browser.url(await browser.testHandle.assetURL('obfuscate-pii-invalid-regex-type.html')) .then(() => browser.waitForAgentLoad()) @@ -200,7 +200,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should create SMs for obfuscation rule containing undefined regex type', async () => { + it('should create SMs for obfuscation rule containing undefined regex type', async () => { await browser.url(await browser.testHandle.assetURL('obfuscate-pii-invalid-regex-undefined.html')) .then(() => browser.waitForAgentLoad()) @@ -220,7 +220,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should create SMs for obfuscation rule containing invalid replacement type', async () => { + it('should create SMs for obfuscation rule containing invalid replacement type', async () => { await browser.url(await browser.testHandle.assetURL('obfuscate-pii-invalid-replacement-type.html')) .then(() => browser.waitForAgentLoad()) @@ -240,7 +240,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should send SMs for polyfilled native functions', async () => { + it('should send SMs for polyfilled native functions', async () => { await browser.url(await browser.testHandle.assetURL('polyfill-metrics.html')) .then(() => browser.waitForAgentLoad()) @@ -308,7 +308,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should send SMs for session trace duration', async () => { + it('should send SMs for session trace duration', async () => { await browser.url(await browser.testHandle.assetURL('instrumented.html')) .then(() => browser.waitForAgentLoad()) @@ -324,7 +324,7 @@ describe('metrics', () => { }])) }) - withBrowsersMatching(reliableUnload)('should send SMs for custom data size', async () => { + it('should send SMs for custom data size', async () => { await browser.url(await browser.testHandle.assetURL('instrumented.html')) .then(() => browser.waitForAgentLoad()) @@ -346,7 +346,7 @@ describe('metrics', () => { }) function loaderTypeSupportabilityMetric (loaderType) { - withBrowsersMatching([reliableUnload, supportsFetch])(`generic agent info captured for ${loaderType} loader`, async () => { + it.withBrowsersMatching([reliableUnload, supportsFetch])(`generic agent info captured for ${loaderType} loader`, async () => { await browser.url(await browser.testHandle.assetURL('instrumented.html', { loader: loaderType })) .then(() => browser.waitForAgentLoad()) diff --git a/tests/specs/obfuscate.e2e.js b/tests/specs/obfuscate.e2e.js index 1509e0165..607d5011b 100644 --- a/tests/specs/obfuscate.e2e.js +++ b/tests/specs/obfuscate.e2e.js @@ -23,8 +23,8 @@ const config = { } } -describe('obfuscate rules', () => { - withBrowsersMatching(supportsFetchExtended)('should apply to all payloads', async () => { +describe.withBrowsersMatching(supportsFetchExtended)('obfuscate rules', () => { + it('should apply to all payloads', async () => { const spaPromise = browser.testHandle.expectEvents() const ajaxPromise = browser.testHandle.expectAjaxEvents() const timingsPromise = browser.testHandle.expectTimings() diff --git a/tests/specs/rum/retry-harvesting.e2e.js b/tests/specs/rum/retry-harvesting.e2e.js new file mode 100644 index 000000000..c6e5445a2 --- /dev/null +++ b/tests/specs/rum/retry-harvesting.e2e.js @@ -0,0 +1,60 @@ +import { testAjaxEventsRequest, testAjaxTimeSlicesRequest, testInsRequest, testInteractionEventsRequest, testResourcesRequest, testRumRequest, testTimingEventsRequest } from '../../../tools/testing-server/utils/expect-tests' + +describe('rum retry harvesting', () => { + [400, 404, 408, 429, 500, 502, 503, 504, 512].forEach(statusCode => { + it(`should not retry rum and should not continue harvesting when request statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testRumRequest, + permanent: true, + statusCode, + body: '' + }) + + const [ + resourcesResults, + interactionResults, + timingsResults, + ajaxSliceResults, + ajaxEventsResults, + insResults, + rumResults + ] = await Promise.all([ + browser.testHandle.expectResources(10000, true), + browser.testHandle.expectInteractionEvents(10000, true), + browser.testHandle.expectTimings(10000, true), + browser.testHandle.expectAjaxTimeSlices(10000, true), + browser.testHandle.expectAjaxEvents(10000, true), + browser.testHandle.expectIns(10000, true), + browser.testHandle.expectRum(), + browser.url(await browser.testHandle.assetURL('obfuscate-pii.html')) + .then(() => $('a').click()) + ]) + + // Uncomment this code to reproduce the issue described in https://issues.newrelic.com/browse/NEWRELIC-9348 + // Leave uncommented once that ticket is worked + // await browser.pause(500) + + // await browser.execute(function () { + // newrelic.noticeError(new Error('hippo hangry')) + // newrelic.addPageAction('DummyEvent', { free: 'tacos' }) + // }) + + // await Promise.all([ + // browser.testHandle.expectTimings(10000, true), + // browser.testHandle.expectAjaxEvents(10000, true), + // browser.testHandle.expectMetrics(10000, true), + // browser.testHandle.expectErrors(10000, true), + // browser.testHandle.expectResources(10000, true), + // browser.url(await browser.testHandle.assetURL('/')) + // ]) + + expect(rumResults.reply.statusCode).toEqual(statusCode) + expect(resourcesResults).toBeUndefined() + expect(interactionResults).toBeUndefined() + expect(timingsResults).toBeUndefined() + expect(ajaxSliceResults).toBeUndefined() + expect(ajaxEventsResults).toBeUndefined() + expect(insResults).toBeUndefined() + }) + }) +}) diff --git a/tests/specs/session-manager.e2e.js b/tests/specs/session-manager.e2e.js index 53c791226..fabb5bd96 100644 --- a/tests/specs/session-manager.e2e.js +++ b/tests/specs/session-manager.e2e.js @@ -65,7 +65,7 @@ describe('newrelic session ID', () => { expect(ls2.expiresAt).toEqual(ls1.expiresAt) }) - withBrowsersMatching(supportsMultipleTabs)('should keep a session id across page loads - Multi tab navigation', async () => { + it.withBrowsersMatching(supportsMultipleTabs)('should keep a session id across page loads - Multi tab navigation', async () => { await browser.url(await browser.testHandle.assetURL('session-entity.html', config)) .then(() => browser.waitForAgentLoad()) diff --git a/tests/specs/session-replay/harvest.e2e.js b/tests/specs/session-replay/harvest.e2e.js index 692055733..be7a5e7ed 100644 --- a/tests/specs/session-replay/harvest.e2e.js +++ b/tests/specs/session-replay/harvest.e2e.js @@ -1,7 +1,8 @@ +import { notIE } from '../../../tools/browser-matcher/common-matchers.mjs' import { config, getSR } from './helpers' -describe('Session Replay Harvest Behavior', () => { +describe.withBrowsersMatching(notIE)('Session Replay Harvest Behavior', () => { beforeEach(async () => { await browser.enableSessionReplay() }) @@ -12,8 +13,10 @@ describe('Session Replay Harvest Behavior', () => { it('Should harvest early if exceeds preferred size - mocked', async () => { const startTime = Date.now() - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ harvestTimeSeconds: 60 }))) - await browser.waitForAgentLoad() + + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { harvestTimeSeconds: 60 } }))) + .then(() => browser.waitForSessionReplayRecording()) + const [{ request: blobHarvest }] = await Promise.all([ browser.testHandle.expectBlob(10000), // preferred size = 64kb, compression estimation is 88% @@ -29,15 +32,16 @@ describe('Session Replay Harvest Behavior', () => { it('Should abort if exceeds maximum size - mocked', async () => { const startTime = Date.now() - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ harvestTimeSeconds: 60 }))) - .then(() => browser.waitForAgentLoad()) + + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { harvestTimeSeconds: 60 } }))) + .then(() => browser.waitForSessionReplayRecording()) await browser.execute(function () { Object.values(newrelic.initializedAgents)[0].features.session_replay.featAggregate.payloadBytesEstimation = 1000001 / 0.12 document.querySelector('body').click() }) - expect((await getSR())).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ blocked: true, initialized: true })) @@ -49,8 +53,8 @@ describe('Session Replay Harvest Behavior', () => { const [{ request: blobHarvest }] = await Promise.all([ browser.testHandle.expectBlob(), - browser.url(await browser.testHandle.assetURL('64kb-dom.html', config())), - browser.waitForAgentLoad() + browser.url(await browser.testHandle.assetURL('64kb-dom.html', config({ session_replay: { harvestTimeSeconds: 60 } }))) + .then(() => browser.waitForSessionReplayRecording()) ]) expect(blobHarvest.body.blob.length).toBeGreaterThan(0) @@ -59,10 +63,11 @@ describe('Session Replay Harvest Behavior', () => { it('Should abort if exceeds maximum size - real', async () => { const startTime = Date.now() - await browser.url(await browser.testHandle.assetURL('1mb-dom.html', config({ harvestTimeSeconds: 60 }))) - .then(() => browser.waitForAgentLoad()) - expect((await getSR())).toEqual(expect.objectContaining({ + await browser.url(await browser.testHandle.assetURL('1mb-dom.html', config({ session_replay: { harvestTimeSeconds: 60 } }))) + .then(() => browser.waitForSessionReplayRecording()) + + await expect(getSR()).resolves.toEqual(expect.objectContaining({ blocked: true, initialized: true })) diff --git a/tests/specs/session-replay/helpers.js b/tests/specs/session-replay/helpers.js index a3bef89e3..fb0ea9841 100644 --- a/tests/specs/session-replay/helpers.js +++ b/tests/specs/session-replay/helpers.js @@ -1,3 +1,5 @@ +import { deepmerge } from 'deepmerge-ts' + export const RRWEB_EVENT_TYPES = { DomContentLoaded: 0, Load: 1, @@ -7,14 +9,21 @@ export const RRWEB_EVENT_TYPES = { Custom: 5 } -export function config (props = {}) { - return { - loader: 'experimental', - init: { - privacy: { cookies_enabled: true }, - session_replay: { enabled: true, harvestTimeSeconds: 5, sampleRate: 1, errorSampleRate: 0, ...props } +export function config (initOverrides = {}) { + return deepmerge( + { + loader: 'experimental', + init: { + privacy: { cookies_enabled: true }, + session_replay: { enabled: true, harvestTimeSeconds: 5, sampleRate: 1, errorSampleRate: 0 } + } + }, + { + init: { + ...initOverrides + } } - } + ) } export async function getSR () { diff --git a/tests/specs/session-replay/ingest.e2e.js b/tests/specs/session-replay/ingest.e2e.js index 7f8bd6a31..4770d0000 100644 --- a/tests/specs/session-replay/ingest.e2e.js +++ b/tests/specs/session-replay/ingest.e2e.js @@ -1,7 +1,8 @@ +import { notIE } from '../../../tools/browser-matcher/common-matchers.mjs' +import { testBlobRequest } from '../../../tools/testing-server/utils/expect-tests' import { config, getSR } from './helpers' -import { testRumRequest, testBlobRequest } from '../../../tools/testing-server/utils/expect-tests' -describe('Session Replay Ingest Behavior', () => { +describe.withBrowsersMatching(notIE)('Session Replay Ingest Behavior', () => { beforeEach(async () => { await browser.enableSessionReplay() }) @@ -12,7 +13,7 @@ describe('Session Replay Ingest Behavior', () => { it('Should empty event buffer when sending', async () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) - .then(() => browser.waitForAgentLoad()) + .then(() => browser.waitForSessionReplayRecording()) expect((await getSR()).events.length).toBeGreaterThan(0) @@ -23,9 +24,9 @@ describe('Session Replay Ingest Behavior', () => { it('Should stop recording if 429 response', async () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) - .then(() => browser.waitForAgentLoad()) + .then(() => browser.waitForSessionReplayRecording()) - expect(await getSR()).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ events: expect.any(Array), initialized: true, recording: true, @@ -34,14 +35,14 @@ describe('Session Replay Ingest Behavior', () => { })) await Promise.all([ - browser.testHandle.expectBlob(), browser.testHandle.scheduleReply('bamServer', { test: testBlobRequest, statusCode: 429 - }) + }), + browser.testHandle.expectBlob() ]) - expect(await getSR()).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ events: [], initialized: true, recording: false, diff --git a/tests/specs/session-replay/initialization.e2e.js b/tests/specs/session-replay/initialization.e2e.js index 8ee7ba6de..df0b079e9 100644 --- a/tests/specs/session-replay/initialization.e2e.js +++ b/tests/specs/session-replay/initialization.e2e.js @@ -1,7 +1,7 @@ +import { notIE } from '../../../tools/browser-matcher/common-matchers.mjs' import { config, getSR } from './helpers' -import { testRumRequest } from '../../../tools/testing-server/utils/expect-tests' -describe('Session Replay Initialization', () => { +describe.withBrowsersMatching(notIE)('Session Replay Initialization', () => { beforeEach(async () => { await browser.enableSessionReplay() }) @@ -10,44 +10,40 @@ describe('Session Replay Initialization', () => { await browser.destroyAgentSession(browser.testHandle) }) - describe('Feature flags', () => { - it('should not run if flag is 0', async () => { - await browser.testHandle.clearScheduledReplies('bamServer') - - const [rumResp] = await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) - .then(() => Promise.all([browser.testHandle.expectRum(), browser.waitForAgentLoad()])) - - expect(JSON.parse(rumResp.reply.body)).toEqual(expect.objectContaining({ - sr: 0 - })) - const sr = await getSR() - expect(sr.initialized).toEqual(true) - expect(sr.recording).toEqual(false) - }) - - it('should run if flag is 1', async () => { - await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) - .then(() => browser.waitForAgentLoad()) - - const { initialized, recording } = await getSR() - expect(initialized).toEqual(true) - expect(recording).toEqual(true) - }) - - it('should not run if cookies_enabled is false', async () => { - await browser.url(await browser.testHandle.assetURL('instrumented.html', { ...config(), init: { ...config().init, privacy: { cookies_enabled: false } } })) - .then(() => browser.waitForAgentLoad()) - - const { exists } = await getSR() - expect(exists).toEqual(false) - }) - - it('should not run if session_trace is disabled', async () => { - await browser.url(await browser.testHandle.assetURL('instrumented.html', { ...config(), init: { ...config().init, session_trace: { enabled: false } } })) - .then(() => browser.waitForAgentLoad()) - - const { exists } = await getSR() - expect(exists).toEqual(false) - }) + it('should not start recording if rum response sr flag is 0', async () => { + await browser.testHandle.clearScheduledReplies('bamServer') + + const [rumResp] = await Promise.all([ + browser.testHandle.expectRum(), + browser.url(await browser.testHandle.assetURL('instrumented.html', config())) + .then(() => browser.waitForFeatureAggregate('session_replay')) + ]) + + expect(JSON.parse(rumResp.reply.body).sr).toEqual(0) + + const sr = await getSR() + expect(sr.initialized).toEqual(true) + expect(sr.recording).toEqual(false) + }) + + it('should start recording if rum response sr flag is 1', async () => { + await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) + .then(() => browser.waitForAgentLoad()) + + await expect(browser.waitForSessionReplayRecording()).resolves.toBeUndefined() + }) + + it('should not load the aggregate if cookies_enabled is false', async () => { + await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ privacy: { cookies_enabled: false } }))) + .then(() => browser.waitForAgentLoad()) + + await expect(browser.waitForFeatureAggregate('session_replay', 5000)).rejects.toThrow() + }) + + it('should not run if session_trace is disabled', async () => { + await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ session_trace: { enabled: false } }))) + .then(() => browser.waitForAgentLoad()) + + await expect(browser.waitForFeatureAggregate('session_replay', 5000)).rejects.toThrow() }) }) diff --git a/tests/specs/session-replay/mode.e2e.js b/tests/specs/session-replay/mode.e2e.js index 56f041691..1ab77cf9b 100644 --- a/tests/specs/session-replay/mode.e2e.js +++ b/tests/specs/session-replay/mode.e2e.js @@ -1,7 +1,7 @@ -import { testRumRequest } from '../../../tools/testing-server/utils/expect-tests' +import { notIE } from '../../../tools/browser-matcher/common-matchers.mjs' import { config, getSR } from './helpers' -describe('Session Replay Sample Mode Validation', () => { +describe.withBrowsersMatching(notIE)('Session Replay Sample Mode Validation', () => { beforeEach(async () => { await browser.enableSessionReplay() }) @@ -11,12 +11,10 @@ describe('Session Replay Sample Mode Validation', () => { }) it('Full 1 Error 1 === FULL', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ sampleRate: 1, errorSampleRate: 1 }))) - .then(() => browser.waitForAgentLoad()) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampleRate: 1, errorSampleRate: 1 } }))) + .then(() => browser.waitForSessionReplayRecording()) - const sr = await getSR() - - expect(sr).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: true, initialized: true, events: expect.any(Array), @@ -25,12 +23,10 @@ describe('Session Replay Sample Mode Validation', () => { }) it('Full 1 Error 0 === FULL', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ sampleRate: 1, errorSampleRate: 0 }))) - .then(() => browser.waitForAgentLoad()) - - const sr = await getSR() + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampleRate: 1, errorSampleRate: 0 } }))) + .then(() => browser.waitForSessionReplayRecording()) - expect(sr).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: true, initialized: true, events: expect.any(Array), @@ -39,12 +35,10 @@ describe('Session Replay Sample Mode Validation', () => { }) it('Full 0 Error 1 === ERROR', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ sampleRate: 0, errorSampleRate: 1 }))) - .then(() => browser.waitForAgentLoad()) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampleRate: 0, errorSampleRate: 1 } }))) + .then(() => browser.waitForSessionReplayRecording()) - const sr = await getSR() - - expect(sr).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: true, initialized: true, events: expect.any(Array), @@ -53,12 +47,10 @@ describe('Session Replay Sample Mode Validation', () => { }) it('Full 0 Error 0 === OFF', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ sampleRate: 0, errorSampleRate: 0 }))) - .then(() => browser.waitForAgentLoad()) - - const sr = await getSR() + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampleRate: 0, errorSampleRate: 0 } }))) + .then(() => browser.waitForFeatureAggregate('session_replay')) - expect(sr).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: false, initialized: true, events: [], @@ -67,10 +59,10 @@ describe('Session Replay Sample Mode Validation', () => { }) it('ERROR (seen after init) => FULL', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ sampleRate: 0, errorSampleRate: 1 }))) - .then(() => browser.waitForAgentLoad()) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampleRate: 0, errorSampleRate: 1 } }))) + .then(() => browser.waitForSessionReplayRecording()) - expect(await getSR()).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: true, initialized: true, events: expect.any(Array), @@ -81,7 +73,7 @@ describe('Session Replay Sample Mode Validation', () => { newrelic.noticeError(new Error('test')) }) - expect(await getSR()).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: true, initialized: true, events: expect.any(Array), @@ -90,12 +82,13 @@ describe('Session Replay Sample Mode Validation', () => { }) it('ERROR (seen before init) => FULL', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ sampleRate: 0, errorSampleRate: 1 }))) - .then(() => Promise.all([browser.execute(function () { + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampleRate: 0, errorSampleRate: 1 } }))) + .then(() => browser.execute(function () { newrelic.noticeError(new Error('test')) - }), browser.waitForAgentLoad()])) + })) + .then(() => browser.waitForSessionReplayRecording()) - expect(await getSR()).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: true, initialized: true, events: expect.any(Array), @@ -104,10 +97,10 @@ describe('Session Replay Sample Mode Validation', () => { }) it('FULL => OFF', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ sampleRate: 1, errorSampleRate: 0 }))) - .then(() => browser.waitForAgentLoad()) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampleRate: 1, errorSampleRate: 0 } }))) + .then(() => browser.waitForSessionReplayRecording()) - expect(await getSR()).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: true, initialized: true, events: expect.any(Array), @@ -118,7 +111,7 @@ describe('Session Replay Sample Mode Validation', () => { Object.values(NREUM.initializedAgents)[0].runtime.session.reset() }) - expect(await getSR()).toEqual(expect.objectContaining({ + await expect(getSR()).resolves.toEqual(expect.objectContaining({ recording: false, initialized: true, events: expect.any(Array), diff --git a/tests/specs/session-replay/payload.e2e.js b/tests/specs/session-replay/payload.e2e.js index a0c86c462..57219de81 100644 --- a/tests/specs/session-replay/payload.e2e.js +++ b/tests/specs/session-replay/payload.e2e.js @@ -1,7 +1,7 @@ -import { testRumRequest } from '../../../tools/testing-server/utils/expect-tests' +import { notIE } from '../../../tools/browser-matcher/common-matchers.mjs' import { config } from './helpers' -describe('Session Replay Payload Validation', () => { +describe.withBrowsersMatching(notIE)('Session Replay Payload Validation', () => { beforeEach(async () => { await browser.enableSessionReplay() }) @@ -22,8 +22,9 @@ describe('Session Replay Payload Validation', () => { it('should match expected payload - standard', async () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForAgentLoad()) - const { localStorage } = await browser.getAgentSessionInfo() + const { request: harvestContents } = await browser.testHandle.expectBlob() + const { localStorage } = await browser.getAgentSessionInfo() expect(harvestContents.query).toMatchObject({ protocol_version: '0', @@ -50,11 +51,14 @@ describe('Session Replay Payload Validation', () => { it('should match expected payload - error', async () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForAgentLoad()) + + const [{ request: harvestContents }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + newrelic.noticeError(new Error('test')) + }) + ]) const { localStorage } = await browser.getAgentSessionInfo() - await browser.execute(function () { - newrelic.noticeError(new Error('test')) - }) - const { request: harvestContents } = await browser.testHandle.expectBlob() expect(harvestContents.query).toMatchObject({ protocol_version: '0', diff --git a/tests/specs/session-replay/rrweb-configuration.e2e.js b/tests/specs/session-replay/rrweb-configuration.e2e.js index ffd5cf66d..704273736 100644 --- a/tests/specs/session-replay/rrweb-configuration.e2e.js +++ b/tests/specs/session-replay/rrweb-configuration.e2e.js @@ -1,7 +1,7 @@ -import { testRumRequest } from '../../../tools/testing-server/utils/expect-tests' +import { notIE } from '../../../tools/browser-matcher/common-matchers.mjs' import { config } from './helpers' -describe('Rrweb Configuration', () => { +describe.withBrowsersMatching(notIE)('RRWeb Configuration', () => { beforeEach(async () => { await browser.enableSessionReplay() }) @@ -13,7 +13,7 @@ describe('Rrweb Configuration', () => { describe('enabled', () => { it('enabled: true should import feature', async () => { await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) - .then(() => browser.waitForAgentLoad()) + .then(() => browser.waitForFeatureAggregate('session_replay')) const wasInitialized = await browser.execute(function () { return Object.values(newrelic.initializedAgents)[0].features.session_replay.featAggregate.initialized @@ -23,18 +23,10 @@ describe('Rrweb Configuration', () => { }) it('enabled: false should NOT import feature', async () => { - await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ enabled: false }))) + await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ session_replay: { enabled: false } }))) .then(() => browser.waitForAgentLoad()) - const wasInitialized = await browser.execute(function () { - try { - return Object.values(newrelic.initializedAgents)[0].features.session_replay.featAggregate.initialized - } catch (err) { - return false - } - }) - - expect(wasInitialized).toEqual(false) + await expect(browser.waitForFeatureAggregate('session_replay', 10000)).rejects.toThrow() }) }) @@ -43,22 +35,26 @@ describe('Rrweb Configuration', () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea#plain').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea#plain').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) it('maskAllInputs: false should NOT convert inputs to *', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskAllInputs: false }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskAllInputs: false } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea#plain').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea#plain').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeTruthy() }) @@ -66,7 +62,7 @@ describe('Rrweb Configuration', () => { describe('maskTextSelector', () => { it('maskTextSelector: "*" should convert all text to *', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskAllInputs: false }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskAllInputs: false } }))) .then(() => browser.waitForAgentLoad()) const { request: { body } } = await browser.testHandle.expectBlob() @@ -75,7 +71,7 @@ describe('Rrweb Configuration', () => { }) it('maskTextSelector: "null" should convert NO text to "*"', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskAllInputs: false }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskAllInputs: false } }))) .then(() => browser.waitForAgentLoad()) const { request: { body } } = await browser.testHandle.expectBlob() @@ -86,25 +82,29 @@ describe('Rrweb Configuration', () => { describe('ignoreClass', () => { it('ignoreClass: nr-ignore should ignore elem', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskAllInputs: false }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskAllInputs: false } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea.nr-ignore').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea.nr-ignore').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) it('ignoreClass: cannot be overridden', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskAllInputs: false, ignoreClass: null }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskAllInputs: false, ignoreClass: null } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea.nr-ignore').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea.nr-ignore').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) @@ -112,25 +112,29 @@ describe('Rrweb Configuration', () => { describe('blockClass', () => { it('blockClass: nr-block should block elem', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskAllInputs: false }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskAllInputs: false } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea.nr-block').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea.nr-block').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) it('blockClass: cannot be overridden', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskAllInputs: false, blockClass: null }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskAllInputs: false, blockClass: null } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea.nr-block').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea.nr-block').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) @@ -138,25 +142,29 @@ describe('Rrweb Configuration', () => { describe('maskTextClass', () => { it('maskTextClass: should mask elem', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea.nr-mask').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea.nr-mask').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) it('maskTextClass: cannot be overridden', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskTextClass: null }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskTextClass: null } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea.nr-mask').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea.nr-mask').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) @@ -164,39 +172,45 @@ describe('Rrweb Configuration', () => { describe('blockSelector', () => { it('blockSelector: nr-data-block should block elem', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskAllInputs: false }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskAllInputs: false } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea[data-nr-block]').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea[data-nr-block]').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) it('blockSelector: only applies to specified elem', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskAllInputs: false }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskAllInputs: false } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea[data-nr-block]').value = 'testing' - document.querySelector('textarea[data-other-block]').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea[data-nr-block]').value = 'testing' + document.querySelector('textarea[data-other-block]').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeTruthy() }) it('blockSelector: can be extended but not overridden', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskAllInputs: false, blockSelector: '[data-other-block]' }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskAllInputs: false, blockSelector: '[data-other-block]' } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('textarea[data-nr-block]').value = 'testing' - document.querySelector('textarea[data-other-block]').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('textarea[data-nr-block]').value = 'testing' + document.querySelector('textarea[data-other-block]').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) @@ -204,26 +218,30 @@ describe('Rrweb Configuration', () => { describe('maskInputOptions', () => { it('maskInputOptions: nr-data-block should block elem', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('#pass-input').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('#pass-input').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) it('maskInputOptions: can be extended but not overridden', async () => { - await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ maskTextSelector: null, maskInputOptions: { text: true } }))) + await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { maskTextSelector: null, maskInputOptions: { text: true } } }))) .then(() => browser.waitForAgentLoad()) - await browser.execute(function () { - document.querySelector('#pass-input').value = 'testing' - document.querySelector('#text-input').value = 'testing' - }) - const { request: { body } } = await browser.testHandle.expectBlob() + const [{ request: { body } }] = await Promise.all([ + browser.testHandle.expectBlob(), + browser.execute(function () { + document.querySelector('#pass-input').value = 'testing' + document.querySelector('#text-input').value = 'testing' + }) + ]) expect(body.blob.includes('testing')).toBeFalsy() }) diff --git a/tests/specs/session-replay/session-pages.e2e.js b/tests/specs/session-replay/session-pages.e2e.js index 46a42be9f..a6594ee06 100644 --- a/tests/specs/session-replay/session-pages.e2e.js +++ b/tests/specs/session-replay/session-pages.e2e.js @@ -1,8 +1,7 @@ -import { supportsMultipleTabs } from '../../../tools/browser-matcher/common-matchers.mjs' -import { testRumRequest } from '../../../tools/testing-server/utils/expect-tests.js' -import { config, getSR } from './helpers' +import { supportsMultipleTabs, notIE } from '../../../tools/browser-matcher/common-matchers.mjs' +import { config } from './helpers' -describe('Session Replay Across Pages', () => { +describe.withBrowsersMatching(notIE)('Session Replay Across Pages', () => { beforeEach(async () => { await browser.enableSessionReplay() }) @@ -15,8 +14,8 @@ describe('Session Replay Across Pages', () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForAgentLoad()) - const { localStorage } = await browser.getAgentSessionInfo() const { request: page1Contents } = await browser.testHandle.expectBlob(10000) + const { localStorage } = await browser.getAgentSessionInfo() expect(page1Contents.query).toMatchObject({ protocol_version: '0', @@ -39,18 +38,7 @@ describe('Session Replay Across Pages', () => { } }) - await browser.testHandle.scheduleReply('bamServer', { - test: testRumRequest, - body: JSON.stringify({ - stn: 1, - err: 1, - ins: 1, - cap: 1, - spa: 1, - loaded: 1, - sr: 1 - }) - }) + await browser.enableSessionReplay() await browser.refresh() .then(() => browser.waitForAgentLoad()) @@ -81,8 +69,9 @@ describe('Session Replay Across Pages', () => { it('should record across same-tab page navigation', async () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForAgentLoad()) - const { localStorage } = await browser.getAgentSessionInfo() + const { request: page1Contents } = await browser.testHandle.expectBlob(10000) + const { localStorage } = await browser.getAgentSessionInfo() expect(page1Contents.query).toMatchObject({ protocol_version: '0', @@ -104,18 +93,8 @@ describe('Session Replay Across Pages', () => { 'nr.rrweb.version': expect.any(String) } }) - await browser.testHandle.scheduleReply('bamServer', { - test: testRumRequest, - body: JSON.stringify({ - stn: 1, - err: 1, - ins: 1, - cap: 1, - spa: 1, - loaded: 1, - sr: 1 - }) - }) + + await browser.enableSessionReplay() await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) .then(() => browser.waitForAgentLoad()) @@ -143,11 +122,12 @@ describe('Session Replay Across Pages', () => { }) }) - withBrowsersMatching(supportsMultipleTabs)('should record across new-tab page navigation', async () => { + it.withBrowsersMatching(supportsMultipleTabs)('should record across new-tab page navigation', async () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForAgentLoad()) - const { localStorage } = await browser.getAgentSessionInfo() + const { request: page1Contents } = await browser.testHandle.expectBlob(10000) + const { localStorage } = await browser.getAgentSessionInfo() expect(page1Contents.query).toMatchObject({ protocol_version: '0', @@ -172,18 +152,7 @@ describe('Session Replay Across Pages', () => { const newTab = await browser.createWindow('tab') await browser.switchToWindow(newTab.handle) - await browser.testHandle.scheduleReply('bamServer', { - test: testRumRequest, - body: JSON.stringify({ - stn: 1, - err: 1, - ins: 1, - cap: 1, - spa: 1, - loaded: 1, - sr: 1 - }) - }) + await browser.enableSessionReplay() await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForAgentLoad()) @@ -217,8 +186,9 @@ describe('Session Replay Across Pages', () => { it('should not record across navigations if not active', async () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForAgentLoad()) - const { localStorage } = await browser.getAgentSessionInfo() + const { request: page1Contents } = await browser.testHandle.expectBlob(10000) + const { localStorage } = await browser.getAgentSessionInfo() expect(page1Contents.query).toMatchObject({ protocol_version: '0', @@ -245,23 +215,10 @@ describe('Session Replay Across Pages', () => { Object.values(NREUM.initializedAgents)[0].runtime.session.state.sessionReplay = 0 }) - await browser.testHandle.scheduleReply('bamServer', { - test: testRumRequest, - body: JSON.stringify({ - stn: 1, - err: 1, - ins: 1, - cap: 1, - spa: 1, - loaded: 1, - sr: 1 - }) - }) + await browser.enableSessionReplay() await browser.refresh() .then(() => browser.waitForAgentLoad()) - const sr = await getSR() - - expect(sr.exists).toEqual(false) + await expect(browser.waitForFeatureAggregate('session_replay', 5000)).rejects.toThrow() }) }) diff --git a/tests/specs/session-replay/session-state.e2e.js b/tests/specs/session-replay/session-state.e2e.js index b1f1c5519..18c77eadc 100644 --- a/tests/specs/session-replay/session-state.e2e.js +++ b/tests/specs/session-replay/session-state.e2e.js @@ -1,5 +1,4 @@ -import { supportsMultipleTabs } from '../../../tools/browser-matcher/common-matchers.mjs' -import { testRumRequest } from '../../../tools/testing-server/utils/expect-tests.js' +import { supportsMultipleTabs, notIE } from '../../../tools/browser-matcher/common-matchers.mjs' import { RRWEB_EVENT_TYPES, config, getSR } from './helpers.js' /** The "mode" with which the session replay is recording */ @@ -9,7 +8,7 @@ const MODE = { ERROR: 2 } -describe('session manager state behavior', () => { +describe.withBrowsersMatching(notIE)('session manager state behavior', () => { beforeEach(async () => { await browser.enableSessionReplay() }) @@ -21,7 +20,7 @@ describe('session manager state behavior', () => { describe('session manager mode matches session replay instance mode', () => { it('should match in full mode', async () => { await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) - .then(() => browser.waitForAgentLoad()) + .then(() => browser.waitForFeatureAggregate('session_replay')) await browser.pause(1000) const { agentSessions } = await browser.getAgentSessionInfo() @@ -30,18 +29,20 @@ describe('session manager state behavior', () => { }) it('should match in error mode', async () => { - await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ sampleRate: 0, errorSampleRate: 1 }))) - .then(() => browser.waitForAgentLoad()) + await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ session_replay: { sampleRate: 0, errorSampleRate: 1 } }))) + .then(() => browser.waitForFeatureAggregate('session_replay')) + await browser.pause(1000) const { agentSessions } = await browser.getAgentSessionInfo() const sessionClass = Object.values(agentSessions)[0] expect(sessionClass.sessionReplay).toEqual(MODE.ERROR) }) it('should match in off mode', async () => { - await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ sampleRate: 0, errorSampleRate: 0 }))) - .then(() => browser.waitForAgentLoad()) + await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ session_replay: { sampleRate: 0, errorSampleRate: 0 } }))) + .then(() => browser.waitForFeatureAggregate('session_replay')) + await browser.pause(1000) const { agentSessions } = await browser.getAgentSessionInfo() const sessionClass = Object.values(agentSessions)[0] expect(sessionClass.sessionReplay).toEqual(MODE.OFF) @@ -50,17 +51,8 @@ describe('session manager state behavior', () => { describe('When session ends', () => { it('should end recording and unload', async () => { - await browser.url(await browser.testHandle.assetURL('instrumented.html', { - ...config(), - init: { - // harvest intv longer than the session expiry time - ...config({ harvestTimeSeconds: 10 }).init, - session: { expiresMs: 7500 } - } - })) - .then(() => Promise.all([ - browser.waitForAgentLoad() - ])) + await browser.url(await browser.testHandle.assetURL('instrumented.html', config({ session: { expiresMs: 7500 }, session_replay: { harvestTimeSeconds: 10 } }))) + .then(() => browser.waitForSessionReplayRecording()) // session has started, replay should have set mode to "FULL" const { agentSessions: oldSession } = await browser.getAgentSessionInfo() @@ -69,7 +61,9 @@ describe('session manager state behavior', () => { await Promise.all([ browser.testHandle.expectBlob(), - browser.execute(function () { document.querySelector('body').click() }) + browser.execute(function () { + document.querySelector('body').click() + }) ]) // session has ended, replay should have set mode to "OFF" @@ -80,11 +74,9 @@ describe('session manager state behavior', () => { }) describe('When session resumes', () => { - withBrowsersMatching(supportsMultipleTabs)('should take a full snapshot and continue recording', async () => { + it.withBrowsersMatching(supportsMultipleTabs)('should take a full snapshot and continue recording', async () => { await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) - .then(() => browser.waitForAgentLoad()) - - await browser.pause(1000) + .then(() => browser.waitForSessionReplayRecording()) const { events: currentPayload } = await getSR() @@ -94,22 +86,10 @@ describe('session manager state behavior', () => { const newTab = await browser.createWindow('tab') await browser.switchToWindow(newTab.handle) - await browser.testHandle.scheduleReply('bamServer', { - test: testRumRequest, - body: JSON.stringify({ - stn: 1, - err: 1, - ins: 1, - cap: 1, - spa: 1, - loaded: 1, - sr: 1 - }) - }) + await browser.enableSessionReplay() await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) - .then(() => browser.waitForAgentLoad()) + .then(() => browser.waitForSessionReplayRecording()) - await browser.pause(1000) const { events: resumedPayload } = await getSR() // payload was harvested, new vis change should trigger a new recording which includes a new full snapshot @@ -123,7 +103,7 @@ describe('session manager state behavior', () => { }) describe('When session pauses', () => { - withBrowsersMatching(supportsMultipleTabs)('should pause recording', async () => { + it.withBrowsersMatching(supportsMultipleTabs)('should pause recording', async () => { await browser.url(await browser.testHandle.assetURL('instrumented.html', config())) .then(() => browser.waitForAgentLoad()) @@ -131,26 +111,13 @@ describe('session manager state behavior', () => { const newTab = await browser.createWindow('tab') await browser.switchToWindow(newTab.handle) - await browser.testHandle.scheduleReply('bamServer', { - test: testRumRequest, - body: JSON.stringify({ - stn: 1, - err: 1, - ins: 1, - cap: 1, - spa: 1, - loaded: 1, - sr: 1 - }) - }) - await Promise.all([ - browser.url(await browser.testHandle.assetURL('instrumented.html', { ...config(), loader: 'full' })), - browser.waitForAgentLoad() - ]) + await browser.enableSessionReplay() + await browser.url(await browser.testHandle.assetURL('instrumented.html', { ...config(), loader: 'full' })) + .then(() => browser.waitForAgentLoad()) // Waiting for the second blob should time out, indicating no second call to the BAM endpoint. // The wait must be longer than harvest interval. - await browser.testHandle.expectBlob(6000, true) + await browser.testHandle.expectBlob(10000, true) await browser.closeWindow() await browser.switchToWindow((await browser.getWindowHandles())[0]) }) diff --git a/tests/specs/spa/harvesting.e2e.js b/tests/specs/spa/harvesting.e2e.js new file mode 100644 index 000000000..2f9a9868f --- /dev/null +++ b/tests/specs/spa/harvesting.e2e.js @@ -0,0 +1,27 @@ +describe('spa harvesting', () => { + it('should set correct customEnd value on multiple custom interactions', async () => { + const [interactionResults] = await Promise.all([ + browser.testHandle.expectInteractionEvents(), + browser.url(await browser.testHandle.assetURL('spa/multiple-custom-interactions.html')) + .then(() => browser.waitForAgentLoad()) + ]) + + const interactions = interactionResults.request.body + expect(interactions.length).toEqual(3) + expect(interactions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + customName: 'interaction1' + }), + expect.objectContaining({ + customName: 'interaction2' + }), + expect.objectContaining({ + customName: 'interaction4' + }) + ])) + interactions.forEach(interaction => { + const customEndTime = interaction.children.find(child => child.type === 'customEnd') + expect(customEndTime.time).toBeGreaterThanOrEqual(interaction.end) + }) + }) +}) diff --git a/tests/specs/spa/retry-harvesting.e2e.js b/tests/specs/spa/retry-harvesting.e2e.js new file mode 100644 index 000000000..288a98869 --- /dev/null +++ b/tests/specs/spa/retry-harvesting.e2e.js @@ -0,0 +1,63 @@ +import { testInteractionEventsRequest } from '../../../tools/testing-server/utils/expect-tests' + +describe('retry harvesting', () => { + [408, 429, 500, 503].forEach(statusCode => + it(`should send the interaction on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testInteractionEventsRequest, + permanent: true, + statusCode + }) + + const [firstInteractionEventHarvest] = await Promise.all([ + browser.testHandle.expectInteractionEvents(), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const [secondInteractionEventHarvest] = await Promise.all([ + browser.testHandle.expectInteractionEvents(), + await browser.execute(function () { + newrelic.interaction().setName('interaction1').save().end() + }) + ]) + + expect(firstInteractionEventHarvest.reply.statusCode).toEqual(statusCode) + expect(secondInteractionEventHarvest.request.body).toEqual(expect.arrayContaining(firstInteractionEventHarvest.request.body)) + }) + ); + + [400, 404, 502, 504, 512].forEach(statusCode => + it(`should not send the page action on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testInteractionEventsRequest, + permanent: true, + statusCode + }) + + const [firstInteractionEventHarvest] = await Promise.all([ + browser.testHandle.expectInteractionEvents(), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const [secondInteractionEventHarvest] = await Promise.all([ + browser.testHandle.expectInteractionEvents(), + await browser.execute(function () { + newrelic.interaction().setName('interaction1').save().end() + }) + ]) + + expect(firstInteractionEventHarvest.reply.statusCode).toEqual(statusCode) + expect(secondInteractionEventHarvest.request.body).not.toEqual(expect.arrayContaining(firstInteractionEventHarvest.request.body)) + }) + ) +}) diff --git a/tests/specs/stack-trace.e2e.js b/tests/specs/stack-trace.e2e.js index 5f8b36c6d..f9fbb1c67 100644 --- a/tests/specs/stack-trace.e2e.js +++ b/tests/specs/stack-trace.e2e.js @@ -11,8 +11,8 @@ const supportedBrowsers = new SpecMatcher() .include('edge>=14') .include('android') -describe('stack trace', () => { - withBrowsersMatching(supportedBrowsers)('identifies for same-page scripts (but only same-page scripts)', async () => { +describe.withBrowsersMatching(supportedBrowsers)('stack trace', () => { + it('identifies for same-page scripts (but only same-page scripts)', async () => { const [errorsResults] = await Promise.all([ browser.testHandle.expectErrors(), browser.url(await browser.testHandle.assetURL('sub-path-script-error/')) // Setup expects before loading the page @@ -35,7 +35,7 @@ describe('stack trace', () => { expect(stackTraceLines[3]).not.toContain('') }) - withBrowsersMatching(supportedBrowsers)('still identifies for same-page scripts after SPA route changes', async () => { + it('still identifies for same-page scripts after SPA route changes', async () => { await browser.url(await browser.testHandle.assetURL('sub-path-script-error/index.html')) .then(() => browser.waitForAgentLoad()) diff --git a/tests/specs/stn/retry-harvesting.e2e.js b/tests/specs/stn/retry-harvesting.e2e.js new file mode 100644 index 000000000..a45852109 --- /dev/null +++ b/tests/specs/stn/retry-harvesting.e2e.js @@ -0,0 +1,83 @@ +import { faker } from '@faker-js/faker' +import { testResourcesRequest } from '../../../tools/testing-server/utils/expect-tests' + +describe('ins retry harvesting', () => { + [408, 429, 500, 503].forEach(statusCode => + it(`should send the session trace on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testResourcesRequest, + permanent: true, + statusCode, + body: '' + }) + + const [firstResourcesHarvest] = await Promise.all([ + browser.testHandle.expectResources(), + browser.url(await browser.testHandle.assetURL('stn/instrumented.html')) + .then(() => browser.waitForAgentLoad()) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const ptid = faker.datatype.uuid() + await browser.testHandle.scheduleReply('bamServer', { + test: testResourcesRequest, + permanent: true, + body: ptid + }) + + const secondResourcesHarvest = await browser.testHandle.expectResources() + const [thirdResourcesHarvest] = await Promise.all([ + browser.testHandle.expectResources(), + $('#trigger').click() + ]) + + expect(firstResourcesHarvest.reply.statusCode).toEqual(statusCode) + expect(secondResourcesHarvest.request.body.res).toEqual(expect.arrayContaining(firstResourcesHarvest.request.body.res)) + expect(secondResourcesHarvest.request.query.ptid).toBeUndefined() + expect(thirdResourcesHarvest.request.query.ptid).toEqual(ptid) + }) + ); + + [400, 404, 502, 504, 512].forEach(statusCode => + it(`should not send the session trace on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testResourcesRequest, + permanent: true, + statusCode, + body: '' + }) + + const [firstResourcesHarvest] = await Promise.all([ + browser.testHandle.expectResources(), + browser.url(await browser.testHandle.assetURL('stn/instrumented.html')) + .then(() => browser.waitForAgentLoad()) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + // await browser.pause(500) + // await browser.testHandle.clearScheduledReplies('bamServer') + + // const ptid = faker.datatype.uuid() + // await browser.testHandle.scheduleReply('bamServer', { + // test: testResourcesRequest, + // permanent: true, + // body: ptid + // }) + + // const secondResourcesHarvest = await browser.testHandle.expectResources() + // const [thirdResourcesHarvest] = await Promise.all([ + // browser.testHandle.expectResources(), + // $('#trigger').click() + // ]) + + expect(firstResourcesHarvest.reply.statusCode).toEqual(statusCode) + // expect(secondResourcesHarvest.request.body.res).not.toEqual(expect.arrayContaining(firstResourcesHarvest.request.body.res)) + // expect(secondResourcesHarvest.request.query.ptid).toBeUndefined() + // expect(thirdResourcesHarvest.request.query.ptid).toEqual(ptid) + await expect(browser.testHandle.expectResources(10000, true)).resolves.toBeUndefined() + }) + ) +}) diff --git a/tests/specs/third-party-compatibility/jspdf.e2e.js b/tests/specs/third-party-compatibility/jspdf.e2e.js index 0aec18e20..3fa57270d 100644 --- a/tests/specs/third-party-compatibility/jspdf.e2e.js +++ b/tests/specs/third-party-compatibility/jspdf.e2e.js @@ -1,8 +1,8 @@ import { reliableUnload } from '../../../tools/browser-matcher/common-matchers.mjs' import runTest from './run-test' -describe('jspdf compatibility', () => { - withBrowsersMatching(reliableUnload)('2.5.1', async () => { +describe.withBrowsersMatching(reliableUnload)('jspdf compatibility', () => { + it('2.5.1', async () => { await runTest({ browser, testAsset: 'third-party-compatibility/jspdf/2.5.1.html', diff --git a/tests/specs/third-party-compatibility/mootools.e2e.js b/tests/specs/third-party-compatibility/mootools.e2e.js index 0b5ae6171..12df36616 100644 --- a/tests/specs/third-party-compatibility/mootools.e2e.js +++ b/tests/specs/third-party-compatibility/mootools.e2e.js @@ -1,8 +1,8 @@ import { reliableUnload } from '../../../tools/browser-matcher/common-matchers.mjs' import runTest from './run-test' -describe('mootools compatibility', () => { - withBrowsersMatching(reliableUnload)('1.6.0-nocompat', async () => { +describe.withBrowsersMatching(reliableUnload)('mootools compatibility', () => { + it('1.6.0-nocompat', async () => { await runTest({ browser, testAsset: 'third-party-compatibility/mootools/1.6.0-nocompat.html', diff --git a/tests/specs/third-party-compatibility/requirejs.e2e.js b/tests/specs/third-party-compatibility/requirejs.e2e.js index ec558df12..180a05dc1 100644 --- a/tests/specs/third-party-compatibility/requirejs.e2e.js +++ b/tests/specs/third-party-compatibility/requirejs.e2e.js @@ -1,8 +1,8 @@ import { reliableUnload } from '../../../tools/browser-matcher/common-matchers.mjs' import runTest from './run-test' -describe('requirejs compatibility', () => { - withBrowsersMatching(reliableUnload)('2.3.6', async () => { +describe.withBrowsersMatching(reliableUnload)('requirejs compatibility', () => { + it('2.3.6', async () => { await runTest({ browser, testAsset: 'third-party-compatibility/requirejs/2.3.6.html' diff --git a/tests/specs/xhr/retry-harvesting.e2e.js b/tests/specs/xhr/retry-harvesting.e2e.js new file mode 100644 index 000000000..c9c0f1cd2 --- /dev/null +++ b/tests/specs/xhr/retry-harvesting.e2e.js @@ -0,0 +1,111 @@ +import { testAjaxEventsRequest, testAjaxTimeSlicesRequest } from '../../../tools/testing-server/utils/expect-tests' + +describe('xhr retry harvesting', () => { + [408, 429, 500, 503].forEach(statusCode => + it(`should send the ajax event and time slice on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testAjaxEventsRequest, + permanent: true, + statusCode + }) + await browser.testHandle.scheduleReply('bamServer', { + test: testAjaxTimeSlicesRequest, + permanent: true, + statusCode + }) + + const [firstAjaxEventsHarvest, firstAjaxTimeSlicesHarvest] = await Promise.all([ + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectAjaxTimeSlices(), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + .then(() => browser.execute(function () { + var xhr = new XMLHttpRequest() + xhr.open('GET', '/json') + xhr.send() + })) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const [secondAjaxEventsHarvest, secondAjaxTimeSlicesHarvest] = await Promise.all([ + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectAjaxTimeSlices(), + browser.execute(function () { + var xhr = new XMLHttpRequest() + xhr.open('GET', '/text') + xhr.send() + }) + ]) + + expect(firstAjaxEventsHarvest.reply.statusCode).toEqual(statusCode) + expect(firstAjaxTimeSlicesHarvest.reply.statusCode).toEqual(statusCode) + + const firstTimeSlice = firstAjaxTimeSlicesHarvest.request.body.xhr.find(x => x.params.pathname === '/json') + expect(secondAjaxTimeSlicesHarvest.request.body.xhr).toEqual(expect.arrayContaining([firstTimeSlice])) + + const firstEvent = firstAjaxEventsHarvest.request.body.find(x => x.path === '/json') + expect(secondAjaxEventsHarvest.request.body).toEqual(expect.arrayContaining([firstEvent])) + }) + ); + + [400, 404, 502, 504, 512].forEach(statusCode => + it(`should not send the ajax event and time slice on the next harvest when the first harvest statusCode is ${statusCode}`, async () => { + await browser.testHandle.scheduleReply('bamServer', { + test: testAjaxEventsRequest, + permanent: true, + statusCode + }) + await browser.testHandle.scheduleReply('bamServer', { + test: testAjaxTimeSlicesRequest, + permanent: true, + statusCode + }) + + const [firstAjaxEventsHarvest, firstAjaxTimeSlicesHarvest] = await Promise.all([ + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectAjaxTimeSlices(), + browser.url(await browser.testHandle.assetURL('instrumented.html')) + .then(() => browser.waitForAgentLoad()) + .then(() => browser.execute(function () { + var xhr = new XMLHttpRequest() + xhr.open('GET', '/json') + xhr.send() + })) + ]) + + // Pause a bit for browsers built-in automated retry logic crap + await browser.pause(500) + await browser.testHandle.clearScheduledReplies('bamServer') + + const [secondAjaxEventsHarvest, secondAjaxTimeSlicesHarvest] = await Promise.all([ + browser.testHandle.expectAjaxEvents(), + browser.testHandle.expectAjaxTimeSlices(), + browser.execute(function () { + var xhr = new XMLHttpRequest() + xhr.open('GET', '/text') + xhr.send() + }) + ]) + + expect(firstAjaxEventsHarvest.reply.statusCode).toEqual(statusCode) + expect(firstAjaxTimeSlicesHarvest.reply.statusCode).toEqual(statusCode) + + const firstTimeSlice = firstAjaxTimeSlicesHarvest.request.body.xhr.find(x => x.params.pathname === '/json') + expect(secondAjaxTimeSlicesHarvest.request.body.xhr).not.toEqual(expect.arrayContaining([firstTimeSlice])) + + const firstEvent = firstAjaxEventsHarvest.request.body.find(x => x.path === '/json') + expect(secondAjaxEventsHarvest.request.body).not.toEqual(expect.arrayContaining([firstEvent])) + + const firstEventHarvestTime = Number(firstAjaxEventsHarvest.request.query.rst) + const secondEventHarvestTime = Number(secondAjaxEventsHarvest.request.query.rst) + expect(secondEventHarvestTime).toBeWithin(firstEventHarvestTime + 5000, firstEventHarvestTime + 10000) + + const firstTimeSliceHarvestTime = Number(firstAjaxTimeSlicesHarvest.request.query.rst) + const secondTimeSliceHarvestTime = Number(secondAjaxTimeSlicesHarvest.request.query.rst) + expect(secondTimeSliceHarvestTime).toBeWithin(firstTimeSliceHarvestTime + 5000, firstTimeSliceHarvestTime + 10000) + }) + ) +}) diff --git a/tools/test-builds/browser-agent-wrapper/package.json b/tools/test-builds/browser-agent-wrapper/package.json index db71ff70c..84aca5d04 100644 --- a/tools/test-builds/browser-agent-wrapper/package.json +++ b/tools/test-builds/browser-agent-wrapper/package.json @@ -9,6 +9,6 @@ "author": "", "license": "ISC", "dependencies": { - "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.234.0.tgz" + "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.235.0.tgz" } } diff --git a/tools/test-builds/browser-tests/package.json b/tools/test-builds/browser-tests/package.json index 3d5465c07..4792b15c0 100644 --- a/tools/test-builds/browser-tests/package.json +++ b/tools/test-builds/browser-tests/package.json @@ -9,6 +9,6 @@ "author": "", "license": "ISC", "dependencies": { - "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.234.0.tgz" + "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.235.0.tgz" } } diff --git a/tools/test-builds/raw-src-wrapper/package.json b/tools/test-builds/raw-src-wrapper/package.json index 48efc8ff9..04a8e86db 100644 --- a/tools/test-builds/raw-src-wrapper/package.json +++ b/tools/test-builds/raw-src-wrapper/package.json @@ -9,6 +9,6 @@ "author": "", "license": "ISC", "dependencies": { - "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.234.0.tgz" + "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.235.0.tgz" } } diff --git a/tools/test-builds/vite-react-wrapper/package.json b/tools/test-builds/vite-react-wrapper/package.json index f6c01b4d0..1c8b20bd2 100644 --- a/tools/test-builds/vite-react-wrapper/package.json +++ b/tools/test-builds/vite-react-wrapper/package.json @@ -4,7 +4,7 @@ "main": "index.js", "license": "MIT", "dependencies": { - "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.234.0.tgz", + "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.235.0.tgz", "react": "18.2.0", "react-dom": "18.2.0" }, diff --git a/tools/testing-server/index.js b/tools/testing-server/index.js index ba0440420..7be302383 100644 --- a/tools/testing-server/index.js +++ b/tools/testing-server/index.js @@ -1,7 +1,7 @@ const fastify = require('fastify') const { urlFor } = require('./utils/url') const waitOn = require('wait-on') -const { paths, defaultAgentConfig } = require('./constants') +const { paths } = require('./constants') const TestHandle = require('./test-handle') const TestServerLogger = require('./logger') @@ -38,6 +38,7 @@ class TestServer { * Default fastify server config */ #defaultServerConfig = { + forceCloseConnections: true, maxParamLength: Number.MAX_SAFE_INTEGER, bodyLimit: Number.MAX_SAFE_INTEGER, logger: false @@ -124,9 +125,9 @@ class TestServer { async ready () { await waitOn({ resources: [ - `http-get://127.0.0.1:${this.assetServer.port}/`, - `http-get://127.0.0.1:${this.corsServer.port}/json`, - `http-get://127.0.0.1:${this.bamServer.port}/1/${defaultAgentConfig.licenseKey}`, + `http-get://127.0.0.1:${this.assetServer.port}/health`, + `http-get://127.0.0.1:${this.corsServer.port}/health`, + `http-get://127.0.0.1:${this.bamServer.port}/health`, `http-get://127.0.0.1:${this.commandServer.port}/health` ] }) diff --git a/tools/testing-server/logger.js b/tools/testing-server/logger.js index f17f9b4c9..5a2d3bda9 100644 --- a/tools/testing-server/logger.js +++ b/tools/testing-server/logger.js @@ -14,9 +14,9 @@ class TestServerLogger { } } - logNetworkRequest (request) { + logNetworkRequest (request, reply) { if (this.#config.logRequests) { - this.#parentLogger.info(`${request.server.testServerId} -> ${request.method} ${request.url}`) + this.#parentLogger.info(`${request.server.testServerId} -> ${request.method} ${request.url} ${reply.statusCode}`) } } diff --git a/tools/testing-server/plugins/agent-injector/debug-shim.js b/tools/testing-server/plugins/agent-injector/debug-shim.js index b47f354e5..b259d6710 100644 --- a/tools/testing-server/plugins/agent-injector/debug-shim.js +++ b/tools/testing-server/plugins/agent-injector/debug-shim.js @@ -28,21 +28,36 @@ module.exports = ` var origOnError = window.onerror window.onerror = function() { NRDEBUG('error thrown: ' + JSON.stringify(arguments)) - origOnError(arguments) + if (typeof origOnError === 'function') { + origOnError(arguments) + } } var origLog = window.console.log window.console.log = function() { NRDEBUG('console.log: ' + JSON.stringify(arguments)) - origLog(arguments) + if (typeof origLog === 'function') { + origLog(arguments) + } } var origWarn = window.console.warn window.console.warn = function() { NRDEBUG('console.warn: ' + JSON.stringify(arguments)) - origWarn(arguments) + if (typeof origWarn === 'function') { + origWarn(arguments) + } } var origErr = window.console.error window.console.error = function() { NRDEBUG('console.error: ' + JSON.stringify(arguments)) - origErr(arguments) + if (typeof origErr === 'function') { + origErr(arguments) + } + } + var origTrace = window.console.trace + window.console.trace = function() { + NRDEBUG('console.trace: ' + JSON.stringify(arguments)) + if (typeof origTrace === 'function') { + origTrace(arguments) + } } ` diff --git a/tools/testing-server/plugins/compression-interceptor/index.js b/tools/testing-server/plugins/compression-interceptor/index.js index 2a3391ed9..383401c89 100644 --- a/tools/testing-server/plugins/compression-interceptor/index.js +++ b/tools/testing-server/plugins/compression-interceptor/index.js @@ -10,6 +10,7 @@ const fp = require('fastify-plugin') module.exports = fp(async function (fastify) { fastify.addHook('preParsing', (request, reply, payload, done) => { if (request.query.content_encoding === 'gzip') request.headers['content-encoding'] = 'gzip' + // sendBeacon does not add content-type header, and fastify compress apparently fails if no content-type is found if (!request.headers['content-type']) request.headers['content-type'] = 'text/plain' done(null, payload) diff --git a/tools/testing-server/plugins/request-logger/index.js b/tools/testing-server/plugins/request-logger/index.js index be6eb87c8..ea522c350 100644 --- a/tools/testing-server/plugins/request-logger/index.js +++ b/tools/testing-server/plugins/request-logger/index.js @@ -5,10 +5,11 @@ const fp = require('fastify-plugin') * @param {module:fastify.FastifyInstance} fastify the fastify server instance */ module.exports = fp(async function (fastify) { - fastify.addHook('preHandler', (request, reply, done) => { - if (!request.url.startsWith('/debug')) { - fastify.testServerLogger.logNetworkRequest(request) + fastify.addHook('onSend', (request, reply, response, done) => { + if (!request.url.startsWith('/debug') && !request.url.startsWith('/health')) { + fastify.testServerLogger.logNetworkRequest(request, reply) } - done() + + done(null, response) }) }) diff --git a/tools/testing-server/plugins/test-handle/index.js b/tools/testing-server/plugins/test-handle/index.js index 66de2f887..dd344feec 100644 --- a/tools/testing-server/plugins/test-handle/index.js +++ b/tools/testing-server/plugins/test-handle/index.js @@ -24,7 +24,7 @@ module.exports = fp(async function (fastify, testServer) { if (request.scheduledReply.statusCode) { reply.code(request.scheduledReply.statusCode) } - if (request.scheduledReply.body) { + if (Object.prototype.hasOwnProperty.call(request.scheduledReply, 'body')) { payload = request.scheduledReply.body } } diff --git a/tools/testing-server/routes/bam-apis.js b/tools/testing-server/routes/bam-apis.js index 24fdd0a98..2f73336f1 100644 --- a/tools/testing-server/routes/bam-apis.js +++ b/tools/testing-server/routes/bam-apis.js @@ -7,6 +7,9 @@ const { rumFlags } = require('../constants') * @param {TestServer} testServer test server instance */ module.exports = fp(async function (fastify) { + fastify.get('/health', async function (request, reply) { + reply.code(204).send() + }) fastify.route({ method: ['GET', 'POST'], url: '/debug', diff --git a/tools/testing-server/routes/command-apis.js b/tools/testing-server/routes/command-apis.js index 6f6d97954..f4cefc9e3 100644 --- a/tools/testing-server/routes/command-apis.js +++ b/tools/testing-server/routes/command-apis.js @@ -44,9 +44,9 @@ module.exports = fp(async function (fastify, testServer) { reply.code(400).send(e) } }) - fastify.post('/test-handle/:testId/scheduleReply', async function (request, reply) { const testHandle = testServer.getTestHandle(request.params.testId) + try { testHandle.scheduleReply(request.body.serverId, request.body.scheduledReply) reply.code(200).send() @@ -54,9 +54,9 @@ module.exports = fp(async function (fastify, testServer) { reply.code(400).send(e) } }) - fastify.post('/test-handle/:testId/clearScheduledReplies', async function (request, reply) { const testHandle = testServer.getTestHandle(request.params.testId) + try { testHandle.clearScheduledReplies(request.body.serverId) reply.code(200).send() @@ -64,4 +64,15 @@ module.exports = fp(async function (fastify, testServer) { reply.code(400).send(e) } }) + fastify.post('/test-handle/:testId/requestCounts', async function (request, reply) { + const testHandle = testServer.getTestHandle(request.params.testId) + + try { + reply.code(200).send( + testHandle.requestCounts + ) + } catch (e) { + reply.code(400).send(e) + } + }) }) diff --git a/tools/testing-server/routes/mock-apis.js b/tools/testing-server/routes/mock-apis.js index 16e335ae0..e35151c3d 100644 --- a/tools/testing-server/routes/mock-apis.js +++ b/tools/testing-server/routes/mock-apis.js @@ -13,6 +13,9 @@ const { paths } = require('../constants') * @param {TestServer} testServer test server instance */ module.exports = fp(async function (fastify, testServer) { + fastify.get('/health', async function (request, reply) { + reply.code(204).send() + }) fastify.get('/slowscript', { compress: false }, (request, reply) => { diff --git a/tools/testing-server/test-handle.js b/tools/testing-server/test-handle.js index 59bffb06e..06bef4077 100644 --- a/tools/testing-server/test-handle.js +++ b/tools/testing-server/test-handle.js @@ -25,6 +25,7 @@ const SerAny = require('serialize-anything') * @typedef {object} ScheduledReply * @property {Function|string} test function that takes the fastify request object and returns true if the scheduled * response should be applied + * @property {boolean} permanent indicates if the reply should be left in place * @property {number} statusCode response code * @property {string} body response body * @property {number} delay delay the response by a number of milliseconds @@ -121,11 +122,17 @@ module.exports = class TestHandle { if (test.call(this, request)) { request.scheduledReply = scheduledReply - scheduledReplies.delete(scheduledReply) + + if (!scheduledReply.permanent) { + scheduledReplies.delete(scheduledReply) + } break } } catch (e) { - scheduledReplies.delete(scheduledReply) + fastify.log.error(e) + if (!scheduledReply.permanent) { + scheduledReplies.delete(scheduledReply) + } } } } @@ -149,6 +156,7 @@ module.exports = class TestHandle { break } } catch (e) { + fastify.log.error(e) pendingExpect.reject(e) pendingExpects.delete(pendingExpect) } diff --git a/tools/wdio/args.mjs b/tools/wdio/args.mjs index 51e147d5b..efd204356 100644 --- a/tools/wdio/args.mjs +++ b/tools/wdio/args.mjs @@ -3,7 +3,6 @@ import yargs from 'yargs/yargs' import { hideBin } from 'yargs/helpers' import loaders from './util/loaders.js' -process.argvOriginal = [...process.argv].slice(2) const args = yargs(hideBin(process.argv)) .usage('$0 file1[, filen] [options]') .example('$0 tests/**/*.js -vb "chrome@39, firefox, ie@>8"') diff --git a/tools/wdio/config/base.conf.mjs b/tools/wdio/config/base.conf.mjs index c6c160085..11298b976 100644 --- a/tools/wdio/config/base.conf.mjs +++ b/tools/wdio/config/base.conf.mjs @@ -47,7 +47,7 @@ export default function config () { buildIdentifier }] ], - specFileRetries: args.retry ? 3 : 0, + specFileRetries: args.retry ? 1 : 0, specFileRetriesDeferred: true, framework: 'mocha', mochaOpts: { diff --git a/tools/wdio/config/sauce.conf.mjs b/tools/wdio/config/sauce.conf.mjs index 3ac3bb718..b633114b6 100644 --- a/tools/wdio/config/sauce.conf.mjs +++ b/tools/wdio/config/sauce.conf.mjs @@ -31,11 +31,6 @@ function sauceCapabilities () { ...sauceBrowser, platform: undefined, version: undefined, - ...(() => { - if (args.sauceExtendedDebugging && getBrowserName(sauceBrowser) === 'chrome') { - return { extendedDebugging: true } - } - })(), ...getMobileCapabilities(sauceBrowser), 'sauce:options': !args.sauce ? { diff --git a/tools/wdio/plugins/browser-matcher.mjs b/tools/wdio/plugins/browser-matcher.mjs index 8791aaa89..8c6f188fc 100644 --- a/tools/wdio/plugins/browser-matcher.mjs +++ b/tools/wdio/plugins/browser-matcher.mjs @@ -1,5 +1,7 @@ +import logger from '@wdio/logger' import { getBrowserName, getBrowserVersion } from '../../browsers-lists/utils.mjs' +const log = logger('browser-matcher') /** * This is a WDIO worker plugin that provides a global method allowing for the * filtering of tests by a browser match. @@ -11,10 +13,48 @@ export default class BrowserMatcher { async beforeSession (_, capabilities) { this.#browserName = getBrowserName(capabilities) this.#browserVersion = getBrowserVersion(capabilities) - global.withBrowsersMatching = this.#browserMatchTest.bind(this) + this.#setupMochaGlobals() + global.withBrowsersMatching = (matcher) => { + log.warn('withBrowsersMatching() global deprecated, use it.withBrowsersMatching() or describe.withBrowsersMatching()') + return this.#browserMatchTest(matcher, global.it) + } + } + + #setupMochaGlobals () { + let globalDescribe + Object.defineProperty(global, 'describe', { + configurable: true, + get: () => { + return globalDescribe + }, + set: (value) => { + this.#extendMochaGlobal(value) + globalDescribe = value + } + }) + + let globalIt + Object.defineProperty(global, 'it', { + configurable: true, + get: () => { + return globalIt + }, + set: (value) => { + this.#extendMochaGlobal(value) + globalIt = value + } + }) + } + + #extendMochaGlobal (originalGlobal) { + Object.defineProperty(originalGlobal, 'withBrowsersMatching', { + value: (matcher) => { + return this.#browserMatchTest(matcher, originalGlobal) + } + }) } - #browserMatchTest (matcher) { + #browserMatchTest (matcher, originalGlobal) { let skip = false if (Array.isArray(matcher) && matcher.length > 0) { @@ -39,7 +79,7 @@ export default class BrowserMatcher { is a waste of time. */ if (!skip) { - global.it.apply(this, args) + originalGlobal.apply(this, args) } } } diff --git a/tools/wdio/plugins/custom-commands.mjs b/tools/wdio/plugins/custom-commands.mjs index bd967ee25..2c5b151ab 100644 --- a/tools/wdio/plugins/custom-commands.mjs +++ b/tools/wdio/plugins/custom-commands.mjs @@ -115,5 +115,54 @@ export default class CustomCommands { }) }) }) + + /** + * Waits for a specific feature aggregate class to be loaded. + */ + browser.addCommand('waitForFeatureAggregate', async function (feature, timeout) { + await browser.waitUntil( + () => browser.execute(function (feat) { + try { + var initializedAgent = Object.values(newrelic.initializedAgents)[0] + return !!(initializedAgent && + initializedAgent.features && + initializedAgent.features[feat] && + initializedAgent.features[feat].featAggregate) + } catch (err) { + console.error(err) + return false + } + }, feature), + { + timeout: timeout || 30000, + timeoutMsg: `Aggregate never loaded for feature ${feature}` + }) + }) + + /** + * Waits for the session replay feature to initialize and start recording. + */ + browser.addCommand('waitForSessionReplayRecording', async function () { + await browser.waitForFeatureAggregate('session_replay') + await browser.waitUntil( + () => browser.execute(function () { + try { + var initializedAgent = Object.values(newrelic.initializedAgents)[0] + return !!(initializedAgent && + initializedAgent.features && + initializedAgent.features.session_replay && + initializedAgent.features.session_replay.featAggregate && + initializedAgent.features.session_replay.featAggregate.initialized && + initializedAgent.features.session_replay.featAggregate.recording) + } catch (err) { + console.error(err) + return false + } + }), + { + timeout: 30000, + timeoutMsg: 'Session replay recording never started' + }) + }) } } diff --git a/tools/wdio/plugins/istanbul.mjs b/tools/wdio/plugins/istanbul.mjs index e6bce54fb..d43c7ad85 100644 --- a/tools/wdio/plugins/istanbul.mjs +++ b/tools/wdio/plugins/istanbul.mjs @@ -4,10 +4,12 @@ import url from 'url' import istanbulCoverage from 'istanbul-lib-coverage' import istanbulReport from 'istanbul-lib-report' import reports from 'istanbul-reports' +import logger from '@wdio/logger' import { v4 as UUIDv4 } from 'uuid' import { getBrowserName, getBrowserVersion } from '../../browsers-lists/utils.mjs' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) +const log = logger('istanbul-coverage') export default class IstanbulCoverage { #coverageDir = path.join( @@ -110,6 +112,8 @@ export default class IstanbulCoverage { * all the coverage files into a single report. */ async onComplete () { + const coverageStart = performance.now() + if (!this.#enabled) return const coverageFiles = await this.#findCoverageFiles(path.join(this.#coverageDir, 'raw')) @@ -125,6 +129,8 @@ export default class IstanbulCoverage { }) const lcovReport = reports.create('lcov') lcovReport.execute(lcovContext) + + log.info(`Coverage generated in ${Math.round(performance.now() - coverageStart)}ms`) } async #findCoverageFiles (dirPath) { diff --git a/tools/wdio/plugins/newrelic-instrumentation.mjs b/tools/wdio/plugins/newrelic-instrumentation.mjs index e13d1ec39..479b97976 100644 --- a/tools/wdio/plugins/newrelic-instrumentation.mjs +++ b/tools/wdio/plugins/newrelic-instrumentation.mjs @@ -1,10 +1,15 @@ import newrelic from 'newrelic' +import logger from '@wdio/logger' + +const log = logger('newrelic-instrumentation') export default class NewrelicInstrumentation { /** * Runs in the scope of the main WDIO process after all testing has completed. */ async onComplete (exitCode) { + const shutdownStart = performance.now() + if (exitCode > 0) { newrelic.noticeError( new Error(`WDIO shutdown with exit code: ${exitCode}`) @@ -14,6 +19,8 @@ export default class NewrelicInstrumentation { await new Promise((resolve) => newrelic.shutdown({ collectPendingData: true, timeout: 3000 }, resolve) ) + + log.info(`Shutdown in ${Math.round(performance.now() - shutdownStart)}ms`) } /** @@ -21,9 +28,13 @@ export default class NewrelicInstrumentation { * and before the worker is shutdown. */ async after () { + const shutdownStart = performance.now() + await new Promise((resolve) => newrelic.shutdown({ collectPendingData: true, timeout: 3000 }, resolve) ) + + log.info(`Shutdown in ${Math.round(performance.now() - shutdownStart)}ms`) } } export const launcher = NewrelicInstrumentation diff --git a/tools/wdio/plugins/testing-server/default-asset-query.mjs b/tools/wdio/plugins/testing-server/default-asset-query.mjs index 0c45c83f6..960bb4d8b 100644 --- a/tools/wdio/plugins/testing-server/default-asset-query.mjs +++ b/tools/wdio/plugins/testing-server/default-asset-query.mjs @@ -19,7 +19,9 @@ const query = { page_view_event: { enabled: true }, page_view_timing: { enabled: true, harvestTimeSeconds: 5, long_task: false }, session_trace: { enabled: true, harvestTimeSeconds: 5 }, - spa: { enabled: true, harvestTimeSeconds: 5 } + spa: { enabled: true, harvestTimeSeconds: 5 }, + harvest: { tooManyRequestsDelay: 5 }, + session_replay: { enabled: false, harvestTimeSeconds: 5, sampleRate: 0, errorSampleRate: 0 } } } diff --git a/tools/wdio/plugins/testing-server/index.mjs b/tools/wdio/plugins/testing-server/index.mjs index a9130f08e..c356b980e 100644 --- a/tools/wdio/plugins/testing-server/index.mjs +++ b/tools/wdio/plugins/testing-server/index.mjs @@ -6,16 +6,11 @@ import { TestHandleConnector } from './test-handle-connector.mjs' * a test handle connector. */ export default class TestingServerWorker { - #testingServerIndex = 0 - #commandServerPorts + #commandServerPort beforeSession (_, capabilities) { - this.#commandServerPorts = capabilities.testServerCommandPorts - delete capabilities.testServerCommandPorts - - if (!Array.isArray(this.#commandServerPorts) || this.#commandServerPorts.length === 0) { - throw new Error('No testing server command ports were passed to the child WDIO process.') - } + this.#commandServerPort = capabilities.testServerCommandPort + delete capabilities.testServerCommandPort } /** @@ -24,20 +19,10 @@ export default class TestingServerWorker { */ async before () { browser.addCommand('getTestHandle', async () => { - const testHandle = new TestHandleConnector(this.#getNextTestingServer()) + const testHandle = new TestHandleConnector(this.#commandServerPort) await testHandle.ready() return testHandle }) } - - #getNextTestingServer () { - if (this.#testingServerIndex + 1 > this.#commandServerPorts.length) { - this.#testingServerIndex = 0 - } - - const nextTestingServerCommandPort = this.#commandServerPorts[this.#testingServerIndex] - this.#testingServerIndex = this.#testingServerIndex + 1 - return nextTestingServerCommandPort - } } export const launcher = TestingServerLauncher diff --git a/tools/wdio/plugins/testing-server/launcher.mjs b/tools/wdio/plugins/testing-server/launcher.mjs index 7286e632f..b9911e25f 100644 --- a/tools/wdio/plugins/testing-server/launcher.mjs +++ b/tools/wdio/plugins/testing-server/launcher.mjs @@ -1,82 +1,39 @@ -import process from 'process' -import childProcess from 'child_process' -import path from 'path' -import url from 'url' import logger from '@wdio/logger' +import TestServer from '../../../testing-server/index.js' -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) -const testingServerModule = path.resolve(__dirname, '../../bin/server.js') -const testingServerCwd = path.resolve(__dirname, '../../../../') -let testingServerId = 0 +const log = logger('testing-server') /** * This is a WDIO launcher plugin that starts the testing servers. */ export default class TestingServerLauncher { - #testingServerProcs = [] - #testingServerCommandPorts = [] + #testingServer - async onPrepare (config, capabilities) { - const maxTestingServers = Math.min( - 5, // Max out at 5 test servers - Math.floor(config.maxInstances / 4) || 1 - ) - process.setMaxListeners(Infinity) - await Promise.all( - [...Array(maxTestingServers)] - .map(() => this.#createTestingServerProcess()) - ) + constructor (opts) { + this.#testingServer = new TestServer({ + ...opts, + logger: log + }) + } + + async onPrepare (_, capabilities) { + await this.#testingServer.start() + + log.info(`Asset server started on http://${this.#testingServer.assetServer.host}:${this.#testingServer.assetServer.port}`) + log.info(`CORS server started on http://${this.#testingServer.corsServer.host}:${this.#testingServer.corsServer.port}`) + log.info(`BAM server started on http://${this.#testingServer.bamServer.host}:${this.#testingServer.bamServer.port}`) + log.info(`Command server started on http://${this.#testingServer.commandServer.host}:${this.#testingServer.commandServer.port}`) capabilities.forEach((capability) => { - capability.testServerCommandPorts = this.#testingServerCommandPorts + capability.testServerCommandPort = this.#testingServer.commandServer.port }) } async onComplete () { - await Promise.all([ - this.#testingServerProcs.map(child => { - return new Promise(resolve => { - child.on('exit', resolve) - child.kill() - }) - }) - ]) - } - - async #createTestingServerProcess () { - await new Promise((resolve) => { - const testingServerLogger = logger(`testing-server-${testingServerId++}`) - const abortController = new AbortController() - const child = childProcess.fork( - testingServerModule, - [...process.argvOriginal, '-p', '-1'], - { cwd: testingServerCwd, stdio: 'pipe', signal: abortController.signal } - ) + const shutdownStart = performance.now() - const serverStartTimeout = setTimeout(abortController.abort, 5000); + await this.#testingServer.stop() - ['SIGINT', 'SIGUSR1', 'SIGUSR2', 'SIGTERM', 'exit'].forEach((eventType) => { - process.on(eventType, () => child.kill()) - }) - - child.on('message', (message) => { - if (message?.commandServer?.port) { - clearTimeout(serverStartTimeout) - this.#testingServerProcs.push(child) - this.#testingServerCommandPorts.push(message.commandServer.port) - resolve() - } - }) - child.stdout.on('data', (data) => { - if (data) { - testingServerLogger.log(data.toString()) - } - }) - child.stderr.on('data', (data) => { - if (data) { - testingServerLogger.error(data.toString()) - } - }) - }) + log.info(`Shutdown in ${Math.round(performance.now() - shutdownStart)}ms`) } } diff --git a/tools/wdio/plugins/testing-server/test-handle-connector.mjs b/tools/wdio/plugins/testing-server/test-handle-connector.mjs index 909529703..78e6bfe91 100644 --- a/tools/wdio/plugins/testing-server/test-handle-connector.mjs +++ b/tools/wdio/plugins/testing-server/test-handle-connector.mjs @@ -50,6 +50,17 @@ export class TestHandleConnector { } } + /** + * Retrieves information about the number and type of requests the testing server + * has seen. + */ + async getRequestCounts () { + const result = await fetch(`${this.#commandServerBase}/test-handle/${this.#testId}/requestCounts`, { + method: 'POST' + }) + return await result.json() + } + /** * Schedules a reply to a server request * @param {'assetServer'|'bamServer'} serverId Id of the server the request will be received on diff --git a/tsconfig.json b/tsconfig.json index 6ba4079b8..3092e5c44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "skipLibCheck": true }, "include": ["src/**/*"], - "exclude": ["**/*.test.js"] + "exclude": ["**/*.test.js", "**/*.component-test.js","**/__mocks__/*.js"] } diff --git a/webpack.config.js b/webpack.config.js index 72d7fecf6..1e90c656b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -7,7 +7,7 @@ const pkg = require('./package.json') const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin -let { PUBLISH, SOURCEMAPS = true, PR_NAME, BRANCH_NAME, VERSION_OVERRIDE } = process.env +let { PUBLISH, SOURCEMAPS = true, COVERAGE = 'false', PR_NAME, BRANCH_NAME, VERSION_OVERRIDE } = process.env // this will change to package.json.version when it is aligned between all the packages let VERSION = VERSION_OVERRIDE || pkg.version let PATH_VERSION, SUBVERSION, PUBLIC_PATH, MAP_PATH @@ -79,6 +79,7 @@ console.log('SUBVERSION', SUBVERSION) console.log('PUBLIC_PATH', PUBLIC_PATH) console.log('MAP_PATH', MAP_PATH) console.log('IS_LOCAL', IS_LOCAL) +console.log('COVERAGE', COVERAGE) if (PR_NAME) console.log('PR_NAME', PR_NAME) if (BRANCH_NAME) console.log('BRANCH_NAME', BRANCH_NAME) process.env.BUILD_VERSION = VERSION @@ -169,7 +170,7 @@ const standardConfig = merge(commonConfig, { { test: /\.js$/, exclude: /(node_modules)/, - use: SUBVERSION === 'LOCAL' + use: COVERAGE === 'true' ? [ { loader: './tools/webpack/loaders/istanbul/index.mjs' }, {