diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 7e14a2d20..257b49059 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -1,5 +1,8 @@ echo "Post Create Starting" +export NVM_DIR="/usr/local/share/nvm" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + nvm install nvm use npm install gulp-cli -g diff --git a/.github/actions/load/action.yml b/.github/actions/load/action.yml new file mode 100644 index 000000000..0102608db --- /dev/null +++ b/.github/actions/load/action.yml @@ -0,0 +1,38 @@ +name: Load working directory +description: Load working directory saved with "actions/save" +inputs: + name: + description: The name used with actions/save + +runs: + using: 'composite' + steps: + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + - uses: actions/github-script@v8 + id: platform + with: + result-encoding: string + script: | + const os = require('os'); + return os.platform(); + - name: 'Clear working directory' + shell: bash + run: | + rm -r "$(pwd)"/* + + - name: Download artifact + uses: actions/download-artifact@v5 + with: + path: '${{ runner.temp }}' + name: '${{ inputs.name }}' + + - name: 'Untar working directory' + shell: bash + run: | + wdir="$(pwd)" + parent="$(dirname "$wdir")" + target="$(basename "$wdir")" + tar ${{ steps.platform.outputs.result == 'win32' && '--force-local' || '' }} -C "$parent" -xf '${{ runner.temp }}/${{ inputs.name }}.tar' "$target" diff --git a/.github/actions/save/action.yml b/.github/actions/save/action.yml new file mode 100644 index 000000000..3efca584c --- /dev/null +++ b/.github/actions/save/action.yml @@ -0,0 +1,41 @@ +name: Save working directory +description: Save working directory, preserving permissions +inputs: + prefix: + description: Prefix to use for autogenerated names + required: false + name: + description: a name to reference with actions/load + required: false +outputs: + name: + description: a name to reference with actions/load + value: ${{ fromJSON(steps.platform.outputs.result).name }} + +runs: + using: 'composite' + steps: + - uses: actions/github-script@v8 + id: platform + with: + script: | + const os = require('os'); + const crypto = require("crypto"); + const id = crypto.randomBytes(16).toString("hex"); + return { + name: ${{ inputs.name && format('"{0}"', inputs.name) || format('"{0}" + id', inputs.prefix || '') }}, + platform: os.platform(), + } + - name: Tar working directory + shell: bash + run: | + wdir="$(pwd)" + parent="$(dirname "$wdir")" + target="$(basename "$wdir")" + tar ${{ fromJSON(steps.platform.outputs.result).platform == 'win32' && '--force-local' || '' }} -C "$parent" -cf "${{ runner.temp }}/${{ fromJSON(steps.platform.outputs.result).name }}.tar" "$target" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + path: '${{ runner.temp }}/${{ fromJSON(steps.platform.outputs.result).name }}.tar' + name: ${{ fromJSON(steps.platform.outputs.result).name }} + overwrite: true diff --git a/.github/actions/wait-for-browserstack/action.yml b/.github/actions/wait-for-browserstack/action.yml index 63d24b87f..12ad89d70 100644 --- a/.github/actions/wait-for-browserstack/action.yml +++ b/.github/actions/wait-for-browserstack/action.yml @@ -1,18 +1,10 @@ name: Wait for browserstack sessions description: Wait until enough browserstack sessions have become available -inputs: - BROWSERSTACK_USER_NAME: - description: "Browserstack user name" - BROWSERSTACK_ACCESS_KEY: - description: "Browserstack access key" runs: using: 'composite' steps: - - env: - BROWSERSTACK_USERNAME: ${{ inputs.BROWSERSTACK_USER_NAME }} - BROWSERSTACK_ACCESS_KEY: ${{ inputs.BROWSERSTACK_ACCESS_KEY }} - shell: bash + - shell: bash run: | while status=$(curl -u "${BROWSERSTACK_USERNAME}:${BROWSERSTACK_ACCESS_KEY}" \ diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml new file mode 100644 index 000000000..662bf38d9 --- /dev/null +++ b/.github/workflows/browser-tests.yml @@ -0,0 +1,116 @@ +name: Run unit tests on all browsers +on: + workflow_call: + inputs: + chunks: + description: Number of chunks to split tests into + required: false + type: number + default: 1 + build-cmd: + description: Build command, run once + required: false + type: string + test-cmd: + description: Test command, run once per chunk + required: true + type: string + timeout: + description: Timeout on test run + required: false + type: number + default: 10 + outputs: + coverage: + description: Artifact name for coverage results + value: ${{ jobs.browser-tests.outputs.coverage }} + secrets: + BROWSERSTACK_USER_NAME: + description: "Browserstack user name" + BROWSERSTACK_ACCESS_KEY: + description: "Browserstack access key" +jobs: + build: + uses: ./.github/workflows/build.yml + with: + build-cmd: ${{ inputs.build-cmd }} + + setup: + needs: build + name: "Setup environment" + runs-on: ubuntu-latest + outputs: + browsers: ${{ toJSON(fromJSON(steps.define.outputs.result).browsers) }} + bstack-key: ${{ steps.bstack-save.outputs.name }} + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Restore working directory + uses: ./.github/actions/load + with: + name: ${{ needs.build.outputs.built-key }} + - name: "Define testing strategy" + uses: actions/github-script@v8 + id: define + with: + script: | + const fs = require('node:fs/promises'); + const browsers = require('./.github/workflows/browser_testing.json'); + const excludeFromBstack = Object.values(browsers).map(browser => browser.bsName); + const bstackBrowsers = Object.fromEntries( + // exlude "latest" version of browsers that we can test on GH actions + Object.entries(require('./browsers.json')) + .filter(([name, def]) => !excludeFromBstack.includes(def.browser) || def.browser_version !== 'latest') + ) + const updatedBrowsersJson = JSON.stringify(bstackBrowsers, null, 2); + console.log("Using browsers.json:", updatedBrowsersJson); + await fs.writeFile('./browsers.json', updatedBrowsersJson); + return { + hasBSBrowsers: Object.keys(bstackBrowsers).length > 0, + browsers: Object.entries(browsers).map(([name, def]) => Object.assign({name}, def)) + } + - name: "Save working directory" + id: bstack-save + if: ${{ fromJSON(steps.define.outputs.result).hasBSBrowsers }} + uses: ./.github/actions/save + with: + prefix: browserstack- + + test-build-logic: + needs: build + name: "Test build logic" + uses: + ./.github/workflows/run-tests.yml + with: + built-key: ${{ needs.build.outputs.built-key }} + test-cmd: gulp test-build-logic + + browser-tests: + needs: [setup, build] + name: "Browser: ${{ matrix.browser.name }}" + strategy: + fail-fast: false + matrix: + browser: ${{ fromJSON(needs.setup.outputs.browsers) }} + uses: + ./.github/workflows/run-tests.yml + with: + built-key: ${{ needs.build.outputs.built-key }} + test-cmd: ${{ inputs.test-cmd }} --browsers ${{ matrix.browser.name }} ${{ matrix.browser.coverage && '--coverage' || '--no-coverage' }} + chunks: ${{ inputs.chunks }} + runs-on: ${{ matrix.browser.runsOn || 'ubuntu-latest' }} + + browserstack-tests: + needs: setup + if: ${{ needs.setup.outputs.bstack-key }} + name: "Browserstack tests" + uses: + ./.github/workflows/run-tests.yml + with: + built-key: ${{ needs.setup.outputs.bstack-key }} + test-cmd: ${{ inputs.test-cmd }} --browserstack --no-coverage + chunks: ${{ inputs.chunks }} + browserstack: true + secrets: + BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} diff --git a/.github/workflows/browser_testing.json b/.github/workflows/browser_testing.json new file mode 100644 index 000000000..aee1631ea --- /dev/null +++ b/.github/workflows/browser_testing.json @@ -0,0 +1,17 @@ +{ + "ChromeHeadless": { + "bsName": "chrome", + "coverage": true + }, + "EdgeHeadless": { + "bsName": "edge", + "runsOn": "windows-latest" + }, + "SafariNative": { + "bsName": "safari", + "runsOn": "macos-latest" + }, + "FirefoxHeadless": { + "bsName": "firefox" + } +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..6942d25b5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,43 @@ +name: Run unit tests +on: + workflow_call: + inputs: + source-key: + description: Artifact name for source directory + type: string + required: false + default: source + build-cmd: + description: Build command + required: false + type: string + outputs: + built-key: + description: Artifact name for built directory + value: ${{ jobs.build.outputs.built-key }} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + built-key: ${{ inputs.build-cmd && steps.save.outputs.name || inputs.source-key }} + steps: + - name: Checkout + if: ${{ inputs.build-cmd }} + uses: actions/checkout@v5 + - name: Restore source + if: ${{ inputs.build-cmd }} + uses: ./.github/actions/load + with: + name: ${{ inputs.source-key }} + - name: Build + if: ${{ inputs.build-cmd }} + run: ${{ inputs.build-cmd }} + - name: 'Save working directory' + id: save + if: ${{ inputs.build-cmd }} + uses: ./.github/actions/save + with: + prefix: 'build-' diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 000000000..c1bb56f93 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,174 @@ +name: Run unit tests +on: + workflow_call: + inputs: + chunks: + description: Number of chunks to split tests into + required: false + type: number + default: 1 + build-cmd: + description: Build command, run once + required: false + type: string + built-key: + description: Artifact name for built source + required: false + type: string + test-cmd: + description: Test command, run once per chunk + required: true + type: string + browserstack: + description: If true, set up browserstack environment and adjust concurrency + required: false + type: boolean + default: false + timeout: + description: Timeout on test run + required: false + type: number + default: 10 + runs-on: + description: Runner image + required: false + default: ubuntu-latest + type: string + outputs: + coverage: + description: Artifact name for coverage results + value: ${{ jobs.collect-coverage.outputs.coverage }} + secrets: + BROWSERSTACK_USER_NAME: + description: "Browserstack user name" + BROWSERSTACK_ACCESS_KEY: + description: "Browserstack access key" + +jobs: + checkout: + name: "Set up environment" + runs-on: ubuntu-latest + outputs: + chunks: ${{ steps.chunks.outputs.chunks }} + steps: + - name: Define chunks + id: chunks + run: | + echo 'chunks=[ '$(seq --separator=, 1 1 ${{ inputs.chunks }})' ]' >> "$GITHUB_OUTPUT" + + build: + uses: ./.github/workflows/build.yml + with: + build-cmd: ${{ !inputs.built-key && inputs.build-cmd || '' }} + source-key: ${{ inputs.built-key || 'source' }} + + run-tests: + needs: [checkout, build] + strategy: + fail-fast: false + max-parallel: ${{ inputs.browserstack && 1 || inputs.chunks }} + matrix: + chunk-no: ${{ fromJSON(needs.checkout.outputs.chunks) }} + + name: "Test chunk ${{ matrix.chunk-no }}" + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USER_NAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + TEST_CHUNKS: ${{ inputs.chunks }} + TEST_CHUNK: ${{ matrix.chunk-no }} + outputs: + coverage: ${{ steps.coverage.outputs.coverage }} + concurrency: + # The following generates 'browserstack-' when inputs.browserstack is true, and a hopefully unique ID otherwise + # Ideally we'd like to serialize browserstack access across all workflows, but github's max queue length is only 1 + # (cfr. https://github.com/orgs/community/discussions/12835) + # so we add the run_id to serialize only within one push / pull request (which has the effect of queueing e2e and unit tests) + group: ${{ inputs.browserstack && 'browser' || github.run_id }}${{ inputs.browserstack && 'stac' || inputs.test-cmd }}${{ inputs.browserstack && 'k' || matrix.chunk-no }}-${{ github.run_id }} + cancel-in-progress: false + + runs-on: ${{ inputs.runs-on }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Restore source + uses: ./.github/actions/load + with: + name: ${{ needs.build.outputs.built-key }} + + - name: 'BrowserStack Env Setup' + if: ${{ inputs.browserstack }} + uses: 'browserstack/github-actions/setup-env@master' + with: + username: ${{ secrets.BROWSERSTACK_USER_NAME}} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + build-name: Run ${{github.run_id}}, attempt ${{ github.run_attempt }}, chunk ${{ matrix.chunk-no }}, ref ${{ github.event_name == 'pull_request_target' && format('PR {0}', github.event.pull_request.number) || github.ref }}, ${{ inputs.test-cmd }} + + - name: 'BrowserStackLocal Setup' + if: ${{ inputs.browserstack }} + uses: 'browserstack/github-actions/setup-local@master' + with: + local-testing: start + local-identifier: random + + - name: 'Wait for browserstack' + if: ${{ inputs.browserstack }} + uses: ./.github/actions/wait-for-browserstack + + - name: Run tests + uses: nick-fields/retry@v3 + with: + timeout_minutes: ${{ inputs.timeout }} + max_attempts: 3 + command: ${{ inputs.test-cmd }} + + - name: 'BrowserStackLocal Stop' + if: ${{ inputs.browserstack }} + uses: 'browserstack/github-actions/setup-local@master' + with: + local-testing: stop + + - name: 'Check for coverage' + id: 'coverage' + shell: bash + run: | + if [ -d "./build/coverage" ]; then + echo 'coverage=true' >> "$GITHUB_OUTPUT"; + fi + + - name: 'Save coverage result' + if: ${{ steps.coverage.outputs.coverage }} + uses: actions/upload-artifact@v4 + with: + name: coverage-partial-${{inputs.test-cmd}}-${{ matrix.chunk-no }} + path: ./build/coverage + overwrite: true + + collect-coverage: + if: ${{ needs.run-tests.outputs.coverage }} + needs: [build, run-tests] + name: 'Collect coverage results' + runs-on: ubuntu-latest + outputs: + coverage: coverage-complete-${{ inputs.test-cmd }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Restore source + uses: ./.github/actions/load + with: + name: ${{ needs.build.outputs.built-key }} + + - name: Download coverage results + uses: actions/download-artifact@v6 + with: + path: ./build/coverage + pattern: coverage-partial-${{ inputs.test-cmd }}-* + merge-multiple: true + + - name: 'Save working directory' + uses: ./.github/actions/save + with: + name: coverage-complete-${{ inputs.test-cmd }} + diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml deleted file mode 100644 index 54bae4417..000000000 --- a/.github/workflows/run-unit-tests.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Run unit tests -on: - workflow_call: - inputs: - build-cmd: - description: Build command, run once - required: true - type: string - test-cmd: - description: Test command, run once per chunk - required: true - type: string - serialize: - description: If true, allow only one concurrent chunk (see note on concurrency below) - required: false - type: boolean - outputs: - wdir: - description: Cache key for the working directory after running tests - value: ${{ jobs.chunk-4.outputs.wdir }} - secrets: - BROWSERSTACK_USER_NAME: - description: "Browserstack user name" - BROWSERSTACK_ACCESS_KEY: - description: "Browserstack access key" - -jobs: - build: - name: Build - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - - - name: Fetch source - uses: actions/cache/restore@v5 - with: - path: . - key: source-${{ github.run_id }} - fail-on-cache-miss: true - - - name: Build - run: ${{ inputs.build-cmd }} - - - name: Cache build output - uses: actions/cache/save@v5 - with: - path: . - key: build-${{ inputs.build-cmd }}-${{ github.run_id }} - - - name: Verify cache - uses: actions/cache/restore@v5 - with: - path: . - key: build-${{ inputs.build-cmd }}-${{ github.run_id }} - lookup-only: true - fail-on-cache-miss: true - - chunk-1: - needs: build - name: Run tests (chunk 1 of 4) - uses: ./.github/workflows/test-chunk.yml - with: - chunk-no: 1 - wdir: build-${{ inputs.build-cmd }}-${{ github.run_id }} - cmd: ${{ inputs.test-cmd }} - serialize: ${{ inputs.serialize }} - secrets: - BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - chunk-2: - name: Run tests (chunk 2 of 4) - needs: chunk-1 - uses: ./.github/workflows/test-chunk.yml - with: - chunk-no: 2 - wdir: ${{ needs.chunk-1.outputs.wdir }} - cmd: ${{ inputs.test-cmd }} - serialize: ${{ inputs.serialize }} - secrets: - BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - chunk-3: - name: Run tests (chunk 3 of 4) - needs: chunk-2 - uses: ./.github/workflows/test-chunk.yml - with: - chunk-no: 3 - wdir: ${{ needs.chunk-2.outputs.wdir }} - cmd: ${{ inputs.test-cmd }} - serialize: ${{ inputs.serialize }} - secrets: - BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - chunk-4: - name: Run tests (chunk 4 of 4) - needs: chunk-3 - uses: ./.github/workflows/test-chunk.yml - with: - chunk-no: 4 - wdir: ${{ needs.chunk-3.outputs.wdir }} - cmd: ${{ inputs.test-cmd }} - serialize: ${{ inputs.serialize }} - secrets: - BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} diff --git a/.github/workflows/test-chunk.yml b/.github/workflows/test-chunk.yml deleted file mode 100644 index 3c0987d91..000000000 --- a/.github/workflows/test-chunk.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Test chunk -on: - workflow_call: - inputs: - serialize: - required: false - type: boolean - cmd: - required: true - type: string - chunk-no: - required: true - type: number - wdir: - required: true - type: string - outputs: - wdir: - description: "Cache key for the working directory after running tests" - value: test-${{ inputs.cmd }}-${{ inputs.chunk-no }}-${{ github.run_id }} - secrets: - BROWSERSTACK_USER_NAME: - description: "Browserstack user name" - BROWSERSTACK_ACCESS_KEY: - description: "Browserstack access key" - -concurrency: - # The following generates 'browserstack-' when inputs.serialize is true, and a hopefully unique ID otherwise - # Ideally we'd like to serialize browserstack access across all workflows, but github's max queue length is only 1 - # (cfr. https://github.com/orgs/community/discussions/12835) - # so we add the run_id to serialize only within one push / pull request (which has the effect of queueing e2e and unit tests) - group: ${{ inputs.serialize && 'browser' || github.run_id }}${{ inputs.serialize && 'stack' || inputs.cmd }}-${{ github.run_id }} - cancel-in-progress: false - -jobs: - test: - name: "Test chunk ${{ inputs.chunk-no }}" - env: - BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USER_NAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - TEST_CHUNKS: 4 - TEST_CHUNK: ${{ inputs.chunk-no }} - runs-on: ubuntu-latest - steps: - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - - - name: Restore working directory - id: restore-dir - uses: actions/cache/restore@v5 - with: - path: . - key: ${{ inputs.wdir }} - fail-on-cache-miss: true - - - name: 'BrowserStack Env Setup' - uses: 'browserstack/github-actions/setup-env@master' - with: - username: ${{ secrets.BROWSERSTACK_USER_NAME}} - access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - - - name: 'BrowserStackLocal Setup' - uses: 'browserstack/github-actions/setup-local@master' - with: - local-testing: start - local-identifier: random - - - name: 'Wait for browserstack' - if: ${{ inputs.serialize }} - uses: ./.github/actions/wait-for-browserstack - with: - BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - - - name: Run tests - uses: nick-fields/retry@v3 - with: - timeout_minutes: 8 - max_attempts: 3 - command: ${{ inputs.cmd }} - - - name: 'BrowserStackLocal Stop' - uses: 'browserstack/github-actions/setup-local@master' - with: - local-testing: stop - - - name: Save working directory - uses: actions/cache/save@v5 - with: - path: . - key: test-${{ inputs.cmd }}-${{ inputs.chunk-no }}-${{ github.run_id }} - - - name: Verify cache - uses: actions/cache/restore@v5 - with: - path: . - key: test-${{ inputs.cmd }}-${{ inputs.chunk-no }}-${{ github.run_id }} - lookup-only: true - fail-on-cache-miss: true - - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8765056f..c384cd29b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,35 +59,22 @@ jobs: - name: Install dependencies run: npm ci - - name: Cache source - uses: actions/cache/save@v5 + - name: 'Save working directory' + uses: ./.github/actions/save with: - path: . - key: source-${{ github.run_id }} - - - name: Verify cache - uses: actions/cache/restore@v5 - with: - path: . - key: source-${{ github.run_id }} - lookup-only: true - fail-on-cache-miss: true + name: source lint: name: "Run linter" needs: checkout runs-on: ubuntu-latest steps: - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' + - name: Checkout + uses: actions/checkout@v5 - name: Restore source - uses: actions/cache/restore@v5 + uses: ./.github/actions/load with: - path: . - key: source-${{ github.run_id }} - fail-on-cache-miss: true + name: source - name: lint run: | npx eslint @@ -95,91 +82,52 @@ jobs: test-no-features: name: "Unit tests (all features disabled)" needs: checkout - uses: ./.github/workflows/run-unit-tests.yml + uses: ./.github/workflows/run-tests.yml with: + chunks: 8 build-cmd: npx gulp precompile-all-features-disabled test-cmd: npx gulp test-all-features-disabled-nobuild - serialize: false + browserstack: false secrets: BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} test: name: "Unit tests (all features enabled + coverage)" needs: checkout - uses: ./.github/workflows/run-unit-tests.yml + uses: ./.github/workflows/browser-tests.yml with: + chunks: 8 build-cmd: npx gulp precompile - test-cmd: npx gulp test-only-nobuild --browserstack - serialize: true + test-cmd: npx gulp test-only-nobuild secrets: BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} test-e2e: - name: "End-to-end tests" + name: End-to-end tests needs: checkout - runs-on: ubuntu-latest - concurrency: - # see test-chunk.yml for notes on concurrency groups - group: browserstack-${{ github.run_id }} - cancel-in-progress: false - env: - BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USER_NAME }} + uses: ./.github/workflows/run-tests.yml + with: + test-cmd: npx gulp e2e-test + browserstack: true + timeout: 15 + secrets: + BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - steps: - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - - name: Restore source - uses: actions/cache/restore@v5 - with: - path: . - key: source-${{ github.run_id }} - fail-on-cache-miss: true - - - name: 'BrowserStack Env Setup' - uses: 'browserstack/github-actions/setup-env@master' - with: - username: ${{ secrets.BROWSERSTACK_USER_NAME}} - access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - - - name: 'BrowserStackLocal Setup' - uses: 'browserstack/github-actions/setup-local@master' - with: - local-testing: start - local-identifier: random - - - name: 'Wait for browserstack' - uses: ./.github/actions/wait-for-browserstack - with: - BROWSERSTACK_USER_NAME: ${{ secrets.BROWSERSTACK_USER_NAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - - - name: Run tests - uses: nick-fields/retry@v3 - with: - timeout_minutes: 20 - max_attempts: 3 - command: npx gulp e2e-test - - - name: 'BrowserStackLocal Stop' - uses: 'browserstack/github-actions/setup-local@master' - with: - local-testing: stop coveralls: name: Update coveralls needs: [checkout, test] - if: false #REMOVE ONCE REPO IS PUBLIC runs-on: ubuntu-latest steps: - - name: Restore working directory - uses: actions/cache/restore@v5 + - name: Checkout + uses: actions/checkout@v5 + + - name: Restore source + uses: ./.github/actions/load with: - path: . - key: ${{ needs.test.outputs.wdir }} - fail-on-cache-miss: true + name: ${{ needs.test.outputs.coverage }} + - name: Coveralls uses: coverallsapp/github-action@v2 with: diff --git a/customize/buildOptions.mjs b/customize/buildOptions.mjs index 7b8013ab2..3341f35b0 100644 --- a/customize/buildOptions.mjs +++ b/customize/buildOptions.mjs @@ -1,8 +1,12 @@ import path from 'path' -import validate from 'schema-utils' +import { validate } from 'schema-utils' const boModule = path.resolve(import.meta.dirname, '../dist/src/buildOptions.mjs') +/** + * Resolve the absolute path of the default build options module. + * @returns {string} Absolute path to the generated build options module. + */ export function getBuildOptionsModule () { return boModule } @@ -25,6 +29,11 @@ const schema = { } } +/** + * Validate and load build options overrides. + * @param {object} [options] user supplied overrides + * @returns {Promise} Promise resolving to merged build options. + */ export function getBuildOptions (options = {}) { validate(schema, options, { name: 'Prebid build options', diff --git a/gulpfile.js b/gulpfile.js index 4f3de3b8c..f272e3cfc 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -39,7 +39,7 @@ const TerserPlugin = require('terser-webpack-plugin'); const {precompile, babelPrecomp} = require('./gulp.precompilation.js'); -const TEST_CHUNKS = 4; +const TEST_CHUNKS = 8; // these modules must be explicitly listed in --modules to be included in the build, won't be part of "all" modules var explicitModules = [ @@ -521,7 +521,7 @@ gulp.task('build-bundle-verbose', gulp.series(precompile(), makeWebpackPkg(makeV // public tasks (dependencies are needed for each task since they can be ran on their own) gulp.task('update-browserslist', execaTask('npx update-browserslist-db@latest')); gulp.task('test-build-logic', execaTask('npx mocha ./test/build-logic')) -gulp.task('test-only-nobuild', gulp.series('test-build-logic', testTaskMaker({coverage: true}))) +gulp.task('test-only-nobuild', gulp.series(testTaskMaker({coverage: argv.coverage ?? true}))) gulp.task('test-only', gulp.series('test-build-logic', 'precompile', test)); gulp.task('test-all-features-disabled-nobuild', testTaskMaker({disableFeatures: helpers.getTestDisableFeatures(), oneBrowser: 'chrome', watch: false})); diff --git a/integrationExamples/gpt/neuwoRtdProvider_example.html b/integrationExamples/gpt/neuwoRtdProvider_example.html index 943b98ebe..f65e32d6c 100644 --- a/integrationExamples/gpt/neuwoRtdProvider_example.html +++ b/integrationExamples/gpt/neuwoRtdProvider_example.html @@ -121,10 +121,30 @@ const inputIabContentTaxonomyVersion = document.getElementById('iab-content-taxonomy-version'); const iabContentTaxonomyVersion = inputIabContentTaxonomyVersion ? inputIabContentTaxonomyVersion.value : undefined; + // Cache Option + const inputEnableCache = document.getElementById('enable-cache'); + const enableCache = inputEnableCache ? inputEnableCache.checked : undefined; + + // URL Stripping Options + const inputStripAllQueryParams = document.getElementById('strip-all-query-params'); + const stripAllQueryParams = inputStripAllQueryParams ? inputStripAllQueryParams.checked : undefined; + + const inputStripQueryParamsForDomains = document.getElementById('strip-query-params-for-domains'); + const stripQueryParamsForDomainsValue = inputStripQueryParamsForDomains ? inputStripQueryParamsForDomains.value.trim() : ''; + const stripQueryParamsForDomains = stripQueryParamsForDomainsValue ? stripQueryParamsForDomainsValue.split(',').map(d => d.trim()).filter(d => d) : undefined; + + const inputStripQueryParams = document.getElementById('strip-query-params'); + const stripQueryParamsValue = inputStripQueryParams ? inputStripQueryParams.value.trim() : ''; + const stripQueryParams = stripQueryParamsValue ? stripQueryParamsValue.split(',').map(p => p.trim()).filter(p => p) : undefined; + + const inputStripFragments = document.getElementById('strip-fragments'); + const stripFragments = inputStripFragments ? inputStripFragments.checked : undefined; + oajs.que.push(function () { oajs.setConfig({ debug: true, realTimeData: { + auctionDelay: 500, dataProviders: [ { name: "NeuwoRTDModule", @@ -133,7 +153,12 @@ neuwoApiUrl, neuwoApiToken, websiteToAnalyseUrl, - iabContentTaxonomyVersion + iabContentTaxonomyVersion, + enableCache, + stripAllQueryParams, + stripQueryParamsForDomains, + stripQueryParams, + stripFragments } } ] @@ -166,11 +191,14 @@

Basic OpenAds.js Example using Neuwo Rtd Provider

after running commands in the openads.js source folder that includes libraries/modules/neuwoRtdProvider.js + // Install dependencies npm ci + + // Run a local development server npx gulp serve --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter // No tests - npx gulp serve-fast --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter --notests + npx gulp serve-fast --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter // Only tests npx gulp test-only --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter --file=test/spec/modules/neuwoRtdProvider_spec.js @@ -180,10 +208,49 @@

Basic OpenAds.js Example using Neuwo Rtd Provider

Neuwo Rtd Provider Configuration

Add token and url to use for Neuwo extension configuration

- - - - +
+ +
+
+ +
+
+ +
+ +

IAB Content Taxonomy Options

+
+ +
+ +

Cache Options

+
+ +
+ +

URL Cleaning Options

+
+ +
+
+ +
+
+ +
+
+ +
+
@@ -232,4 +299,86 @@

Neuwo Data in Bid Request

if (helper) helper.style.display = location.href !== 'http://localhost:9999/integrationExamples/gpt/neuwoRtdProvider_example.html' ? 'block' : 'none'; + + \ No newline at end of file diff --git a/integrationExamples/gpt/prebidServer_example.html b/integrationExamples/gpt/prebidServer_example.html index 291e5ecd7..44fe229fa 100644 --- a/integrationExamples/gpt/prebidServer_example.html +++ b/integrationExamples/gpt/prebidServer_example.html @@ -83,9 +83,10 @@

OpenAds Test Bidder Example

+

+

Banner ad
- \ No newline at end of file + diff --git a/karma.conf.maker.js b/karma.conf.maker.js index 1068e9828..6866b296c 100644 --- a/karma.conf.maker.js +++ b/karma.conf.maker.js @@ -40,6 +40,7 @@ function newWebpackConfig(codeCoverage, disableFeatures) { function newPluginsArray(browserstack) { var plugins = [ 'karma-chrome-launcher', + 'karma-safarinative-launcher', 'karma-coverage', 'karma-mocha', 'karma-chai', @@ -47,14 +48,14 @@ function newPluginsArray(browserstack) { 'karma-sourcemap-loader', 'karma-spec-reporter', 'karma-webpack', - 'karma-mocha-reporter' + 'karma-mocha-reporter', + '@chiragrupani/karma-chromium-edge-launcher', ]; if (browserstack) { plugins.push('karma-browserstack-launcher'); } plugins.push('karma-firefox-launcher'); plugins.push('karma-opera-launcher'); - plugins.push('karma-safari-launcher'); plugins.push('karma-script-launcher'); return plugins; } @@ -88,7 +89,7 @@ function setBrowsers(karmaConf, browserstack) { karmaConf.browserStack = { username: process.env.BROWSERSTACK_USERNAME, accessKey: process.env.BROWSERSTACK_ACCESS_KEY, - build: 'Prebidjs Unit Tests ' + new Date().toLocaleString() + build: process.env.BROWSERSTACK_BUILD_NAME } if (process.env.TRAVIS) { karmaConf.browserStack.startTunnel = false; @@ -174,10 +175,10 @@ module.exports = function(codeCoverage, browserstack, watchMode, file, disableFe // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits singleRun: !watchMode, - browserDisconnectTimeout: 1e5, // default 2000 - browserNoActivityTimeout: 1e5, // default 10000 - captureTimeout: 3e5, // default 60000, - browserDisconnectTolerance: 3, + browserDisconnectTimeout: 1e4, + browserNoActivityTimeout: 3e4, + captureTimeout: 2e4, + browserDisconnectTolerance: 5, concurrency: 5, // browserstack allows us 5 concurrent sessions plugins: plugins diff --git a/libraries/adagioUtils/adagioUtils.js b/libraries/adagioUtils/adagioUtils.js index c2614c45d..265a44201 100644 --- a/libraries/adagioUtils/adagioUtils.js +++ b/libraries/adagioUtils/adagioUtils.js @@ -22,6 +22,7 @@ export const _ADAGIO = (function() { const w = getBestWindowForAdagio(); w.ADAGIO = w.ADAGIO || {}; + // TODO: consider using the Prebid-generated page view ID instead of generating a custom one w.ADAGIO.pageviewId = w.ADAGIO.pageviewId || generateUUID(); w.ADAGIO.adUnits = w.ADAGIO.adUnits || {}; w.ADAGIO.pbjsAdUnits = w.ADAGIO.pbjsAdUnits || []; diff --git a/libraries/pbsExtensions/processors/pageViewIds.js b/libraries/pbsExtensions/processors/pageViewIds.js new file mode 100644 index 000000000..2de754917 --- /dev/null +++ b/libraries/pbsExtensions/processors/pageViewIds.js @@ -0,0 +1,9 @@ +import {deepSetValue} from '../../../src/utils.js'; + +export function setRequestExtPrebidPageViewIds(ortbRequest, bidderRequest) { + deepSetValue( + ortbRequest, + `ext.openads.page_view_ids.${bidderRequest.bidderCode}`, + bidderRequest.pageViewId + ); +} diff --git a/libraries/pbsExtensions/processors/pbs.js b/libraries/pbsExtensions/processors/pbs.js index 227eea901..e7df9b766 100644 --- a/libraries/pbsExtensions/processors/pbs.js +++ b/libraries/pbsExtensions/processors/pbs.js @@ -7,6 +7,7 @@ import {setImpAdUnitCode} from './adUnitCode.js'; import {setRequestExtPrebid, setRequestExtPrebidChannel} from './requestExtPrebid.js'; import {setBidResponseVideoCache} from './video.js'; import {addEventTrackers} from './eventTrackers.js'; +import {setRequestExtPrebidPageViewIds} from './pageViewIds.js'; export const PBS_PROCESSORS = { [REQUEST]: { @@ -21,7 +22,11 @@ export const PBS_PROCESSORS = { extPrebidAliases: { // sets ext.openads.aliases fn: setRequestExtPrebidAliases - } + }, + extPrebidPageViewIds: { + // sets ext.openads.page_view_ids + fn: setRequestExtPrebidPageViewIds + }, }, [IMP]: { params: { diff --git a/libraries/vidazooUtils/bidderUtils.js b/libraries/vidazooUtils/bidderUtils.js index 084329368..fa2cea1b6 100644 --- a/libraries/vidazooUtils/bidderUtils.js +++ b/libraries/vidazooUtils/bidderUtils.js @@ -6,7 +6,8 @@ import { parseSizesInput, parseUrl, triggerPixel, - uniques + uniques, + getWinDimensions } from '../../src/utils.js'; import {chunk} from '../chunk/chunk.js'; import {CURRENCY, DEAL_ID_EXPIRY, SESSION_ID_KEY, TTL_SECONDS, UNIQUE_DEAL_ID_EXPIRY} from './constants.js'; @@ -280,7 +281,7 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder uniqueDealId: uniqueDealId, bidderVersion: bidderVersion, prebidVersion: '$prebid.version$', - res: `${screen.width}x${screen.height}`, + res: getScreenResolution(), schain: schain, mediaTypes: mediaTypes, isStorageAllowed: isStorageAllowed, @@ -374,6 +375,15 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder return data; } +function getScreenResolution() { + const dimensions = getWinDimensions(); + const width = dimensions?.screen?.width; + const height = dimensions?.screen?.height; + if (width != null && height != null) { + return `${width}x${height}` + } +} + export function createInterpretResponseFn(bidderCode, allowSingleRequest) { return function interpretResponse(serverResponse, request) { if (!serverResponse || !serverResponse.body) { diff --git a/metadata/modules.json b/metadata/modules.json index ebd317026..4aa546de0 100644 --- a/metadata/modules.json +++ b/metadata/modules.json @@ -5980,4 +5980,4 @@ "gvlid": null } ] -} \ No newline at end of file +} diff --git a/metadata/modules/permutiveIdentityManagerIdSystem.json b/metadata/modules/permutiveIdentityManagerIdSystem.json index e8fc0cd1f..ce7b56fec 100644 --- a/metadata/modules/permutiveIdentityManagerIdSystem.json +++ b/metadata/modules/permutiveIdentityManagerIdSystem.json @@ -10,4 +10,4 @@ "aliasOf": null } ] -} \ No newline at end of file +} diff --git a/metadata/modules/permutiveRtdProvider.json b/metadata/modules/permutiveRtdProvider.json index 0e675450f..691d87b6a 100644 --- a/metadata/modules/permutiveRtdProvider.json +++ b/metadata/modules/permutiveRtdProvider.json @@ -9,4 +9,4 @@ "disclosureURL": null } ] -} \ No newline at end of file +} diff --git a/modules/connectIdSystem.js b/modules/connectIdSystem.js index 01b7e9196..006cb06d0 100644 --- a/modules/connectIdSystem.js +++ b/modules/connectIdSystem.js @@ -9,7 +9,7 @@ import {ajax} from '../src/ajax.js'; import {submodule} from '../src/hook.js'; import {getRefererInfo} from '../src/refererDetection.js'; -import {getStorageManager} from '../src/storageManager.js'; +import {getStorageManager, STORAGE_TYPE_COOKIES, STORAGE_TYPE_LOCALSTORAGE} from '../src/storageManager.js'; import {formatQS, isNumber, isPlainObject, logError, parseUrl} from '../src/utils.js'; import {MODULE_TYPE_UID} from '../src/activities/modules.js'; @@ -45,15 +45,23 @@ const O_AND_O_DOMAINS = [ export const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME}); /** + * Stores the ConnectID object in browser storage according to storage configuration * @function - * @param {Object} obj + * @param {Object} obj - The ID object to store + * @param {Object} [storageConfig={}] - Storage configuration + * @param {string} [storageConfig.type] - Storage type: 'cookie', 'html5', or 'cookie&html5' */ -function storeObject(obj) { +function storeObject(obj, storageConfig = {}) { const expires = Date.now() + STORAGE_DURATION; - if (storage.cookiesAreEnabled()) { + const storageType = storageConfig.type || ''; + + const useCookie = !storageType || storageType.includes(STORAGE_TYPE_COOKIES); + const useLocalStorage = !storageType || storageType.includes(STORAGE_TYPE_LOCALSTORAGE); + + if (useCookie && storage.cookiesAreEnabled()) { setEtldPlusOneCookie(MODULE_NAME, JSON.stringify(obj), new Date(expires), getSiteHostname()); } - if (storage.localStorageIsEnabled()) { + if (useLocalStorage && storage.localStorageIsEnabled()) { storage.setDataInLocalStorage(MODULE_NAME, JSON.stringify(obj)); } } @@ -110,8 +118,17 @@ function getIdFromLocalStorage() { return null; } -function syncLocalStorageToCookie() { - if (!storage.cookiesAreEnabled()) { +/** + * Syncs ID from localStorage to cookie if storage configuration allows + * @function + * @param {Object} [storageConfig={}] - Storage configuration + * @param {string} [storageConfig.type] - Storage type: 'cookie', 'html5', or 'cookie&html5' + */ +function syncLocalStorageToCookie(storageConfig = {}) { + const storageType = storageConfig.type || ''; + const useCookie = !storageType || storageType.includes(STORAGE_TYPE_COOKIES); + + if (!useCookie || !storage.cookiesAreEnabled()) { return; } const value = getIdFromLocalStorage(); @@ -129,12 +146,19 @@ function isStale(storedIdData) { return false; } -function getStoredId() { +/** + * Retrieves stored ConnectID from cookie or localStorage + * @function + * @param {Object} [storageConfig={}] - Storage configuration + * @param {string} [storageConfig.type] - Storage type: 'cookie', 'html5', or 'cookie&html5' + * @returns {Object|null} The stored ID object or null if not found + */ +function getStoredId(storageConfig = {}) { let storedId = getIdFromCookie(); if (!storedId) { storedId = getIdFromLocalStorage(); if (storedId && !isStale(storedId)) { - syncLocalStorageToCookie(); + syncLocalStorageToCookie(storageConfig); } } return storedId; @@ -191,13 +215,14 @@ export const connectIdSubmodule = { return; } const params = config.params || {}; + const storageConfig = config.storage || {}; if (!params || (typeof params.pixelId === 'undefined' && typeof params.endpoint === 'undefined')) { logError(`${MODULE_NAME} module: configuration requires the 'pixelId'.`); return; } - const storedId = getStoredId(); + const storedId = getStoredId(storageConfig); let shouldResync = isStale(storedId); @@ -213,7 +238,7 @@ export const connectIdSubmodule = { } if (!shouldResync) { storedId.lastUsed = Date.now(); - storeObject(storedId); + storeObject(storedId, storageConfig); return {id: storedId}; } } @@ -274,7 +299,7 @@ export const connectIdSubmodule = { } responseObj.ttl = validTTLMiliseconds; } - storeObject(responseObj); + storeObject(responseObj, storageConfig); } else { logError(`${MODULE_NAME} module: UPS response returned an invalid payload ${response}`); } diff --git a/modules/neuwoRtdProvider.js b/modules/neuwoRtdProvider.js index 99715f0b4..3255547aa 100644 --- a/modules/neuwoRtdProvider.js +++ b/modules/neuwoRtdProvider.js @@ -23,6 +23,17 @@ import { deepSetValue, logError, logInfo, mergeDeep } from "../src/utils.js"; const MODULE_NAME = "NeuwoRTDModule"; export const DATA_PROVIDER = "www.neuwo.ai"; +// Cached API response to avoid redundant requests. +let globalCachedResponse; + +/** + * Clears the cached API response. Primarily used for testing. + * @private + */ +export function clearCache() { + globalCachedResponse = undefined; +} + // Maps the IAB Content Taxonomy version string to the corresponding segtax ID. // Based on https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/main/AdCOM%20v1.0%20FINAL.md#list--category-taxonomies- const IAB_CONTENT_TAXONOMY_MAP = { @@ -36,11 +47,13 @@ const IAB_CONTENT_TAXONOMY_MAP = { /** * Validates the configuration and initialises the module. + * * @param {Object} config The module configuration. * @param {Object} userConsent The user consent object. * @returns {boolean} `true` if the module is configured correctly, otherwise `false`. */ function init(config, userConsent) { + logInfo(MODULE_NAME, "init:", config, userConsent); const params = config?.params || {}; if (!params.neuwoApiUrl) { logError(MODULE_NAME, "init:", "Missing Neuwo Edge API Endpoint URL"); @@ -55,18 +68,46 @@ function init(config, userConsent) { /** * Fetches contextual data from the Neuwo API and enriches the bid request object with IAB categories. + * Uses cached response if available to avoid redundant API calls. + * * @param {Object} reqBidsConfigObj The bid request configuration object. * @param {function} callback The callback function to continue the auction. * @param {Object} config The module configuration. + * @param {Object} config.params Configuration parameters. + * @param {string} config.params.neuwoApiUrl The Neuwo API endpoint URL. + * @param {string} config.params.neuwoApiToken The Neuwo API authentication token. + * @param {string} [config.params.websiteToAnalyseUrl] Optional URL to analyze instead of current page. + * @param {string} [config.params.iabContentTaxonomyVersion] IAB content taxonomy version (default: "3.0"). + * @param {boolean} [config.params.enableCache=true] If true, caches API responses to avoid redundant requests (default: true). + * @param {boolean} [config.params.stripAllQueryParams] If true, strips all query parameters from the URL. + * @param {string[]} [config.params.stripQueryParamsForDomains] List of domains for which to strip all query params. + * @param {string[]} [config.params.stripQueryParams] List of specific query parameter names to strip. + * @param {boolean} [config.params.stripFragments] If true, strips URL fragments (hash). * @param {Object} userConsent The user consent object. */ export function getBidRequestData(reqBidsConfigObj, callback, config, userConsent) { logInfo(MODULE_NAME, "getBidRequestData:", "starting getBidRequestData", config); - const { websiteToAnalyseUrl, neuwoApiUrl, neuwoApiToken, iabContentTaxonomyVersion } = - config.params; + const { + websiteToAnalyseUrl, + neuwoApiUrl, + neuwoApiToken, + iabContentTaxonomyVersion, + enableCache = true, + stripAllQueryParams, + stripQueryParamsForDomains, + stripQueryParams, + stripFragments, + } = config.params; - const pageUrl = encodeURIComponent(websiteToAnalyseUrl || getRefererInfo().page); + const rawUrl = websiteToAnalyseUrl || getRefererInfo().page; + const processedUrl = cleanUrl(rawUrl, { + stripAllQueryParams, + stripQueryParamsForDomains, + stripQueryParams, + stripFragments + }); + const pageUrl = encodeURIComponent(processedUrl); // Adjusted for pages api.url?prefix=test (to add params with '&') as well as api.url (to add params with '?') const joiner = neuwoApiUrl.indexOf("?") < 0 ? "?" : "&"; const neuwoApiUrlFull = @@ -75,8 +116,13 @@ export function getBidRequestData(reqBidsConfigObj, callback, config, userConsen const success = (response) => { logInfo(MODULE_NAME, "getBidRequestData:", "Neuwo API raw response:", response); try { - const responseJson = JSON.parse(response); - injectIabCategories(responseJson, reqBidsConfigObj, iabContentTaxonomyVersion); + const responseParsed = JSON.parse(response); + + if (enableCache) { + globalCachedResponse = responseParsed; + } + + injectIabCategories(responseParsed, reqBidsConfigObj, iabContentTaxonomyVersion); } catch (ex) { logError(MODULE_NAME, "getBidRequestData:", "Error while processing Neuwo API response", ex); } @@ -88,15 +134,102 @@ export function getBidRequestData(reqBidsConfigObj, callback, config, userConsen callback(); }; - ajax(neuwoApiUrlFull, { success, error }, null); + if (enableCache && globalCachedResponse) { + logInfo(MODULE_NAME, "getBidRequestData:", "Using cached response:", globalCachedResponse); + injectIabCategories(globalCachedResponse, reqBidsConfigObj, iabContentTaxonomyVersion); + callback(); + } else { + logInfo(MODULE_NAME, "getBidRequestData:", "Calling Neuwo API Endpoint: ", neuwoApiUrlFull); + ajax(neuwoApiUrlFull, { success, error }, null); + } } // // HELPER FUNCTIONS // +/** + * Cleans a URL by stripping query parameters and/or fragments based on the provided configuration. + * + * @param {string} url The URL to clean. + * @param {Object} options Cleaning options. + * @param {boolean} [options.stripAllQueryParams] If true, strips all query parameters. + * @param {string[]} [options.stripQueryParamsForDomains] List of domains for which to strip all query params. + * @param {string[]} [options.stripQueryParams] List of specific query parameter names to strip. + * @param {boolean} [options.stripFragments] If true, strips URL fragments (hash). + * @returns {string} The cleaned URL. + */ +export function cleanUrl(url, options = {}) { + const { stripAllQueryParams, stripQueryParamsForDomains, stripQueryParams, stripFragments } = options; + + if (!url) { + logInfo(MODULE_NAME, "cleanUrl:", "Empty or null URL provided, returning as-is"); + return url; + } + + logInfo(MODULE_NAME, "cleanUrl:", "Input URL:", url, "Options:", options); + + try { + const urlObj = new URL(url); + + // Strip fragments if requested + if (stripFragments === true) { + urlObj.hash = ""; + logInfo(MODULE_NAME, "cleanUrl:", "Stripped fragment from URL"); + } + + // Option 1: Strip all query params unconditionally + if (stripAllQueryParams === true) { + urlObj.search = ""; + const cleanedUrl = urlObj.toString(); + logInfo(MODULE_NAME, "cleanUrl:", "Output URL:", cleanedUrl); + return cleanedUrl; + } + + // Option 2: Strip all query params for specific domains + if (Array.isArray(stripQueryParamsForDomains) && stripQueryParamsForDomains.length > 0) { + const hostname = urlObj.hostname; + const shouldStripForDomain = stripQueryParamsForDomains.some(domain => { + // Support exact match or subdomain match + return hostname === domain || hostname.endsWith("." + domain); + }); + + if (shouldStripForDomain) { + urlObj.search = ""; + const cleanedUrl = urlObj.toString(); + logInfo(MODULE_NAME, "cleanUrl:", "Output URL:", cleanedUrl); + return cleanedUrl; + } + } + + // Option 3: Strip specific query parameters + // Caveats: + // - "?=value" is treated as query parameter with key "" and value "value" + // - "??" is treated as query parameter with key "?" and value "" + if (Array.isArray(stripQueryParams) && stripQueryParams.length > 0) { + const queryParams = urlObj.searchParams; + logInfo(MODULE_NAME, "cleanUrl:", `Query parameters to strip: ${stripQueryParams}`); + stripQueryParams.forEach(param => { + queryParams.delete(param); + }); + urlObj.search = queryParams.toString(); + const cleanedUrl = urlObj.toString(); + logInfo(MODULE_NAME, "cleanUrl:", "Output URL:", cleanedUrl); + return cleanedUrl; + } + + const finalUrl = urlObj.toString(); + logInfo(MODULE_NAME, "cleanUrl:", "Output URL:", finalUrl); + return finalUrl; + } catch (e) { + logError(MODULE_NAME, "cleanUrl:", "Error cleaning URL:", e); + return url; + } +} + /** * Injects data into the OpenRTB 2.x global fragments of the bid request object. + * * @param {Object} reqBidsConfigObj The main bid request configuration object. * @param {string} path The dot-notation path where the data should be injected (e.g., 'site.content.data'). * @param {*} data The data to inject at the specified path. @@ -109,6 +242,7 @@ export function injectOrtbData(reqBidsConfigObj, path, data) { /** * Builds an IAB category data object for use in OpenRTB. + * * @param {Object} marketingCategories Marketing Categories returned by Neuwo API. * @param {string[]} tiers The tier keys to extract from marketingCategories. * @param {number} segtax The IAB taxonomy version Id. @@ -141,12 +275,13 @@ export function buildIabData(marketingCategories, tiers, segtax) { /** * Processes the Neuwo API response to build and inject IAB content and audience categories * into the bid request object. - * @param {Object} responseJson The parsed JSON response from the Neuwo API. + * + * @param {Object} responseParsed The parsed JSON response from the Neuwo API. * @param {Object} reqBidsConfigObj The bid request configuration object to be modified. * @param {string} iabContentTaxonomyVersion The version of the IAB content taxonomy to use for segtax mapping. */ -function injectIabCategories(responseJson, reqBidsConfigObj, iabContentTaxonomyVersion) { - const marketingCategories = responseJson.marketing_categories; +function injectIabCategories(responseParsed, reqBidsConfigObj, iabContentTaxonomyVersion) { + const marketingCategories = responseParsed.marketing_categories; if (!marketingCategories) { logError(MODULE_NAME, "injectIabCategories:", "No Marketing Categories in Neuwo API response."); diff --git a/modules/neuwoRtdProvider.md b/modules/neuwoRtdProvider.md index acd3f27d3..804130be1 100644 --- a/modules/neuwoRtdProvider.md +++ b/modules/neuwoRtdProvider.md @@ -63,41 +63,98 @@ ortb2: { } ``` -To get started, you can generate your API token at [https://neuwo.ai/generatetoken/](https://neuwo.ai/generatetoken/) or [contact us here](https://neuwo.ai/contact-us/). +To get started, you can generate your API token at [https://neuwo.ai/generatetoken/](https://neuwo.ai/generatetoken/), send us an email to [neuwo-helpdesk@neuwo.ai](mailto:neuwo-helpdesk@neuwo.ai) or [contact us here](https://neuwo.ai/contact-us/). ## Configuration -> **Important:** You must add the domain (origin) where Prebid.js is running to the list of allowed origins in Neuwo Edge API configuration. If you have problems, [contact us here](https://neuwo.ai/contact-us/). +> **Important:** You must add the domain (origin) where Prebid.js is running to the list of allowed origins in Neuwo Edge API configuration. If you have problems, send us an email to [neuwo-helpdesk@neuwo.ai](mailto:neuwo-helpdesk@neuwo.ai) or [contact us here](https://neuwo.ai/contact-us/). This module is configured as part of the `realTimeData.dataProviders` object. ```javascript pbjs.setConfig({ realTimeData: { - dataProviders: [{ - name: 'NeuwoRTDModule', - params: { - neuwoApiUrl: '', - neuwoApiToken: '', - iabContentTaxonomyVersion: '3.0', - } - }] - } + auctionDelay: 500, // Value can be adjusted based on the needs + dataProviders: [ + { + name: "NeuwoRTDModule", + waitForIt: true, + params: { + neuwoApiUrl: "", + neuwoApiToken: "", + iabContentTaxonomyVersion: "3.0", + enableCache: true, // Default: true. Caches API responses to avoid redundant requests + }, + }, + ], + }, }); ``` **Parameters** -| Name | Type | Required | Default | Description | -| :--------------------------------- | :----- | :------- | :------ | :------------------------------------------------------------------------------------------------ | -| `name` | String | Yes | | The name of the module, which is `NeuwoRTDModule`. | -| `params` | Object | Yes | | Container for module-specific parameters. | -| `params.neuwoApiUrl` | String | Yes | | The endpoint URL for the Neuwo Edge API. | -| `params.neuwoApiToken` | String | Yes | | Your unique API token provided by Neuwo. | -| `params.iabContentTaxonomyVersion` | String | No | `'3.0'` | Specifies the version of the IAB Content Taxonomy to be used. Supported values: `'2.2'`, `'3.0'`. | +| Name | Type | Required | Default | Description | +| :---------------------------------- | :------- | :------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | String | Yes | | The name of the module, which is `NeuwoRTDModule`. | +| `params` | Object | Yes | | Container for module-specific parameters. | +| `params.neuwoApiUrl` | String | Yes | | The endpoint URL for the Neuwo Edge API. | +| `params.neuwoApiToken` | String | Yes | | Your unique API token provided by Neuwo. | +| `params.iabContentTaxonomyVersion` | String | No | `'3.0'` | Specifies the version of the IAB Content Taxonomy to be used. Supported values: `'2.2'`, `'3.0'`. | +| `params.enableCache` | Boolean | No | `true` | If `true`, caches API responses to avoid redundant requests for the same page during the session. Set to `false` to disable caching and make a fresh API call on every bid request. | +| `params.stripAllQueryParams` | Boolean | No | `false` | If `true`, strips all query parameters from the URL before analysis. Takes precedence over other stripping options. | +| `params.stripQueryParamsForDomains` | String[] | No | `[]` | List of domains for which to strip **all** query parameters. When a domain matches, all query params are removed for that domain and all its subdomains (e.g., `'example.com'` strips params for both `'example.com'` and `'sub.example.com'`). This option takes precedence over `stripQueryParams` for matching domains. | +| `params.stripQueryParams` | String[] | No | `[]` | List of specific query parameter names to strip from the URL (e.g., `['utm_source', 'fbclid']`). Other parameters are preserved. Only applies when the domain does not match `stripQueryParamsForDomains`. | +| `params.stripFragments` | Boolean | No | `false` | If `true`, strips URL fragments (hash, e.g., `#section`) from the URL before analysis. | + +### API Response Caching + +By default, the module caches API responses during the page session to optimise performance and reduce redundant API calls. This behaviour can be disabled by setting `enableCache: false` if needed for dynamic content scenarios. + +### URL Cleaning Options + +The module provides optional URL cleaning capabilities to strip query parameters and/or fragments from the analysed URL before sending it to the Neuwo API. This can be useful for privacy, caching, or analytics purposes. + +**Example with URL cleaning:** + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 500, // Value can be adjusted based on the needs + dataProviders: [ + { + name: "NeuwoRTDModule", + waitForIt: true, + params: { + neuwoApiUrl: "", + neuwoApiToken: "", + iabContentTaxonomyVersion: "3.0", + + // Option 1: Strip all query parameters from the URL + stripAllQueryParams: true, + + // Option 2: Strip all query parameters only for specific domains + // stripQueryParamsForDomains: ['example.com', 'another-domain.com'], + + // Option 3: Strip specific query parameters by name + // stripQueryParams: ['utm_source', 'utm_campaign', 'fbclid'], + + // Optional: Strip URL fragments (hash) + stripFragments: true, + }, + }, + ], + }, +}); +``` ## Local Development +Install the exact versions of packages specified in the lockfile: + +```bash +npm ci +``` + > **Linux** Linux might require exporting the following environment variable before running the commands below: > `export CHROME_BIN=/usr/bin/chromium` @@ -110,20 +167,38 @@ npx gulp serve --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter For a faster build without tests: ```bash -npx gulp serve-fast --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter --notests +npx gulp serve-fast --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter ``` After starting the server, you can access the example page at: [http://localhost:9999/integrationExamples/gpt/neuwoRtdProvider_example.html](http://localhost:9999/integrationExamples/gpt/neuwoRtdProvider_example.html) ### Add development tools if necessary + If you don't have gulp-cli installed globally, run the following command in your Prebid.js source folder: + ```bash npm i -g gulp-cli ``` +## Linting + +To lint the module: + +```bash +npx eslint 'modules/neuwoRtdProvider.js' --cache --cache-strategy content +``` + ## Testing + To run the module-specific tests: + +```bash +npx gulp test-only --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter --file=test/spec/modules/euwoRtdProvider_spec.js +``` + +Skip building, if the project has already been built: + ```bash -npx gulp test-only --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter --file=test/spec/modules/neuwoRtdProvider_spec.js -``` \ No newline at end of file +npx gulp test-only-nobuild --file=test/spec/modules/neuwoRtdProvider_spec.js +``` diff --git a/modules/ooloAnalyticsAdapter.js b/modules/ooloAnalyticsAdapter.js index 22b8476ef..2bcacd92d 100644 --- a/modules/ooloAnalyticsAdapter.js +++ b/modules/ooloAnalyticsAdapter.js @@ -22,6 +22,7 @@ const prebidVersion = '$prebid.version$' const analyticsType = 'endpoint' const ADAPTER_CODE = 'oolo' const AUCTION_END_SEND_TIMEOUT = 1500 +// TODO: consider using the Prebid-generated page view ID instead of generating a custom one export const PAGEVIEW_ID = +generatePageViewId() const { diff --git a/modules/openadsServerBidAdapter/ortbConverter.js b/modules/openadsServerBidAdapter/ortbConverter.js index e53b84f05..ee04d4676 100644 --- a/modules/openadsServerBidAdapter/ortbConverter.js +++ b/modules/openadsServerBidAdapter/ortbConverter.js @@ -27,10 +27,10 @@ const BIDDER_SPECIFIC_REQUEST_PROPS = new Set(['bidderCode', 'bidderRequestId', const getMinimumFloor = (() => { const getMin = minimum(currencyCompare(floor => [floor.bidfloor, floor.bidfloorcur])); return function(candidates) { - let min; + let min = null; for (const candidate of candidates) { if (candidate?.bidfloorcur == null || candidate?.bidfloor == null) return null; - min = min == null ? candidate : getMin(min, candidate); + min = min === null ? candidate : getMin(min, candidate); } return min; } @@ -133,7 +133,7 @@ const PBS_CONVERTER = ortbConverter({ // also, take overrides from s2sConfig.adapterOptions const adapterOptions = context.s2sBidRequest.s2sConfig.adapterOptions; for (const req of context.actualBidRequests.values()) { - setImpBidParams(imp, req, context, context); + setImpBidParams(imp, req); if (adapterOptions && adapterOptions[req.bidder]) { Object.assign(imp.ext.openads.bidder[req.bidder], adapterOptions[req.bidder]); } @@ -251,6 +251,10 @@ const PBS_CONVERTER = ortbConverter({ extPrebidAliases(orig, ortbRequest, proxyBidderRequest, context) { // override alias processing to do it for each bidder in the request context.actualBidderRequests.forEach(req => orig(ortbRequest, req, context)); + }, + extPrebidPageViewIds(orig, ortbRequest, proxyBidderRequest, context) { + // override page view ID processing to do it for each bidder in the request + context.actualBidderRequests.forEach(req => orig(ortbRequest, req, context)); } }, [RESPONSE]: { diff --git a/modules/optimeraRtdProvider.js b/modules/optimeraRtdProvider.js index 3a46184e9..153fbea29 100644 --- a/modules/optimeraRtdProvider.js +++ b/modules/optimeraRtdProvider.js @@ -56,9 +56,6 @@ export let transmitWithBidRequests = 'allow'; /** @type {Object} */ export let optimeraTargeting = {}; -/** @type {boolean} */ -export let fetchScoreFile = true; - /** @type {RtdSubmodule} */ export const optimeraSubmodule = { name: 'optimeraRTD', @@ -84,7 +81,6 @@ export function init(moduleConfig) { if (_moduleParams.transmitWithBidRequests) { transmitWithBidRequests = _moduleParams.transmitWithBidRequests; } - setScoresURL(); return true; } logError('Optimera clientID is missing in the Optimera RTD configuration.'); @@ -111,9 +107,9 @@ export function setScoresURL() { if (scoresURL !== newScoresURL) { scoresURL = newScoresURL; - fetchScoreFile = true; + return true; } else { - fetchScoreFile = false; + return false; } } @@ -125,6 +121,12 @@ export function setScoresURL() { * @param {object} userConsent */ export function fetchScores(reqBidsConfigObj, callback, config, userConsent) { + // If setScoresURL returns false, no need to re-fetch the score file + if (!setScoresURL()) { + callback(); + return; + } + // Else, fetch the score file const ajax = ajaxBuilder(); ajax(scoresURL, { success: (res, req) => { diff --git a/modules/permutiveIdentityManagerIdSystem.js b/modules/permutiveIdentityManagerIdSystem.js index 5dc12d44e..f38496444 100644 --- a/modules/permutiveIdentityManagerIdSystem.js +++ b/modules/permutiveIdentityManagerIdSystem.js @@ -10,6 +10,7 @@ import {prefixLog, safeJSONParse} from '../src/utils.js' */ const MODULE_NAME = 'permutiveIdentityManagerId' +const PERMUTIVE_GVLID = 361 const PERMUTIVE_ID_DATA_STORAGE_KEY = 'permutive-prebid-id' const ID5_DOMAIN = 'id5-sync.com' @@ -80,6 +81,7 @@ export const permutiveIdentityManagerIdSubmodule = { * @type {string} */ name: MODULE_NAME, + gvlid: PERMUTIVE_GVLID, /** * decode the stored id value for passing to bid requests diff --git a/modules/permutiveRtdProvider.js b/modules/permutiveRtdProvider.js index bb06d2d13..886dc8b3b 100644 --- a/modules/permutiveRtdProvider.js +++ b/modules/permutiveRtdProvider.js @@ -17,6 +17,7 @@ import {MODULE_TYPE_RTD} from '../src/activities/modules.js'; */ const MODULE_NAME = 'permutive' +const PERMUTIVE_GVLID = 361 const logger = prefixLog('[PermutiveRTD]') @@ -466,6 +467,7 @@ let permutiveSDKInRealTime = false /** @type {RtdSubmodule} */ export const permutiveSubmodule = { name: MODULE_NAME, + gvlid: PERMUTIVE_GVLID, getBidRequestData: function (reqBidsConfigObj, callback, customModuleConfig) { const completeBidRequestData = () => { logger.logInfo(`Request data updated`) diff --git a/package-lock.json b/package-lock.json index 1be28a259..c11f02183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openads.js", - "version": "10.16.0", + "version": "10.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openads.js", - "version": "10.16.0", + "version": "10.17.0", "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.28.4", @@ -25,6 +25,7 @@ "iab-adcom": "^1.0.6", "iab-native": "^1.0.0", "iab-openrtb": "^1.0.1", + "karma-safarinative-launcher": "^1.1.0", "klona": "^2.0.6", "live-connect-js": "^7.2.0" }, @@ -32,6 +33,7 @@ "@babel/eslint-parser": "^7.16.5", "@babel/plugin-transform-runtime": "^7.27.4", "@babel/register": "^7.28.3", + "@chiragrupani/karma-chromium-edge-launcher": "^2.4.1", "@eslint/compat": "^1.3.1", "@types/google-publisher-tag": "^1.20250210.0", "@wdio/browserstack-service": "^9.19.1", @@ -1610,9 +1612,14 @@ "uuid": "9.0.1" } }, + "node_modules/@chiragrupani/karma-chromium-edge-launcher": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@chiragrupani/karma-chromium-edge-launcher/-/karma-chromium-edge-launcher-2.4.1.tgz", + "integrity": "sha512-HwTlN4dk7dnL9m5nEonq7cI3Wa787wYfGVWeb4oWPMySIEhFpA7/BYQ8zMbpQ4YkSQxVnvY1502aWdbI3w7DeA==", + "dev": true + }, "node_modules/@colors/colors": { "version": "1.5.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.1.90" @@ -2239,9 +2246,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3402,7 +3409,6 @@ }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", - "dev": true, "license": "MIT" }, "node_modules/@stylistic/eslint-plugin": { @@ -3471,12 +3477,10 @@ }, "node_modules/@types/cookie": { "version": "0.4.1", - "dev": true, "license": "MIT" }, "node_modules/@types/cors": { "version": "2.8.17", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3570,7 +3574,6 @@ }, "node_modules/@types/node": { "version": "20.14.2", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -6121,7 +6124,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6147,7 +6149,6 @@ }, "node_modules/anymatch": { "version": "3.1.3", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -6784,7 +6785,6 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/bare-events": { @@ -6875,7 +6875,6 @@ }, "node_modules/base64id": { "version": "2.0.0", - "dev": true, "license": "MIT", "engines": { "node": "^4.5.0 || >= 5.9" @@ -6929,7 +6928,6 @@ }, "node_modules/binary-extensions": { "version": "2.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7013,7 +7011,6 @@ "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -7036,7 +7033,6 @@ }, "node_modules/body-parser/node_modules/debug": { "version": "2.6.9", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -7046,7 +7042,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -7065,14 +7060,12 @@ }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", - "dev": true, "license": "MIT" }, "node_modules/body-parser/node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7088,7 +7081,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -7124,7 +7116,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7133,7 +7124,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -7476,7 +7466,6 @@ }, "node_modules/chokidar": { "version": "3.6.0", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -7803,7 +7792,6 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "dev": true, "license": "MIT" }, "node_modules/concat-with-sourcemaps": { @@ -7816,7 +7804,6 @@ }, "node_modules/connect": { "version": "3.7.0", - "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -7838,7 +7825,6 @@ }, "node_modules/connect/node_modules/debug": { "version": "2.6.9", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -7846,7 +7832,6 @@ }, "node_modules/connect/node_modules/finalhandler": { "version": "1.1.2", - "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -7863,12 +7848,10 @@ }, "node_modules/connect/node_modules/ms": { "version": "2.0.0", - "dev": true, "license": "MIT" }, "node_modules/connect/node_modules/on-finished": { "version": "2.3.0", - "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -7879,7 +7862,6 @@ }, "node_modules/connect/node_modules/statuses": { "version": "1.5.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7971,7 +7953,6 @@ }, "node_modules/cors": { "version": "2.8.5", - "dev": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -8014,10 +7995,11 @@ "dev": true }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -8458,7 +8440,6 @@ }, "node_modules/custom-event": { "version": "1.0.1", - "dev": true, "license": "MIT" }, "node_modules/d": { @@ -8531,7 +8512,6 @@ }, "node_modules/date-format": { "version": "4.0.14", - "dev": true, "license": "MIT", "engines": { "node": ">=4.0" @@ -8812,7 +8792,6 @@ }, "node_modules/di": { "version": "0.0.1", - "dev": true, "license": "MIT" }, "node_modules/diff": { @@ -8842,7 +8821,6 @@ }, "node_modules/dom-serialize": { "version": "2.2.1", - "dev": true, "license": "MIT", "dependencies": { "custom-event": "~1.0.0", @@ -9127,7 +9105,6 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/emojis-list": { @@ -9140,7 +9117,6 @@ }, "node_modules/encodeurl": { "version": "1.0.2", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -9179,7 +9155,6 @@ }, "node_modules/engine.io": { "version": "6.6.2", - "dev": true, "license": "MIT", "dependencies": { "@types/cookie": "^0.4.1", @@ -9199,7 +9174,6 @@ }, "node_modules/engine.io-parser": { "version": "5.2.3", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -9207,7 +9181,6 @@ }, "node_modules/engine.io/node_modules/cookie": { "version": "0.7.2", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9229,7 +9202,6 @@ }, "node_modules/ent": { "version": "2.2.0", - "dev": true, "license": "MIT" }, "node_modules/entities": { @@ -10596,7 +10568,6 @@ }, "node_modules/eventemitter3": { "version": "4.0.7", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -10886,7 +10857,6 @@ }, "node_modules/extend": { "version": "3.0.2", - "dev": true, "license": "MIT" }, "node_modules/extend-shallow": { @@ -11151,7 +11121,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -11281,12 +11250,10 @@ }, "node_modules/flatted": { "version": "3.3.1", - "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { "version": "1.15.6", - "dev": true, "funding": [ { "type": "individual", @@ -11462,7 +11429,6 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -11595,7 +11561,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -11764,7 +11729,6 @@ }, "node_modules/glob-parent": { "version": "5.1.2", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -12847,7 +12811,6 @@ }, "node_modules/http-proxy": { "version": "1.18.1", - "dev": true, "license": "MIT", "dependencies": { "eventemitter3": "^4.0.0", @@ -13022,7 +12985,6 @@ }, "node_modules/inflight": { "version": "1.0.6", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -13194,7 +13156,6 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -13332,7 +13293,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13354,7 +13314,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13381,7 +13340,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -13426,7 +13384,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -13680,7 +13637,6 @@ }, "node_modules/isbinaryfile": { "version": "4.0.10", - "dev": true, "license": "MIT", "engines": { "node": ">= 8.0.0" @@ -14453,7 +14409,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -14577,7 +14535,6 @@ "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", - "dev": true, "license": "MIT", "dependencies": { "@colors/colors": "1.5.0", @@ -14861,8 +14818,17 @@ }, "node_modules/karma-safari-launcher": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/karma-safari-launcher/-/karma-safari-launcher-1.0.0.tgz", + "integrity": "sha512-qmypLWd6F2qrDJfAETvXDfxHvKDk+nyIjpH9xIeI3/hENr0U3nuqkxaftq73PfXZ4aOuOChA6SnLW4m4AxfRjQ==", "dev": true, - "license": "MIT", + "peerDependencies": { + "karma": ">=0.9" + } + }, + "node_modules/karma-safarinative-launcher": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/karma-safarinative-launcher/-/karma-safarinative-launcher-1.1.0.tgz", + "integrity": "sha512-vdMjdQDHkSUbOZc8Zq2K5bBC0yJGFEgfrKRJTqt0Um0SC1Rt8drS2wcN6UA3h4LgsL3f1pMcmRSvKucbJE8Qdg==", "peerDependencies": { "karma": ">=0.9" } @@ -14979,7 +14945,6 @@ }, "node_modules/karma/node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -14993,7 +14958,6 @@ }, "node_modules/karma/node_modules/cliui": { "version": "7.0.4", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -15003,7 +14967,6 @@ }, "node_modules/karma/node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -15014,12 +14977,10 @@ }, "node_modules/karma/node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/karma/node_modules/glob": { "version": "7.2.3", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -15038,7 +14999,6 @@ }, "node_modules/karma/node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -15049,7 +15009,6 @@ }, "node_modules/karma/node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -15065,7 +15024,6 @@ }, "node_modules/karma/node_modules/yargs": { "version": "16.2.0", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^7.0.2", @@ -15082,7 +15040,6 @@ }, "node_modules/karma/node_modules/yargs-parser": { "version": "20.2.9", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -15346,7 +15303,6 @@ }, "node_modules/log4js": { "version": "6.9.1", - "dev": true, "license": "Apache-2.0", "dependencies": { "date-format": "^4.0.14", @@ -15561,7 +15517,6 @@ }, "node_modules/mime": { "version": "2.6.0", - "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -15588,15 +15543,17 @@ } }, "node_modules/min-document": { - "version": "2.19.0", + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", "dev": true, + "license": "MIT", "dependencies": { "dom-walk": "^0.1.0" } }, "node_modules/minimatch": { "version": "3.1.2", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -15607,7 +15564,6 @@ }, "node_modules/minimist": { "version": "1.2.8", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15629,7 +15585,6 @@ }, "node_modules/mkdirp": { "version": "0.5.6", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -15819,7 +15774,9 @@ } }, "node_modules/mocha/node_modules/js-yaml": { - "version": "4.1.0", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -16428,7 +16385,6 @@ }, "node_modules/normalize-path": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16477,7 +16433,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16645,7 +16600,6 @@ }, "node_modules/once": { "version": "1.4.0", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -16962,7 +16916,6 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -17066,7 +17019,6 @@ }, "node_modules/picomatch": { "version": "2.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -17451,7 +17403,6 @@ }, "node_modules/qjobs": { "version": "1.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.9" @@ -17499,7 +17450,6 @@ "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -17515,7 +17465,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -17536,7 +17485,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -17710,7 +17658,6 @@ }, "node_modules/readdirp": { "version": "3.6.0", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -17869,7 +17816,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -17885,7 +17831,6 @@ }, "node_modules/requires-port": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -17976,7 +17921,6 @@ }, "node_modules/rfdc": { "version": "1.4.1", - "dev": true, "license": "MIT" }, "node_modules/rgb2hex": { @@ -17986,7 +17930,6 @@ }, "node_modules/rimraf": { "version": "3.0.2", - "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -18000,7 +17943,6 @@ }, "node_modules/rimraf/node_modules/glob": { "version": "7.2.3", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -18726,7 +18668,6 @@ }, "node_modules/socket.io": { "version": "4.8.0", - "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.4", @@ -18743,7 +18684,6 @@ }, "node_modules/socket.io-adapter": { "version": "2.5.5", - "dev": true, "license": "MIT", "dependencies": { "debug": "~4.3.4", @@ -18752,7 +18692,6 @@ }, "node_modules/socket.io-parser": { "version": "4.2.4", - "dev": true, "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -18803,7 +18742,6 @@ }, "node_modules/source-map": { "version": "0.6.1", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -19064,7 +19002,6 @@ }, "node_modules/streamroller": { "version": "3.1.5", - "dev": true, "license": "MIT", "dependencies": { "date-format": "^4.0.14", @@ -19077,7 +19014,6 @@ }, "node_modules/streamroller/node_modules/fs-extra": { "version": "8.1.0", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -19090,7 +19026,6 @@ }, "node_modules/streamroller/node_modules/jsonfile": { "version": "4.0.0", - "dev": true, "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" @@ -19098,7 +19033,6 @@ }, "node_modules/streamroller/node_modules/universalify": { "version": "0.1.2", - "dev": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -19138,7 +19072,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -19176,7 +19109,6 @@ }, "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -19436,7 +19368,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.9", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "license": "MIT", "dependencies": { @@ -19763,7 +19697,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.14" @@ -19784,7 +19717,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -20105,7 +20037,6 @@ }, "node_modules/ua-parser-js": { "version": "0.7.38", - "dev": true, "funding": [ { "type": "opencollective", @@ -20202,7 +20133,6 @@ }, "node_modules/undici-types": { "version": "5.26.5", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -20682,7 +20612,6 @@ }, "node_modules/void-elements": { "version": "2.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -21720,12 +21649,10 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "dev": true, "license": "ISC" }, "node_modules/ws": { "version": "8.17.1", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -21752,7 +21679,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 70feb1d59..b740aa73a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openads.js", - "version": "10.16.0", + "version": "10.17.0", "oaVersion": "1.5.0", "description": "Header Bidding Management Library", "main": "dist/src/prebid.public.ts", @@ -63,6 +63,7 @@ "@babel/eslint-parser": "^7.16.5", "@babel/plugin-transform-runtime": "^7.27.4", "@babel/register": "^7.28.3", + "@chiragrupani/karma-chromium-edge-launcher": "^2.4.1", "@eslint/compat": "^1.3.1", "@types/google-publisher-tag": "^1.20250210.0", "@wdio/browserstack-service": "^9.19.1", @@ -163,6 +164,7 @@ "iab-adcom": "^1.0.6", "iab-native": "^1.0.0", "iab-openrtb": "^1.0.1", + "karma-safarinative-launcher": "^1.1.0", "klona": "^2.0.6", "live-connect-js": "^7.2.0" }, diff --git a/src/adapterManager.ts b/src/adapterManager.ts index 2eb23990d..d627d93fc 100644 --- a/src/adapterManager.ts +++ b/src/adapterManager.ts @@ -67,6 +67,7 @@ import type { AnalyticsConfig, AnalyticsProvider, AnalyticsProviderConfig, } from "../libraries/analyticsAdapter/AnalyticsAdapter.ts"; +import {getGlobal} from "./prebidGlobal.ts"; export {gdprDataHandler, gppDataHandler, uspDataHandler, coppaDataHandler} from './consentHandler.js'; @@ -168,6 +169,7 @@ export interface BaseBidderRequest { */ bidderRequestId: Identifier; auctionId: Identifier; + pageViewId: Identifier; /** * The bidder associated with this request, or null in the case of stored impressions. */ @@ -525,6 +527,18 @@ const adapterManager = { return bidderRequest as T; } + const pbjsInstance = getGlobal(); + + function getPageViewIdForBidder(bidderCode: string | null): string { + if (!pbjsInstance.pageViewIdPerBidder) { + pbjsInstance.pageViewIdPerBidder = new Map(); + } + if (!pbjsInstance.pageViewIdPerBidder.has(bidderCode)) { + pbjsInstance.pageViewIdPerBidder.set(bidderCode, generateUUID()); + } + return pbjsInstance.pageViewIdPerBidder.get(bidderCode); + } + _s2sConfigs.forEach(s2sConfig => { const s2sParams = s2sActivityParams(s2sConfig); if (s2sConfig && s2sConfig.enabled && dep.isAllowed(ACTIVITY_FETCH_BIDS, s2sParams)) { @@ -536,11 +550,13 @@ const adapterManager = { (serverBidders.length === 0 && hasModuleBids ? [null] : serverBidders).forEach(bidderCode => { const tids = tidFor(extTids, bidderCode, () => ({})); const bidderRequestId = generateUUID(); + const pageViewId = getPageViewIdForBidder(bidderCode); const metrics = auctionMetrics.fork(); const bidderRequest = addOrtb2({ bidderCode, auctionId, bidderRequestId, + pageViewId, uniquePbsTid, bids: getBids({ bidderCode, @@ -584,10 +600,12 @@ const adapterManager = { clientBidders.forEach(bidderCode => { const tids = tidFor(extTids, bidderCode, () => ({})); const bidderRequestId = generateUUID(); + const pageViewId = getPageViewIdForBidder(bidderCode); const metrics = auctionMetrics.fork(); const bidderRequest = addOrtb2({ bidderCode, auctionId, + pageViewId, bidderRequestId, bids: getBids({ bidderCode, diff --git a/src/prebid.ts b/src/prebid.ts index 05a070c12..095bc6b85 100644 --- a/src/prebid.ts +++ b/src/prebid.ts @@ -106,6 +106,7 @@ declare module './prebidGlobal' { */ delayPrerendering?: boolean adUnits: AdUnitDefinition[]; + pageViewIdPerBidder: Map } } @@ -118,6 +119,7 @@ logInfo('OpenAds.js v$prebid.oaVersion$ based on Prebid.js v$prebid.version$ loa // create adUnit array pbjsInstance.adUnits = pbjsInstance.adUnits || []; +pbjsInstance.pageViewIdPerBidder = pbjsInstance.pageViewIdPerBidder || new Map(); function validateSizes(sizes, targLength?: number) { let cleanSizes = []; @@ -161,7 +163,7 @@ export function syncOrtb2(adUnit, mediaType) { deepSetValue(adUnit, `mediaTypes.${mediaType}.${key}`, ortbFieldValue); } else if (ortbFieldValue === undefined) { deepSetValue(adUnit, `ortb2Imp.${mediaType}.${key}`, mediaTypesFieldValue); - } else { + } else if (!deepEqual(mediaTypesFieldValue, ortbFieldValue)) { logWarn(`adUnit ${adUnit.code}: specifies conflicting ortb2Imp.${mediaType}.${key} and mediaTypes.${mediaType}.${key}, the latter will be ignored`, adUnit); deepSetValue(adUnit, `mediaTypes.${mediaType}.${key}`, ortbFieldValue); } @@ -489,6 +491,7 @@ declare module './prebidGlobal' { processQueue: typeof processQueue; triggerBilling: typeof triggerBilling; generateTID: typeof generateTID; + refreshPageViewId: typeof refreshPageViewId; } } @@ -1289,4 +1292,15 @@ function generateTID () { } addApiMethod('generateTID', generateTID); +/** + * Refreshes the previously generated page view ID. Can be used to instruct bidders + * that use page view ID to consider future auctions as part of a new page load. + */ +function refreshPageViewId() { + for (const key of pbjsInstance.pageViewIdPerBidder.keys()) { + pbjsInstance.pageViewIdPerBidder.set(key, generateUUID()); + } +} +addApiMethod('refreshPageViewId', refreshPageViewId); + export default pbjsInstance; diff --git a/src/types/common.d.ts b/src/types/common.d.ts index b636a6cba..3f385ab68 100644 --- a/src/types/common.d.ts +++ b/src/types/common.d.ts @@ -14,6 +14,12 @@ export type Currency = string; export type AdUnitCode = string; export type Size = [number, number]; export type ContextIdentifiers = { + /** + * Page view ID. Unique for a page view (one load of Prebid); can also be refreshed programmatically. + * Shared across all requests and responses within the page view, for the same bidder. + * Different bidders see a different page view ID. + */ + pageViewId: Identifier; /** * Auction ID. Unique for any given auction, but shared across all requests and responses within that auction. */ diff --git a/test/spec/banner_spec.js b/test/spec/banner_spec.js index fcf56ad6e..f60f20231 100644 --- a/test/spec/banner_spec.js +++ b/test/spec/banner_spec.js @@ -127,6 +127,23 @@ describe('banner', () => { assert.ok(logWarnSpy.calledOnce, 'expected warning was logged due to conflicting btype'); }); + it('should not warn if fields match', () => { + const adUnit = { + mediaTypes: { + banner: { + format: [{wratio: 1, hratio: 1}] + } + }, + ortb2Imp: { + banner: { + format: [{wratio: 1, hratio: 1}] + } + } + } + syncOrtb2(adUnit, 'banner'); + sinon.assert.notCalled(logWarnSpy); + }) + it('should omit sync if mediaType not present on adUnit', () => { const adUnit = { mediaTypes: { diff --git a/test/spec/libraries/greedy/greedyPromise_spec.js b/test/spec/libraries/greedy/greedyPromise_spec.js index c59f646ec..f5482b17f 100644 --- a/test/spec/libraries/greedy/greedyPromise_spec.js +++ b/test/spec/libraries/greedy/greedyPromise_spec.js @@ -196,18 +196,19 @@ describe('greedySetTimeout', () => { }, 10) }); - it('can be cleared', (done) => { - let cbRan = false; - const handle = greedySetTimeout(() => { - cbRan = true; - }, 5); - setTimeout(() => { + it('can be cleared', () => { + const clock = sinon.useFakeTimers(); + try { + let cbRan = false; + const handle = greedySetTimeout(() => { + cbRan = true; + }, 5); clearTimeout(handle); - setTimeout(() => { - expect(cbRan).to.be.false; - done() - }, 10) - }, 0) + clock.tick(10); + expect(cbRan).to.be.false; + } finally { + clock.restore(); + } }) }) }); diff --git a/test/spec/modules/connectIdSystem_spec.js b/test/spec/modules/connectIdSystem_spec.js index b65096068..48ef3a30f 100644 --- a/test/spec/modules/connectIdSystem_spec.js +++ b/test/spec/modules/connectIdSystem_spec.js @@ -77,10 +77,14 @@ describe('Yahoo ConnectID Submodule', () => { removeLocalStorageDataStub.restore(); }); - function invokeGetIdAPI(configParams, consentData) { - const result = connectIdSubmodule.getId({ + function invokeGetIdAPI(configParams, consentData, storageConfig) { + const config = { params: configParams - }, consentData); + }; + if (storageConfig) { + config.storage = storageConfig; + } + const result = connectIdSubmodule.getId(config, consentData); if (typeof result === 'object' && result.callback) { result.callback(sinon.stub()); } @@ -803,6 +807,130 @@ describe('Yahoo ConnectID Submodule', () => { expect(setLocalStorageStub.firstCall.args[0]).to.equal(STORAGE_KEY); expect(setLocalStorageStub.firstCall.args[1]).to.deep.equal(JSON.stringify(expectedStoredData)); }); + + it('stores the result in localStorage only when storage type is html5', () => { + getAjaxFnStub.restore(); + const dateNowStub = sinon.stub(Date, 'now'); + dateNowStub.returns(0); + const upsResponse = {connectid: 'html5only'}; + const expectedStoredData = { + connectid: 'html5only', + puid: PUBLISHER_USER_ID, + lastSynced: 0, + lastUsed: 0 + }; + invokeGetIdAPI({ + puid: PUBLISHER_USER_ID, + pixelId: PIXEL_ID + }, consentData, {type: 'html5'}); + const request = server.requests[0]; + request.respond( + 200, + {'Content-Type': 'application/json'}, + JSON.stringify(upsResponse) + ); + dateNowStub.restore(); + + expect(setCookieStub.called).to.be.false; + expect(setLocalStorageStub.calledOnce).to.be.true; + expect(setLocalStorageStub.firstCall.args[0]).to.equal(STORAGE_KEY); + expect(setLocalStorageStub.firstCall.args[1]).to.deep.equal(JSON.stringify(expectedStoredData)); + }); + + it('stores the result in cookie only when storage type is cookie', () => { + getAjaxFnStub.restore(); + const dateNowStub = sinon.stub(Date, 'now'); + dateNowStub.returns(0); + const upsResponse = {connectid: 'cookieonly'}; + const expectedStoredData = { + connectid: 'cookieonly', + puid: PUBLISHER_USER_ID, + lastSynced: 0, + lastUsed: 0 + }; + const expiryDelta = new Date(60 * 60 * 24 * 365 * 1000); + invokeGetIdAPI({ + puid: PUBLISHER_USER_ID, + pixelId: PIXEL_ID + }, consentData, {type: 'cookie'}); + const request = server.requests[0]; + request.respond( + 200, + {'Content-Type': 'application/json'}, + JSON.stringify(upsResponse) + ); + dateNowStub.restore(); + + expect(setCookieStub.calledOnce).to.be.true; + expect(setCookieStub.firstCall.args[0]).to.equal(STORAGE_KEY); + expect(setCookieStub.firstCall.args[1]).to.equal(JSON.stringify(expectedStoredData)); + expect(setCookieStub.firstCall.args[2]).to.equal(expiryDelta.toUTCString()); + expect(setLocalStorageStub.called).to.be.false; + }); + + it('does not sync localStorage to cookie when storage type is html5', () => { + const localStorageData = {connectId: 'foobarbaz'}; + getLocalStorageStub.withArgs(STORAGE_KEY).returns(localStorageData); + invokeGetIdAPI({ + he: HASHED_EMAIL, + pixelId: PIXEL_ID + }, consentData, {type: 'html5'}); + + expect(setCookieStub.called).to.be.false; + }); + + it('updates existing ID with html5 storage type without writing cookie', () => { + const last13Days = Date.now() - (60 * 60 * 24 * 1000 * 13); + const cookieData = {connectId: 'foobar', he: HASHED_EMAIL, lastSynced: last13Days}; + getCookieStub.withArgs(STORAGE_KEY).returns(JSON.stringify(cookieData)); + const dateNowStub = sinon.stub(Date, 'now'); + dateNowStub.returns(20); + const newCookieData = Object.assign({}, cookieData, {lastUsed: 20}) + const result = invokeGetIdAPI({ + he: HASHED_EMAIL, + pixelId: PIXEL_ID + }, consentData, {type: 'html5'}); + dateNowStub.restore(); + + expect(result).to.be.an('object').that.has.all.keys('id'); + expect(setCookieStub.called).to.be.false; + expect(setLocalStorageStub.calledOnce).to.be.true; + expect(setLocalStorageStub.firstCall.args[0]).to.equal(STORAGE_KEY); + expect(setLocalStorageStub.firstCall.args[1]).to.equal(JSON.stringify(newCookieData)); + }); + + it('stores the result in both storages when storage type is cookie&html5', () => { + getAjaxFnStub.restore(); + const dateNowStub = sinon.stub(Date, 'now'); + dateNowStub.returns(0); + const upsResponse = {connectid: 'both'}; + const expectedStoredData = { + connectid: 'both', + puid: PUBLISHER_USER_ID, + lastSynced: 0, + lastUsed: 0 + }; + const expiryDelta = new Date(60 * 60 * 24 * 365 * 1000); + invokeGetIdAPI({ + puid: PUBLISHER_USER_ID, + pixelId: PIXEL_ID + }, consentData, {type: 'cookie&html5'}); + const request = server.requests[0]; + request.respond( + 200, + {'Content-Type': 'application/json'}, + JSON.stringify(upsResponse) + ); + dateNowStub.restore(); + + expect(setCookieStub.calledOnce).to.be.true; + expect(setCookieStub.firstCall.args[0]).to.equal(STORAGE_KEY); + expect(setCookieStub.firstCall.args[1]).to.equal(JSON.stringify(expectedStoredData)); + expect(setCookieStub.firstCall.args[2]).to.equal(expiryDelta.toUTCString()); + expect(setLocalStorageStub.calledOnce).to.be.true; + expect(setLocalStorageStub.firstCall.args[0]).to.equal(STORAGE_KEY); + expect(setLocalStorageStub.firstCall.args[1]).to.deep.equal(JSON.stringify(expectedStoredData)); + }); }); }); describe('userHasOptedOut()', () => { diff --git a/test/spec/modules/openadsServerBidAdapter_spec.js b/test/spec/modules/openadsServerBidAdapter_spec.js index 721f91747..01508463b 100644 --- a/test/spec/modules/openadsServerBidAdapter_spec.js +++ b/test/spec/modules/openadsServerBidAdapter_spec.js @@ -752,6 +752,7 @@ describe('S2S Adapter', function () { 'auctionId': '173afb6d132ba3', 'bidderRequestId': '3d1063078dfcc8', 'tid': '437fbbf5-33f5-487a-8e16-a7112903cfe5', + 'pageViewId': '84dfd20f-0a5a-4ac6-a86b-91569066d4f4', 'bids': [ { 'bidder': 'appnexus', @@ -2373,7 +2374,7 @@ describe('S2S Adapter', function () { }; adapter.callBids(req, BID_REQUESTS, addBidResponse, done, ajax); const payload = JSON.parse(server.requests[0].requestBody); - const permissions = payload.ext.prebid?.data?.eidpermissions; + const permissions = payload.ext.openads?.data?.eidpermissions; if (permissions) { permissions.forEach(p => { expect(p.bidders).to.be.an('array').that.is.not.empty; @@ -2499,7 +2500,7 @@ describe('S2S Adapter', function () { expect(requestBid.ext.openads.targeting.includewinners).to.equal(true); }); - it('adds extPrebid s2sConfig video.ext.openads to request for ORTB', function () { + it('adds custom property in s2sConfig.extPrebid to request for ORTB', function () { const s2sConfig = Object.assign({}, CONFIG, { extPrebid: { foo: 'bar' @@ -2561,7 +2562,7 @@ describe('S2S Adapter', function () { }); }); - it('overrides request.ext.openads properties using s2sConfig video.ext.openads values for ORTB', function () { + it('overrides request.ext.openads properties using s2sConfig.extPrebid values for ORTB', function () { const s2sConfig = Object.assign({}, CONFIG, { extPrebid: { targeting: { @@ -2594,7 +2595,7 @@ describe('S2S Adapter', function () { }); }); - it('overrides request.ext.openads properties using s2sConfig video.ext.openads values for ORTB', function () { + it('overrides request.ext.openads properties and adds custom property from s2sConfig.extPrebid for ORTB', function () { const s2sConfig = Object.assign({}, CONFIG, { extPrebid: { cache: { @@ -2897,6 +2898,21 @@ describe('S2S Adapter', function () { expect(parsedRequestBody.ext.openads.multibid).to.deep.equal(expected); }); + it('passes page view IDs per bidder in request', function () { + const clonedBidRequest = utils.deepClone(BID_REQUESTS[0]); + clonedBidRequest.bidderCode = 'some-other-bidder'; + clonedBidRequest.pageViewId = '490a1cbc-a03c-429a-b212-ba3649ca820c'; + const bidRequests = [BID_REQUESTS[0], clonedBidRequest]; + const expected = { + appnexus: '84dfd20f-0a5a-4ac6-a86b-91569066d4f4', + 'some-other-bidder': '490a1cbc-a03c-429a-b212-ba3649ca820c' + }; + + adapter.callBids(REQUEST, bidRequests, addBidResponse, done, ajax); + const parsedRequestBody = JSON.parse(server.requests[0].requestBody); + expect(parsedRequestBody.ext.openads.page_view_ids).to.deep.equal(expected); + }); + it('sets and passes pbjs version in request if channel does not exist in s2sConfig', () => { const s2sBidRequest = utils.deepClone(REQUEST); const bidRequests = utils.deepClone(BID_REQUESTS); @@ -3615,7 +3631,7 @@ describe('S2S Adapter', function () { expect(response).to.have.property('cpm', 10); }); - it('handles response cache from ext.prebid.cache.vastXml', function () { + it('handles response cache from ext.openads.cache.vastXml', function () { const s2sConfig = Object.assign({}, CONFIG, { endpoint: { p1Consent: 'https://prebidserverurl/openrtb2/auction?querystring=param' diff --git a/test/spec/ortbConverter/pbsExtensions/params_spec.js b/test/spec/ortbConverter/pbsExtensions/params_spec.js index 561a17353..b624cf84c 100644 --- a/test/spec/ortbConverter/pbsExtensions/params_spec.js +++ b/test/spec/ortbConverter/pbsExtensions/params_spec.js @@ -1,20 +1,9 @@ import {setImpBidParams} from '../../../../libraries/pbsExtensions/processors/params.js'; describe('pbjs -> ortb bid params to imp[].ext.openads.BIDDER', () => { - let bidderRegistry, index, adUnit; - beforeEach(() => { - bidderRegistry = {}; - adUnit = {code: 'mockAdUnit'}; - index = { - getAdUnit() { - return adUnit; - } - } - }); - - function setParams(bidRequest, context, deps = {}) { + function setParams(bidRequest = {}) { const imp = {}; - setImpBidParams(imp, bidRequest, context, Object.assign({bidderRegistry, index}, deps)) + setImpBidParams(imp, bidRequest) return imp; } diff --git a/test/spec/ortbConverter/pbsExtensions/video_spec.js b/test/spec/ortbConverter/pbsExtensions/video_spec.js index 95541d297..489bb37ec 100644 --- a/test/spec/ortbConverter/pbsExtensions/video_spec.js +++ b/test/spec/ortbConverter/pbsExtensions/video_spec.js @@ -26,14 +26,14 @@ describe('pbjs - ortb videoCacheKey based on ext.openads', () => { expect(resp).to.eql({mediaType: 'banner'}); }); - it('sets videoCacheKey, vastUrl from ext.prebid.cache.vastXml', () => { + it('sets videoCacheKey, vastUrl from ext.openads.cache.vastXml', () => { sinon.assert.match(setCache(EXT_PREBID_CACHE), { videoCacheKey: 'id', vastUrl: 'url' }); }); - it('sets videoCacheKey, vastUrl from ext.prebid.targeting', () => { + it('sets videoCacheKey, vastUrl from ext.openads.targeting', () => { sinon.assert.match(setCache({ ext: { openads: { diff --git a/test/test_deps.js b/test/test_deps.js index e35e813a5..7047e775d 100644 --- a/test/test_deps.js +++ b/test/test_deps.js @@ -39,6 +39,8 @@ sinon.useFakeXMLHttpRequest = fakeXhr.useFakeXMLHttpRequest.bind(fakeXhr); sinon.createFakeServer = fakeServer.create.bind(fakeServer); sinon.createFakeServerWithClock = fakeServerWithClock.create.bind(fakeServerWithClock); +localStorage.clear(); + require('test/helpers/global_hooks.js'); require('test/helpers/consentData.js'); require('test/helpers/prebidGlobal.js'); diff --git a/wdio.conf.js b/wdio.conf.js index d23fecd0b..53ccd216b 100644 --- a/wdio.conf.js +++ b/wdio.conf.js @@ -1,4 +1,5 @@ const shared = require('./wdio.shared.conf.js'); +const process = require('process'); const browsers = Object.fromEntries( Object.entries(require('./browsers.json')) @@ -28,7 +29,7 @@ function getCapabilities() { osVersion: browser.os_version, networkLogs: true, consoleLogs: 'verbose', - buildName: `Prebidjs E2E (${browser.browser} ${browser.browser_version}) ${new Date().toLocaleString()}` + buildName: process.env.BROWSERSTACK_BUILD_NAME }, acceptInsecureCerts: true, });